openpencil/tests/engine/mcp/stdio/index.test.ts
Danila Poyarkov 9c9e4fae08 refactor(mcp): align transport domain structure
- Group server, stdio, and transport implementation by ownership

- Mirror those domains in engine tests and centralize shared MCP fixtures
2026-07-25 22:59:59 +03:00

351 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
import {
connectMockBrowser,
waitForBrowserRegistration,
type MockBrowser
} from '#tests/helpers/mcp/server'
const AUTH_TOKEN = 'test-stdio-token'
const NO_DOCUMENT_AUTH_TOKEN = 'test-stdio-no-document-token'
const isUnix = process.platform !== 'win32'
const SOCKET_DIR = join(tmpdir(), `openpencil-test-stdio-${process.pid}`)
const SOCKET_PATH = isUnix ? join(SOCKET_DIR, 'mcp.sock') : null
async function createStdioClient(socketPath: string, authToken: string | null) {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('OPENPENCIL_MCP_') && value !== undefined) {
env[key] = value
}
}
if (process.env.OPENPENCIL_MCP_DISCOVERY_PATH) {
env.OPENPENCIL_MCP_DISCOVERY_PATH = process.env.OPENPENCIL_MCP_DISCOVERY_PATH
}
env.PATH = process.env.PATH ?? ''
// Only set OPENPENCIL_MCP_SOCKET when a socket path exists.
// An empty string on Windows would break the stdio bridge's transport
// discovery (it would try to connect to an empty socket path).
if (socketPath) {
env.OPENPENCIL_MCP_SOCKET = socketPath
}
if (authToken) {
env.OPENPENCIL_MCP_AUTH_TOKEN = authToken
}
const transport = new StdioClientTransport({
command: 'bun',
args: ['packages/mcp/src/stdio.ts'],
env,
stderr: 'pipe'
})
const client = new Client({ name: 'test-stdio-client', version: '0.0.0' })
let stderrBuffer = ''
// The bridge must reach the MCP server before tool calls can be sent.
// Wait for the stderr readiness marker — this confirms server reachability
// and ready=true, but document/browser availability is still checked per
// tool call.
// Also reject on timeout or stream end so the test fails fast if the
// bridge never connects.
const BRIDGE_TIMEOUT = 10_000
const bridgeConnected = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Bridge did not connect within timeout'))
}, BRIDGE_TIMEOUT)
const stderr = transport.stderr
if (stderr && 'on' in stderr) {
const stream = stderr as NodeJS.ReadableStream
stream.on('data', (chunk: Buffer) => {
// Buffer stderr so the readiness marker is not missed if it
// straddles a chunk boundary.
stderrBuffer += chunk.toString()
if (stderrBuffer.includes('Connected to OpenPencil')) {
clearTimeout(timer)
resolve()
}
})
stream.on('end', () => {
clearTimeout(timer)
reject(new Error('Bridge stderr stream ended before connection'))
})
stream.on('error', (err: Error) => {
clearTimeout(timer)
reject(new Error(`Bridge stderr error: ${err.message}`))
})
} else {
clearTimeout(timer)
reject(new Error('No stderr stream available'))
}
})
// Start the MCP session and wait for the bridge to be ready in parallel.
// client.connect() handles the JSON-RPC initialize/handshake;
// bridgeConnected confirms the bridge can actually reach the server.
try {
await Promise.all([client.connect(transport), bridgeConnected])
} catch (err) {
await client.close().catch(() => undefined)
await transport.close().catch(() => undefined)
throw err
}
return { client, transport, stderrText: () => stderrBuffer }
}
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 handle: ServerHandle | undefined
let graph: SceneGraph | undefined
let browser: MockBrowser | undefined
let client: Client | undefined
let transport: StdioClientTransport | undefined
let stderrText: (() => string) | undefined
beforeEach(async () => {
// Fail-safe: clean up any leftover state from a failed previous test
if (client) await client.close().catch(() => undefined)
if (browser) browser.close()
if (transport) await transport.close().catch(() => undefined)
if (handle) await handle.close().catch(() => undefined)
graph = new SceneGraph()
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
// Bun does not run afterEach() when beforeEach() throws, so we must clean
// up any partially-initialized resources ourselves before rethrowing.
try {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: SOCKET_PATH,
authToken: AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
if (!handle.httpPort) {
throw new Error('TCP listener not started — httpPort is undefined')
}
browser = await connectMockBrowser(handle.httpPort, graph, AUTH_TOKEN)
await waitForBrowserRegistration(handle.httpPort)
const socketPath = handle.socketPath ?? ''
if (isUnix && !socketPath) {
throw new Error('Unix socket listener not started — socketPath is undefined')
}
const ctx = await createStdioClient(socketPath, AUTH_TOKEN)
client = ctx.client
transport = ctx.transport
stderrText = ctx.stderrText
} catch (err) {
if (client) await client.close().catch(() => undefined)
if (browser) browser.close()
if (transport) await transport.close().catch(() => undefined)
if (handle) await handle.close().catch(() => undefined)
throw err
}
}, 15000)
afterEach(async () => {
if (client) await client.close().catch(() => undefined)
if (browser) browser.close()
if (transport) await transport.close().catch(() => undefined)
if (handle) await handle.close().catch(() => undefined)
if (isUnix) await rm(SOCKET_DIR, { recursive: true, force: true })
handle = undefined
browser = undefined
client = undefined
transport = undefined
stderrText = undefined
})
function requireClient(): Client {
if (!client) throw new Error('client not initialized')
return client
}
function requireGraph(): SceneGraph {
if (!graph) throw new Error('graph not initialized')
return graph
}
test('lists tools over stdio', async () => {
const { tools } = await requireClient().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)
}, 10000)
test('create_shape via stdio creates a node', async () => {
const result = await requireClient().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(requireGraph(), data.id).width).toBe(200)
}, 10000)
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(
browser?.requests.find((item) => {
const args = item.args as { name?: string } | undefined
return item.command === 'tool' && args?.name === 'create_shape'
}),
'tool request'
)
const requestArgs = request.args as
| {
name?: string
document_id?: string
page_id?: string
args?: Record<string, unknown>
}
| undefined
expect(requestArgs?.document_id).toBe('doc-1')
expect(requestArgs?.page_id).toBe('page-1')
expect(requestArgs?.args?.document_id).toBeUndefined()
expect(requestArgs?.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(browser?.graph.getPages()[0].id)
})
test('save_file via stdio succeeds', async () => {
const result = await requireClient().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)
}, 10000)
test('get_codegen_prompt via stdio returns prompt', async () => {
const result = await requireClient().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)
}, 10000)
test('sequential tool calls work (create then delete)', async () => {
const create = await requireClient().callTool({
name: 'create_shape',
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
})
expect(create.isError).not.toBe(true)
const { id } = JSON.parse(textContent(create.content)) as { id: string }
expect(getNodeOrThrow(requireGraph(), id)).toBeDefined()
await requireClient().callTool({ name: 'delete_node', arguments: { id } })
expect(requireGraph().getNode(id)).toBeUndefined()
}, 10000)
test('stderr does not contain JSON-RPC', async () => {
await requireClient().callTool({
name: 'create_shape',
arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }
})
const allStderr = stderrText?.() ?? ''
expect(allStderr).not.toContain('"jsonrpc"')
expect(allStderr).not.toContain('"method"')
}, 10000)
})
describe('MCP stdio readiness without an open document', () => {
test('readiness marker means server reachable, while document tools report no document', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: isUnix ? join(SOCKET_DIR, 'mcp-no-document.sock') : null,
authToken: NO_DOCUMENT_AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
let client: Client | undefined
let transport: StdioClientTransport | undefined
try {
const socketPath = handle.socketPath ?? ''
const ctx = await createStdioClient(socketPath, NO_DOCUMENT_AUTH_TOKEN)
client = ctx.client
transport = ctx.transport
expect(ctx.stderrText()).toContain('document availability is checked per tool call')
const result = await client.callTool({ name: 'get_current_page', arguments: {} })
expect(result.isError).toBe(true)
const message = textContent(result.content)
expect(message).toContain('OpenPencil app is not connected')
expect(message).toContain('no document is open')
} finally {
await client?.close().catch(() => undefined)
await transport?.close().catch(() => undefined)
await handle.close().catch(() => undefined)
if (isUnix) await rm(SOCKET_DIR, { recursive: true, force: true })
}
}, 30000)
})