Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions packages/mcp/src/__tests__/cloud-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { registerCloudTools } from '../tools/cloud.js';

describe('cloud local mount tools', () => {
let tempRoot: string;
let mcpServer: McpServer;
let client: Client;
let runCommand: ReturnType<typeof vi.fn>;

beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'relaycast-cloud-tools-'));
runCommand = vi.fn();
mcpServer = new McpServer({ name: 'test', version: '0.1.0' });
registerCloudTools(mcpServer, {
relayfileBin: 'relayfile-test',
runCommand,
});

client = new Client({ name: 'test-client', version: '0.1.0' });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(ct), mcpServer.connect(st)]);
});

afterEach(async () => {
await client.close();
await mcpServer.close();
await rm(tempRoot, { recursive: true, force: true });
});

it('cloud.local-mount.ensure returns running without starting an existing mount', async () => {
runCommand.mockResolvedValueOnce({
stdout: JSON.stringify({
localDir: tempRoot,
workspaceId: 'ws_existing',
running: true,
conflictCount: 0,
pid: 123,
}),
stderr: '',
});

const result = await client.callTool({
name: 'cloud.local-mount.ensure',
arguments: { localDir: tempRoot },
});

expect(result.structuredContent).toEqual({
mountPath: tempRoot,
workspaceId: 'ws_existing',
status: 'running',
});
expect(runCommand).toHaveBeenCalledTimes(1);
expect(runCommand).toHaveBeenCalledWith(
'relayfile-test',
['status', '--local-dir', tempRoot, '--json'],
{ cwd: tempRoot },
);
});

it('cloud.local-mount.ensure starts relayfile setup when the mount is stopped', async () => {
runCommand
.mockResolvedValueOnce({
stdout: JSON.stringify({
localDir: tempRoot,
workspaceId: 'ws_existing',
running: false,
conflictCount: 0,
}),
stderr: '',
})
.mockResolvedValueOnce({ stdout: 'started\n', stderr: '' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
localDir: tempRoot,
workspaceId: 'ws_existing',
running: true,
conflictCount: 0,
pid: 456,
}),
stderr: '',
});

const result = await client.callTool({
name: 'cloud.local-mount.ensure',
arguments: { localDir: tempRoot },
});

expect(result.structuredContent).toEqual({
mountPath: tempRoot,
workspaceId: 'ws_existing',
status: 'started',
});
expect(runCommand).toHaveBeenNthCalledWith(
2,
'relayfile-test',
[
'setup',
'--local-dir',
tempRoot,
'--workspace',
`cloud-${path.basename(tempRoot)}`,
'--provider',
'none',
'--background',
'--no-open',
],
{ cwd: tempRoot },
);
});

it('cloud.local-mount.status reports synthesized conflict count from disk', async () => {
const relayDir = path.join(tempRoot, '.relay');
await mkdir(path.join(relayDir, 'conflicts', 'nested'), { recursive: true });
await writeFile(path.join(relayDir, 'state.json'), JSON.stringify({
workspaceId: 'ws_disk',
lastReconcileAt: '2026-05-21T12:00:00.000Z',
pendingConflicts: 1,
}));
await writeFile(path.join(relayDir, 'conflicts', 'file.local'), 'local edit');
await writeFile(path.join(relayDir, 'conflicts', 'nested', 'other.local'), 'other edit');
runCommand.mockRejectedValue(new Error('relayfile unavailable'));

const result = await client.callTool({
name: 'cloud.local-mount.status',
arguments: { localDir: tempRoot },
});

expect(result.structuredContent).toEqual({
running: false,
lastSync: '2026-05-21T12:00:00.000Z',
conflictCount: 2,
});
});

it('cloud.local-mount.stop is idempotent when no mount is running', async () => {
runCommand.mockResolvedValueOnce({
stdout: JSON.stringify({
localDir: tempRoot,
workspaceId: 'ws_existing',
running: false,
conflictCount: 0,
}),
stderr: '',
});

const result = await client.callTool({
name: 'cloud.local-mount.stop',
arguments: { localDir: tempRoot },
});

expect(result.structuredContent).toEqual({ stopped: true });
expect(runCommand).toHaveBeenCalledTimes(1);
});

it('cloud.local-mount.stop stops the workspace when status has a workspace id', async () => {
runCommand
.mockResolvedValueOnce({
stdout: JSON.stringify({
localDir: tempRoot,
workspaceId: 'ws_existing',
running: true,
conflictCount: 0,
pid: 789,
}),
stderr: '',
})
.mockResolvedValueOnce({ stdout: 'stopped\n', stderr: '' });

const result = await client.callTool({
name: 'cloud.local-mount.stop',
arguments: { localDir: tempRoot },
});

expect(result.structuredContent).toEqual({ stopped: true });
expect(runCommand).toHaveBeenNthCalledWith(
2,
'relayfile-test',
['stop', 'ws_existing'],
);
});
});
7 changes: 5 additions & 2 deletions packages/mcp/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ describe('createRelayMcpServer', () => {
await Promise.all([client.connect(ct), mcpServer.connect(st)]);
});

it('lists all 42 tools', async () => {
it('lists all 45 tools', async () => {
const tools = await client.listTools();
expect(tools.tools.length).toBe(42);
expect(tools.tools.length).toBe(45);
const toolNames = tools.tools.map((t) => t.name).sort();
expect(toolNames).toEqual([
'agent.add',
Expand All @@ -181,6 +181,9 @@ describe('createRelayMcpServer', () => {
'channel.leave',
'channel.list',
'channel.set_topic',
'cloud.local-mount.ensure',
'cloud.local-mount.status',
'cloud.local-mount.stop',
'integration.command.delete',
'integration.command.invoke',
'integration.command.list',
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { registerChannelTools } from './tools/channels.js';
import { registerMessagingTools } from './tools/messaging.js';
import { registerFeatureTools } from './tools/features.js';
import { registerProgrammabilityTools } from './tools/programmability.js';
import { registerCloudTools } from './tools/cloud.js';
import { registerSystemPrompt } from './prompts.js';
import { createInitialSession, type SessionState } from './types.js';
import { enablePiggyback } from './piggyback.js';
Expand Down Expand Up @@ -319,6 +320,7 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer {
registerMessagingTools(mcpServer, getAgentClient);
registerFeatureTools(mcpServer, getAgentClient);
registerProgrammabilityTools(mcpServer, getRelay, getAgentClient);
registerCloudTools(mcpServer);

// Register system prompt
registerSystemPrompt(mcpServer);
Expand Down
Loading
Loading