import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { AddressInfo } from 'node:net' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { WebSocketServer, type WebSocket } from 'ws' import { ALL_TOOLS, FigmaAPI, SceneGraph, computeAllLayouts, executeRpcCommand } from '@open-pencil/core' import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert' function createMockApp() { const graph = new SceneGraph() const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }) let clientWs: WebSocket | null = null const requests: Array<{ command: string args?: { name?: string; document_id?: string; page_id?: string; args?: Record } }> = [] wss.on('connection', (ws) => { clientWs = ws ws.send(JSON.stringify({ type: 'register', token: 'mock-token' })) ws.on('message', async (raw) => { const msg = JSON.parse(String(raw)) as { type: string id: string command: string args?: { name?: string; args?: Record } } if (msg.type !== 'request') return requests.push({ command: msg.command, args: msg.args }) try { let result: unknown if (msg.command === 'tool' && msg.args?.name) { const toolName = msg.args.name const def = ALL_TOOLS.find((t) => t.name === toolName) if (!def) throw new Error(`Unknown tool: ${toolName}`) const api = new FigmaAPI(graph) api.currentPage = api.wrapNode(graph.getPages()[0].id) result = await def.execute(api, msg.args.args ?? {}) if (def.mutates) computeAllLayouts(graph) } else if (msg.command === 'save_file') { result = { ok: true } } else if (msg.command === 'list_documents') { result = { documents: [ { id: 'doc-1', name: 'Mock document', active: true, current_page_id: graph.getPages()[0].id, current_page_name: graph.getPages()[0].name, pages: graph.getPages().map((page) => ({ id: page.id, name: page.name })) } ] } } else { result = executeRpcCommand(graph, msg.command, msg.args ?? {}) } ws.send(JSON.stringify({ type: 'response', id: msg.id, ok: true, result })) } catch (e) { ws.send( JSON.stringify({ type: 'response', id: msg.id, ok: false, error: e instanceof Error ? e.message : String(e) }) ) } }) }) const port = new Promise((resolve) => { wss.on('listening', () => resolve((wss.address() as AddressInfo).port)) }) return { graph, requests, wss, port, close: () => { clientWs?.close() wss.close() } } } 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'], env: { ...process.env, WS_PORT: String(wsPort), PATH: process.env.PATH ?? '' }, stderr: 'pipe' }) const client = new Client({ name: 'test-stdio-client', version: '0.0.0' }) await new Promise((resolve) => { const stderr = transport.stderr if (stderr && 'on' in stderr) { ;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => { if (chunk.toString().includes('Connected to OpenPencil app')) { resolve() } }) } void client.connect(transport).then(() => { setTimeout(resolve, 1000) return undefined }) }) return { client, transport } } function textContent(content: unknown): string { const items = content as { type: string; text: string }[] return expectDefined( items.find((c) => c.type === 'text'), 'text content' ).text } describe('MCP stdio transport', () => { let app: ReturnType let client: Client let transport: StdioClientTransport beforeEach(async () => { app = createMockApp() const wsPort = await app.port const ctx = await createStdioClient(wsPort) client = ctx.client transport = ctx.transport }) afterEach(async () => { await client.close() app.close() }) test('lists tools over stdio', async () => { const { tools } = await client.listTools() const names = tools.map((t) => t.name) expect(names).toContain('create_shape') expect(names).toContain('get_page_tree') expect(names).toContain('save_file') expect(names).toContain('list_documents') expect(names).toContain('get_codegen_prompt') const createShape = expectDefined( tools.find((tool) => tool.name === 'create_shape'), 'create_shape tool' ) expect(JSON.stringify(createShape.inputSchema)).toContain('document_id') expect(JSON.stringify(createShape.inputSchema)).toContain('page_id') expect(tools.length).toBeGreaterThan(30) }) test('create_shape via stdio creates a node', async () => { const result = await client.callTool({ name: 'create_shape', arguments: { type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'StdioFrame' } }) expect(result.isError).not.toBe(true) const data = JSON.parse(textContent(result.content)) as { id: string name: string type: string } expect(data.type).toBe('FRAME') expect(data.name).toBe('StdioFrame') expect(getNodeOrThrow(app.graph, data.id).width).toBe(200) }) test('tool target fields are sent in the app RPC envelope', async () => { const result = await client.callTool({ name: 'create_shape', arguments: { document_id: 'doc-1', page_id: 'page-1', type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'TargetedFrame' } }) expect(result.isError).not.toBe(true) const request = expectDefined( app.requests.find((item) => item.command === 'tool' && item.args?.name === 'create_shape'), 'tool request' ) expect(request.args?.document_id).toBe('doc-1') expect(request.args?.page_id).toBe('page-1') expect(request.args?.args?.document_id).toBeUndefined() expect(request.args?.args?.page_id).toBeUndefined() }) test('list_documents via stdio returns open documents', async () => { const result = await client.callTool({ name: 'list_documents', arguments: {} }) expect(result.isError).not.toBe(true) const data = JSON.parse(textContent(result.content)) as { documents: Array<{ id: string; current_page_id: string }> } expect(data.documents[0].id).toBe('doc-1') expect(data.documents[0].current_page_id).toBe(app.graph.getPages()[0].id) }) test('save_file via stdio succeeds', async () => { const result = await client.callTool({ name: 'save_file', arguments: {} }) expect(result.isError).not.toBe(true) const data = JSON.parse(textContent(result.content)) as { saved: boolean } expect(data.saved).toBe(true) }) test('get_codegen_prompt via stdio returns prompt', async () => { const result = await client.callTool({ name: 'get_codegen_prompt', arguments: {} }) expect(result.isError).not.toBe(true) const data = JSON.parse(textContent(result.content)) as { prompt: string } expect(data.prompt.length).toBeGreaterThan(100) }) test('delete_node via stdio removes a node', async () => { const create = await client.callTool({ name: 'create_shape', arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } }) const { id } = JSON.parse(textContent(create.content)) as { id: string } expect(app.graph.getNode(id)).toBeDefined() await client.callTool({ name: 'delete_node', arguments: { id } }) expect(app.graph.getNode(id)).toBeUndefined() }) test('stderr does not contain JSON-RPC', async () => { const stderrChunks: string[] = [] const stderr = transport.stderr if (stderr && 'on' in stderr) { ;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => { stderrChunks.push(chunk.toString()) }) } await client.callTool({ name: 'create_shape', arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 } }) const allStderr = stderrChunks.join('') expect(allStderr).not.toContain('"jsonrpc"') expect(allStderr).not.toContain('"method"') }) })