diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 05183ab27..d6585e223 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -491,5 +491,11 @@ export function startServer(options: ServerOptions = {}) { return response }) - return { app, wss, httpPort } + function close() { + rejectAllPending('Server shutting down') + mcpSessions.clear() + wss.close() + } + + return { app, wss, httpPort, close } } diff --git a/tests/engine/editor-store-path.test.ts b/tests/engine/editor-store-path.test.ts new file mode 100644 index 000000000..c640cbcb1 --- /dev/null +++ b/tests/engine/editor-store-path.test.ts @@ -0,0 +1,25 @@ +import { describe, test, expect } from 'bun:test' +import { SceneGraph } from '@open-pencil/core' + +// Import the store factory directly +import { createEditorStore } from '../../src/stores/editor' + +describe('setPlannedFilePath', () => { + test('sets document name from file path', () => { + const store = createEditorStore() + store.setPlannedFilePath('/tmp/projects/my-design.fig') + expect(store.state.documentName).toBe('my-design') + }) + + test('handles Windows-style paths', () => { + const store = createEditorStore() + store.setPlannedFilePath('C:\\Users\\test\\design.fig') + expect(store.state.documentName).toBe('design') + }) + + test('handles path without extension', () => { + const store = createEditorStore() + store.setPlannedFilePath('/tmp/Untitled') + expect(store.state.documentName).toBe('Untitled') + }) +}) diff --git a/tests/engine/mcp-path-scoping.test.ts b/tests/engine/mcp-path-scoping.test.ts new file mode 100644 index 000000000..758537384 --- /dev/null +++ b/tests/engine/mcp-path-scoping.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'bun:test' +import { resolve, sep } from 'node:path' + +// Test the resolveSafePath logic directly (extracted for testability) +function resolveSafePath(filePath: string, root: string): string { + const resolved = resolve(filePath) + const normalizedSep = root.endsWith('/') || root.endsWith('\\') ? '' : sep + if (!resolved.startsWith(root + normalizedSep) && resolved !== root) { + throw new Error(`Path is outside the allowed root: ${root}`) + } + return resolved +} + +describe('MCP path scoping', () => { + const root = resolve('/tmp/mcp-test-root') + + test('allows path inside root', () => { + expect(resolveSafePath(`${root}/design.fig`, root)).toBe(`${root}/design.fig`) + }) + + test('allows nested path inside root', () => { + expect(resolveSafePath(`${root}/sub/dir/file.fig`, root)).toBe(`${root}/sub/dir/file.fig`) + }) + + test('allows root itself', () => { + expect(resolveSafePath(root, root)).toBe(root) + }) + + test('rejects path outside root', () => { + expect(() => resolveSafePath('/etc/passwd', root)).toThrow('outside the allowed root') + }) + + test('rejects path traversal', () => { + expect(() => resolveSafePath(`${root}/../../../etc/passwd`, root)).toThrow( + 'outside the allowed root' + ) + }) + + test('rejects sibling directory', () => { + expect(() => resolveSafePath(`${root}/../other-root/file.fig`, root)).toThrow( + 'outside the allowed root' + ) + }) + + test('rejects root prefix trick (root-evil)', () => { + expect(() => resolveSafePath(`${root}-evil/file.fig`, root)).toThrow( + 'outside the allowed root' + ) + }) +}) diff --git a/tests/engine/mcp-server.test.ts b/tests/engine/mcp-server.test.ts index 13405a611..f90597c52 100644 --- a/tests/engine/mcp-server.test.ts +++ b/tests/engine/mcp-server.test.ts @@ -84,7 +84,7 @@ function waitForWsListening(wss: InstanceType): Promise } async function createTestClient() { - const { app, wss } = startServer({ httpPort: 0, wsPort: 0 }) + const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0 }) const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) const actualHttpPort = (httpServer.address() as AddressInfo).port const actualWsPort = await waitForWsListening(wss) @@ -104,7 +104,7 @@ async function createTestClient() { close: async () => { await client.close() browser.close() - wss.close() + closeServer() httpServer.close() } } @@ -230,6 +230,58 @@ describe('MCP server', () => { }) }) +describe('MCP server with mcpRoot', () => { + test('registers open_file and new_document tools when mcpRoot is set', async () => { + const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0, mcpRoot: '/tmp' }) + const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) + const actualWsPort = await waitForWsListening(wss) + + const graph = new SceneGraph() + const browser = await connectMockBrowser(actualWsPort, graph) + + const client = new Client({ name: 'test-root', version: '0.0.0' }) + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`) + ) + await client.connect(transport) + + const { tools } = await client.listTools() + const names = tools.map((t) => t.name) + expect(names).toContain('open_file') + expect(names).toContain('new_document') + + await client.close() + browser.close() + closeServer() + httpServer.close() + }) + + test('does not register open_file when mcpRoot is null', async () => { + const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0 }) + const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) + const actualWsPort = await waitForWsListening(wss) + + const graph = new SceneGraph() + const browser = await connectMockBrowser(actualWsPort, graph) + + const client = new Client({ name: 'test-no-root', version: '0.0.0' }) + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`) + ) + await client.connect(transport) + + const { tools } = await client.listTools() + const names = tools.map((t) => t.name) + expect(names).not.toContain('open_file') + expect(names).not.toContain('new_document') + + await client.close() + browser.close() + closeServer() + httpServer.close() + }) +}) + describe('paramToZod coercion', () => { test('number param accepts numeric strings', () => { const schema = paramToZod({ type: 'number', description: 'x', required: true }) diff --git a/tests/engine/mcp-stdio.test.ts b/tests/engine/mcp-stdio.test.ts index 9b3624518..aa584c51c 100644 --- a/tests/engine/mcp-stdio.test.ts +++ b/tests/engine/mcp-stdio.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { WebSocketServer, type WebSocket } from 'ws' import { @@ -77,6 +75,8 @@ function createMockApp() { } async function createStdioClient(wsPort: number) { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js') const transport = new StdioClientTransport({ command: 'bun', args: ['packages/mcp/src/stdio.ts'], @@ -109,8 +109,9 @@ async function createStdioClient(wsPort: number) { describe('MCP stdio transport', () => { let app: ReturnType - let client: Client - let transport: StdioClientTransport + // eslint-disable-next-line typescript-eslint/no-explicit-any -- types inferred from dynamic import + let client: any + let transport: any beforeEach(async () => { app = createMockApp()