* feat(MCP): follow agent activity in canvas * fix(fig): preserve imported design fidelity Keep component overrides, variable-backed icon colors, page backgrounds, and fixed text sizing intact across lazy FIG materialization. * feat: add selection-context MCP tools and mode * chore: scope work branch to MCP selection and canvas follow * fix: honor MCP-only tool contracts in CI * refactor(mcp): drop the follow and selection-context tools this branch carried Following agents landed in #725, through the agents registry and the chat's follow toggle, so this branch's MCP follow setting and its follow-agent module are superseded. The see_user_selection and get_user_selection_details tools duplicated get_selection, get_node, describe, get_page_tree, and export_image; the selection-only workflow they served is rebuilt on those tools in the following commits. Co-authored-by: Victor Wads <victor@wads.dev> * feat(mcp): make get_selection the compact entry point with a depth get_selection returned every selected layer's whole subtree, which is too much as the first call when the user points at a large frame. It now returns the selection with direct children by default, counts deeper children as childCount, and takes a depth. Co-authored-by: Victor Wads <victor@wads.dev> * feat(mcp): share only the selection with MCP clients A selection scope, set with Share only the selection in the local server settings or OPENPENCIL_MCP_SCOPE=selection, limits MCP clients to get_selection, get_node, get_page_tree, describe, and export_image on the selected layers and what they hold. The server enforces the scope on everything it sends to the app: MCP sessions and /rpc, which stdio clients also go through, carry only those tool calls and the session-closed notice, each stamped with the scope, so a client cannot reach other tools or the settings that would widen it. The app's bridge rejects node IDs outside the selection, points describe and export_image at the selection when they name no nodes, and asks get_page_tree for a root inside it. A stdio client can ask for the scope itself while the server shares the whole document. Co-authored-by: Victor Wads <victor@wads.dev> * fix(mcp): keep selection-scoped clients from writing files or listing wider tools export_image writes its result to a file when given a path and an MCP root is set, which reaches past reading the selection. A path is now refused in selection scope, by the tool registration before the call and by the app's bridge, so a client with a stale scope cannot write either; the image itself is still returned. A stdio client follows the narrower of its own scope and the scope the server records, instead of letting OPENPENCIL_MCP_SCOPE=document list tools a selection-scoped server rejects. Co-authored-by: Victor Wads <victor@wads.dev> * test(mcp): name the selection scope's tools instead of reading the allowlist The server test compared the listed tools with SELECTION_SCOPE_TOOLS, the same list that decides registration, so a tool added to it by mistake would still pass. It now names the five tools the scope offers. Co-authored-by: Victor Wads <victor@wads.dev> --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
843 lines
30 KiB
TypeScript
843 lines
30 KiB
TypeScript
/* eslint-disable max-lines -- MCP server scenarios share lifecycle and transport fixtures */
|
|
|
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
|
import { mkdir, stat } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
|
|
import { MCP_AGENT_HEADER } from '@open-pencil/core/constants'
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { startServer, type ServerHandle } from '#mcp/server'
|
|
import { createToolDescriptors, getMCPToolDefinitions } from '#mcp/tool/manifest'
|
|
import type { MCPToolScope } from '#mcp/tool/scope'
|
|
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import {
|
|
connectMockBrowser,
|
|
readHealth,
|
|
waitForBrowserRegistration,
|
|
type MockBrowser
|
|
} from '#tests/helpers/mcp/server'
|
|
|
|
const isUnix = process.platform !== 'win32'
|
|
const SOCKET_DIR = join(tmpdir(), `openpencil-test-server-${process.pid}`)
|
|
const TEST_MCP_ROOT = join(tmpdir(), 'open-pencil-mcp-root')
|
|
const TEST_AUTH_TOKEN = 'test-auth-token'
|
|
const TEST_CLIENT_AUTH_TOKEN = 'test-client-token'
|
|
let testCounter = 0
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test client: starts server with ephemeral TCP port, connects mock browser + MCP client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function testSocketPath(): string | null {
|
|
if (!isUnix) return null
|
|
return join(SOCKET_DIR, `mcp-test-${process.pid}-${++testCounter}.sock`)
|
|
}
|
|
|
|
async function createTestClient(disabledTools: string[] = [], scope: MCPToolScope = 'document') {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const authToken = TEST_CLIENT_AUTH_TOKEN
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken,
|
|
disabledTools,
|
|
scope,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
|
|
const httpPort = handle.httpPort
|
|
if (!httpPort) {
|
|
await handle.close()
|
|
throw new Error('TCP listener not started')
|
|
}
|
|
|
|
const graph = new SceneGraph()
|
|
let browser: MockBrowser | undefined
|
|
let client: Client | undefined
|
|
|
|
try {
|
|
browser = await connectMockBrowser(httpPort, graph, authToken)
|
|
await waitForBrowserRegistration(httpPort)
|
|
|
|
client = new Client({ name: 'test-client', version: '0.0.0' })
|
|
const transport = new StreamableHTTPClientTransport(
|
|
new URL(`http://127.0.0.1:${httpPort}/mcp`),
|
|
{
|
|
requestInit: { headers: { Authorization: `Bearer ${authToken}` } }
|
|
}
|
|
)
|
|
await client.connect(transport)
|
|
} catch (e) {
|
|
await client?.close().catch(() => undefined)
|
|
browser?.close()
|
|
await handle.close()
|
|
throw e
|
|
}
|
|
|
|
const safeClient = client
|
|
const safeBrowser = browser
|
|
return {
|
|
client: safeClient,
|
|
browser: safeBrowser,
|
|
graph,
|
|
handle,
|
|
close: async () => {
|
|
const errors: unknown[] = []
|
|
try {
|
|
await safeClient?.close()
|
|
} catch (e) {
|
|
errors.push(e)
|
|
}
|
|
try {
|
|
safeBrowser?.close()
|
|
} catch (e) {
|
|
errors.push(e)
|
|
}
|
|
try {
|
|
await handle.close()
|
|
} catch (e) {
|
|
errors.push(e)
|
|
}
|
|
if (errors.length > 0) throw errors[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseResult(result: { content: { type: string; text?: string }[] }): unknown {
|
|
const textContent = result.content.find((c) => c.type === 'text')
|
|
return textContent?.text ? JSON.parse(textContent.text) : null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP tool + session tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('MCP exposure exclusions apply to both descriptors and registered tools', async () => {
|
|
const def = getMCPToolDefinitions().find((tool) => tool.name === 'get_page_tree')
|
|
if (!def) throw new Error('Missing tool fixture')
|
|
const previous = def.exposure
|
|
let context: Awaited<ReturnType<typeof createTestClient>> | undefined
|
|
try {
|
|
def.exposure = { ...previous, mcp: false }
|
|
expect(createToolDescriptors(false).some((tool) => tool.name === def.name)).toBe(false)
|
|
context = await createTestClient()
|
|
const { tools } = await context.client.listTools()
|
|
expect(tools.some((tool) => tool.name === def.name)).toBe(false)
|
|
} finally {
|
|
def.exposure = previous
|
|
await context?.close()
|
|
}
|
|
})
|
|
|
|
describe('startServer option validation', () => {
|
|
test('rejects an app wait timeout that is not a safe non-negative integer', async () => {
|
|
await expect(startServer({ appWaitTimeoutMs: -1 })).rejects.toThrow(RangeError)
|
|
await expect(startServer({ appWaitTimeoutMs: 1.5 })).rejects.toThrow(
|
|
'appWaitTimeoutMs must be a safe integer'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('MCP server', () => {
|
|
let client: Client
|
|
let graph: SceneGraph
|
|
let cleanup: (() => Promise<void>) | null = null
|
|
|
|
beforeEach(async () => {
|
|
try {
|
|
const ctx = await createTestClient()
|
|
client = ctx.client
|
|
graph = ctx.graph
|
|
cleanup = ctx.close
|
|
} catch (e) {
|
|
if (cleanup) await cleanup().catch(() => undefined)
|
|
throw e
|
|
}
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (cleanup) await cleanup()
|
|
cleanup = null
|
|
})
|
|
|
|
test('lists all registered tools', async () => {
|
|
const { tools } = await client.listTools()
|
|
const expectedNames = createToolDescriptors(false)
|
|
.filter((tool) => tool.availability === 'default')
|
|
.map((tool) => tool.name)
|
|
.sort()
|
|
expect(tools.map((tool) => tool.name).sort()).toEqual(expectedNames)
|
|
})
|
|
|
|
test('describes effects, capabilities, and runtime availability independently', () => {
|
|
const descriptors = createToolDescriptors(true)
|
|
const byName = new Map(descriptors.map((tool) => [tool.name, tool] as const))
|
|
expect(byName.get('get_page_tree')?.effect).toBe('read')
|
|
expect(byName.get('switch_page')?.effect).toBe('read')
|
|
expect(byName.get('viewport_set')?.effect).toBe('read')
|
|
expect(byName.get('export_image')?.effect).toBe('read')
|
|
expect(byName.get('save_file')?.effect).toBe('write')
|
|
expect(byName.get('open_file')?.effect).toBe('read')
|
|
expect(byName.get('open_file')?.capabilities).toEqual(['filesystem:read', 'document:read'])
|
|
expect(byName.get('close_file')?.effect).toBe('write')
|
|
expect(byName.get('update_node')?.effect).toBe('write')
|
|
expect(byName.get('new_document')?.capabilities).toEqual(['document:write', 'filesystem:write'])
|
|
expect(byName.get('activate_document')?.effect).toBe('read')
|
|
expect(byName.get('undo')?.capabilities).toEqual(['document:write'])
|
|
expect(byName.get('redo')?.effect).toBe('write')
|
|
expect(byName.get('get_settings')?.capabilities).toEqual(['settings:read'])
|
|
expect(byName.get('update_settings')?.capabilities).toEqual(['settings:write'])
|
|
expect(byName.get('eval')?.availability).toBe('eval')
|
|
expect(byName.get('eval')?.capabilities).toContain('code:execute')
|
|
})
|
|
|
|
test('omits tools disabled in the generic registration filter', async () => {
|
|
if (cleanup) await cleanup()
|
|
cleanup = null
|
|
|
|
const ctx = await createTestClient(['create_shape', 'list_documents'])
|
|
client = ctx.client
|
|
graph = ctx.graph
|
|
cleanup = ctx.close
|
|
|
|
const { tools } = await client.listTools()
|
|
const names = tools.map((tool) => tool.name)
|
|
expect(names).not.toContain('create_shape')
|
|
expect(names).not.toContain('list_documents')
|
|
expect(names).toContain('get_page_tree')
|
|
|
|
const healthResponse = await fetch(`http://127.0.0.1:${ctx.handle.httpPort}/health`, {
|
|
headers: { Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}` }
|
|
})
|
|
const health = await readHealth(healthResponse)
|
|
const descriptors = health.tools ?? []
|
|
expect(descriptors.find((tool) => tool.name === 'create_shape')?.enabled).toBe(false)
|
|
expect(descriptors.find((tool) => tool.name === 'list_documents')?.enabled).toBe(false)
|
|
expect(descriptors.find((tool) => tool.name === 'get_page_tree')?.enabled).toBe(true)
|
|
})
|
|
|
|
test('tools expose standard MCP effect annotations', async () => {
|
|
const { tools } = await client.listTools()
|
|
const byName = new Map(tools.map((tool) => [tool.name, tool] as const))
|
|
expect(byName.get('get_page_tree')?.annotations?.readOnlyHint).toBe(true)
|
|
expect(byName.get('get_page_tree')?.annotations?.destructiveHint).toBe(false)
|
|
expect(byName.get('update_node')?.annotations?.readOnlyHint).toBe(false)
|
|
expect(byName.get('update_node')?.annotations?.destructiveHint).toBe(true)
|
|
})
|
|
|
|
test('tools have descriptions and input schemas', async () => {
|
|
const { tools } = await client.listTools()
|
|
for (const tool of tools) {
|
|
expect(tool.description).toBeTruthy()
|
|
expect(tool.inputSchema).toBeDefined()
|
|
}
|
|
})
|
|
|
|
test('create_shape creates a node on the live canvas', async () => {
|
|
const result = await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'FRAME', x: 0, y: 0, width: 200, height: 100, name: 'Test' }
|
|
})
|
|
expect(result.isError).not.toBe(true)
|
|
const data = parseResult(result) as { id: string; name: string; type: string }
|
|
expect(data.type).toBe('FRAME')
|
|
expect(data.name).toBe('Test')
|
|
|
|
const node = graph.getNode(data.id)
|
|
expect(node).toBeDefined()
|
|
expect(node?.name).toBe('Test')
|
|
})
|
|
|
|
test('set_fill validates and applies color', async () => {
|
|
const create = await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
|
|
})
|
|
const { id } = parseResult(create) as { id: string }
|
|
|
|
const fill = await client.callTool({
|
|
name: 'set_fill',
|
|
arguments: { id, color: '#00ff00' }
|
|
})
|
|
expect(fill.isError).not.toBe(true)
|
|
})
|
|
|
|
test('get_page_tree returns page structure', async () => {
|
|
await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100, name: 'F1' }
|
|
})
|
|
const result = await client.callTool({ name: 'get_page_tree', arguments: {} })
|
|
expect(result.isError).not.toBe(true)
|
|
const data = parseResult(result) as { children: { name: string }[] }
|
|
expect(data.children.some((c) => c.name === 'F1')).toBe(true)
|
|
})
|
|
|
|
test('delete_node 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 } = parseResult(create) as { id: string }
|
|
|
|
await client.callTool({ name: 'delete_node', arguments: { id } })
|
|
|
|
const get = await client.callTool({ name: 'get_node', arguments: { id } })
|
|
const data = parseResult(get) as { error?: string }
|
|
expect(data.error).toContain('not found')
|
|
})
|
|
|
|
test('find_nodes filters by type', async () => {
|
|
await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }
|
|
})
|
|
await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
|
|
})
|
|
await client.callTool({
|
|
name: 'create_shape',
|
|
arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }
|
|
})
|
|
const result = await client.callTool({ name: 'find_nodes', arguments: { type: 'FRAME' } })
|
|
const data = parseResult(result) as { count: number }
|
|
expect(data.count).toBe(2)
|
|
})
|
|
|
|
test('get_codegen_prompt returns prompt text', async () => {
|
|
const result = await client.callTool({ name: 'get_codegen_prompt', arguments: {} })
|
|
expect(result.isError).not.toBe(true)
|
|
const data = parseResult(result) as { prompt: string }
|
|
expect(data.prompt.length).toBeGreaterThan(100)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mcpRoot tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MCP server sharing only the selection', () => {
|
|
let ctx: Awaited<ReturnType<typeof createTestClient>> | null = null
|
|
|
|
beforeEach(async () => {
|
|
ctx = await createTestClient([], 'selection')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await ctx?.close()
|
|
ctx = null
|
|
})
|
|
|
|
test('lists only the selection read tools', async () => {
|
|
const { tools } = await expectDefined(ctx, 'client').client.listTools()
|
|
expect(tools.map((tool) => tool.name).sort()).toEqual([
|
|
'describe',
|
|
'export_image',
|
|
'get_node',
|
|
'get_page_tree',
|
|
'get_selection'
|
|
])
|
|
})
|
|
|
|
test('marks every tool call with the scope for the app to enforce', async () => {
|
|
const { client, browser } = expectDefined(ctx, 'client')
|
|
await client.callTool({ name: 'get_selection', arguments: {} })
|
|
const call = browser.requests.find((request) => request.command === 'tool')
|
|
expect(call?.args).toMatchObject({ name: 'get_selection', scope: 'selection' })
|
|
})
|
|
|
|
test('refuses to write an export to a file', async () => {
|
|
const { client, browser } = expectDefined(ctx, 'client')
|
|
const result = await client.callTool({
|
|
name: 'export_image',
|
|
arguments: { path: 'selection.png' }
|
|
})
|
|
expect(result.isError).toBe(true)
|
|
expect(browser.requests.map((request) => request.command)).not.toContain('tool')
|
|
})
|
|
|
|
test('keeps raw RPC to the selection tools, so it cannot reach settings', async () => {
|
|
const { browser, handle } = expectDefined(ctx, 'client')
|
|
const rpc = (body: object) =>
|
|
fetch(`http://127.0.0.1:${handle.httpPort}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
}).then((response) => response.json() as Promise<unknown>)
|
|
|
|
expect(await rpc({ command: 'update_settings', args: { mcp: {} } })).toMatchObject({
|
|
ok: false
|
|
})
|
|
expect(
|
|
await rpc({ command: 'tool', args: { name: 'delete_node', args: { id: '0:1' } } })
|
|
).toMatchObject({ ok: false })
|
|
expect(browser.requests.map((request) => request.command)).not.toContain('update_settings')
|
|
|
|
await rpc({ command: 'tool', args: { name: 'get_node', args: { id: '0:1' } } })
|
|
const call = browser.requests.find((request) => request.command === 'tool')
|
|
expect(call?.args).toMatchObject({ name: 'get_node', scope: 'selection' })
|
|
})
|
|
})
|
|
|
|
describe('MCP server with mcpRoot', () => {
|
|
async function withMCPRootServer(
|
|
mcpRoot: string | null,
|
|
fn: (client: Client, browser: MockBrowser, graph: SceneGraph) => Promise<void>
|
|
) {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
if (mcpRoot) await mkdir(mcpRoot, { recursive: true })
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot
|
|
})
|
|
|
|
let browser: MockBrowser | null = null
|
|
let client: Client | null = null
|
|
|
|
try {
|
|
const httpPort = handle.httpPort
|
|
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
|
|
|
|
const graph = new SceneGraph()
|
|
browser = await connectMockBrowser(httpPort, graph, TEST_AUTH_TOKEN)
|
|
await waitForBrowserRegistration(httpPort)
|
|
|
|
client = new Client({ name: 'test-root', version: '0.0.0' })
|
|
const transport = new StreamableHTTPClientTransport(
|
|
new URL(`http://127.0.0.1:${httpPort}/mcp`),
|
|
{ requestInit: { headers: { Authorization: `Bearer ${TEST_AUTH_TOKEN}` } } }
|
|
)
|
|
await client.connect(transport)
|
|
|
|
await fn(client, browser, graph)
|
|
} finally {
|
|
await client?.close().catch(() => undefined)
|
|
browser?.close()
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
test('registers open_file and new_document tools when mcpRoot is set', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client) => {
|
|
const { tools } = await client.listTools()
|
|
const byName = new Map(tools.map((tool) => [tool.name, tool] as const))
|
|
expect(byName.has('open_file')).toBe(true)
|
|
expect(byName.get('open_file')?.annotations?.readOnlyHint).toBe(true)
|
|
expect(byName.has('new_document')).toBe(true)
|
|
})
|
|
})
|
|
|
|
test('registers close_file as destructive and forwards its unsaved choice and target', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
const { tools } = await client.listTools()
|
|
const closeFile = tools.find((tool) => tool.name === 'close_file')
|
|
expect(closeFile?.annotations?.destructiveHint).toBe(true)
|
|
|
|
const result = await client.callTool({
|
|
name: 'close_file',
|
|
arguments: { document_id: 'doc-1', unsaved: 'discard' }
|
|
})
|
|
expect(result.isError).not.toBe(true)
|
|
expect(browser.requests.find((item) => item.command === 'close_file')?.args).toEqual({
|
|
document_id: 'doc-1',
|
|
unsaved: 'discard'
|
|
})
|
|
})
|
|
})
|
|
|
|
test('close_file keeps a save path inside mcpRoot', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
const outside = await client.callTool({
|
|
name: 'close_file',
|
|
arguments: { unsaved: 'save', path: '/etc/escape.fig' }
|
|
})
|
|
expect(outside.isError).toBe(true)
|
|
expect(browser.requests.some((item) => item.command === 'close_file')).toBe(false)
|
|
})
|
|
})
|
|
|
|
test('forwards document activation and history steps with their target', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
const activated = await client.callTool({
|
|
name: 'activate_document',
|
|
arguments: { document_id: 'doc-2', page_id: '0:5' }
|
|
})
|
|
expect(activated.isError).not.toBe(true)
|
|
expect(browser.requests.find((item) => item.command === 'activate_document')?.args).toEqual({
|
|
document_id: 'doc-2',
|
|
page_id: '0:5'
|
|
})
|
|
|
|
const undone = await client.callTool({ name: 'undo', arguments: { document_id: 'doc-2' } })
|
|
expect(undone.isError).not.toBe(true)
|
|
expect(JSON.stringify(undone.content)).toContain('Agent: mock')
|
|
expect(browser.requests.find((item) => item.command === 'undo')?.args).toEqual({
|
|
document_id: 'doc-2'
|
|
})
|
|
})
|
|
})
|
|
|
|
test('forwards settings updates and returns the resulting settings', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
const settings = { appearance: { theme: 'light' } }
|
|
const result = await client.callTool({ name: 'update_settings', arguments: { settings } })
|
|
expect(result.isError).not.toBe(true)
|
|
expect(browser.requests.find((item) => item.command === 'update_settings')?.args).toEqual({
|
|
settings
|
|
})
|
|
expect(JSON.stringify(result.content)).toContain('light')
|
|
})
|
|
})
|
|
|
|
test('save_file accepts an explicit path inside mcpRoot', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
await mkdir(join(TEST_MCP_ROOT, 'unicode'), { recursive: true })
|
|
const savePath = join(TEST_MCP_ROOT, 'unicode', 'пример.fig')
|
|
const result = await client.callTool({
|
|
name: 'save_file',
|
|
arguments: { path: savePath }
|
|
})
|
|
|
|
expect(result.isError).not.toBe(true)
|
|
const request = browser.requests.find((item) => item.command === 'save_file')
|
|
// The server sends the canonical (realpath-resolved) path to the browser
|
|
// to prevent TOCTOU races. On macOS, /var -> /private/var.
|
|
const { realpath } = await import('node:fs/promises')
|
|
const { dirname, basename } = await import('node:path')
|
|
const canonicalPath = join(await realpath(dirname(savePath)), basename(savePath))
|
|
expect(request?.args).toEqual({ path: canonicalPath })
|
|
})
|
|
})
|
|
|
|
test('save_file rejects paths outside mcpRoot', async () => {
|
|
await withMCPRootServer(TEST_MCP_ROOT, async (client, browser) => {
|
|
const result = await client.callTool({
|
|
name: 'save_file',
|
|
arguments: { path: join(join(TEST_MCP_ROOT, '..'), 'outside.fig') }
|
|
})
|
|
|
|
expect(result.isError).toBe(true)
|
|
expect(browser.requests.some((item) => item.command === 'save_file')).toBe(false)
|
|
})
|
|
})
|
|
|
|
test('does not register open_file when mcpRoot is null', async () => {
|
|
await withMCPRootServer(null, async (client) => {
|
|
const { tools } = await client.listTools()
|
|
const names = tools.map((t) => t.name)
|
|
expect(names).not.toContain('open_file')
|
|
expect(names).not.toContain('new_document')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GAP-01: server.close() resource cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MCP server lifecycle', () => {
|
|
test('close() removes the discovery file from disk', async () => {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const { getDiscoveryPath } = await import('#mcp/transport/paths')
|
|
const discoveryPath = await getDiscoveryPath()
|
|
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
try {
|
|
expect(await Bun.file(discoveryPath).exists()).toBe(true)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
expect(await Bun.file(discoveryPath).exists()).toBe(false)
|
|
})
|
|
|
|
test('close() removes the unix socket file from disk', async () => {
|
|
if (!isUnix) return
|
|
await mkdir(SOCKET_DIR, { recursive: true })
|
|
const socketPath = expectDefined(testSocketPath(), 'socket path')
|
|
expect(socketPath).toBeTruthy()
|
|
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath,
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
try {
|
|
const info = await stat(socketPath)
|
|
expect(info.isSocket()).toBe(true)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
await expect(stat(socketPath)).rejects.toThrow()
|
|
})
|
|
|
|
test('close() removes socket file when no replacement server is listening', async () => {
|
|
if (!isUnix) return
|
|
await mkdir(SOCKET_DIR, { recursive: true })
|
|
const socketPath = expectDefined(testSocketPath(), 'socket path')
|
|
|
|
const handle1 = await startServer({
|
|
httpPort: 0,
|
|
withTcp: false,
|
|
socketPath,
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
try {
|
|
expect(handle1.socketPath).toBe(socketPath)
|
|
} finally {
|
|
await handle1.close()
|
|
}
|
|
await expect(stat(socketPath)).rejects.toThrow()
|
|
})
|
|
|
|
test('close() is idempotent and does not throw on second call', async () => {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
await handle.close()
|
|
await expect(handle.close()).resolves.toBeUndefined()
|
|
})
|
|
|
|
test('close() rejects subsequent RPC requests', async () => {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: TEST_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
const httpPort = handle.httpPort
|
|
if (!httpPort) {
|
|
await handle.close()
|
|
throw new Error('withTcp: true did not produce an HTTP port')
|
|
}
|
|
|
|
try {
|
|
const healthyResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
|
|
expect(healthyResp.status).toBe(200)
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
|
|
await expect(fetch(`http://127.0.0.1:${httpPort}/health`)).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GAP-02: concurrent startServer calls produce non-overlapping servers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MCP server concurrent startServer', () => {
|
|
test('two simultaneous startServer calls each get their own discovery file and both stay up', async () => {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const { getDiscoveryPath } = await import('#mcp/transport/paths')
|
|
|
|
// Use unique socket paths so the two servers don't fight over the
|
|
// socket file. The test still covers discovery atomicity by writing
|
|
// to the same shared discovery path.
|
|
const results = await Promise.allSettled([
|
|
startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: 'token-a',
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
}),
|
|
startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: 'token-b',
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
])
|
|
|
|
const a = results[0].status === 'fulfilled' ? results[0].value : null
|
|
const b = results[1].status === 'fulfilled' ? results[1].value : null
|
|
|
|
if (!a || !b) {
|
|
if (a) await a.close()
|
|
if (b) await b.close()
|
|
throw new Error('Multi-server startup failed')
|
|
}
|
|
|
|
try {
|
|
// Both servers are listening on their own ephemeral ports.
|
|
expect(a.httpPort).toBeGreaterThan(0)
|
|
expect(b.httpPort).toBeGreaterThan(0)
|
|
expect(a.httpPort).not.toBe(b.httpPort)
|
|
|
|
// Each responds on /health with the expected auth state.
|
|
const aHealth = await readHealth(await fetch(`http://127.0.0.1:${a.httpPort}/health`))
|
|
const bHealth = await readHealth(await fetch(`http://127.0.0.1:${b.httpPort}/health`))
|
|
expect(aHealth.status).toBe('no_app')
|
|
expect(bHealth.status).toBe('no_app')
|
|
|
|
// Discovery file exists and was last written by one of the two servers
|
|
// (atomic rename guarantees it's a complete file, not interleaved).
|
|
const discoveryPath = await getDiscoveryPath()
|
|
const file = Bun.file(discoveryPath)
|
|
expect(await file.exists()).toBe(true)
|
|
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
|
|
expect(info.pid).toBe(process.pid)
|
|
expect(['token-a', 'token-b']).toContain(
|
|
expectDefined(info.authToken, 'discovery auth token')
|
|
)
|
|
} finally {
|
|
await a.close()
|
|
await b.close()
|
|
}
|
|
}, 15000)
|
|
|
|
test("closing one server does not delete another server's discovery file", async () => {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const { getDiscoveryPath } = await import('#mcp/transport/paths')
|
|
|
|
const a = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: 'token-a',
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
let b: ServerHandle | undefined
|
|
try {
|
|
b = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: 'token-b',
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
} catch (err) {
|
|
await a.close()
|
|
throw err
|
|
}
|
|
|
|
try {
|
|
// Close server a — its discovery cleanup should NOT remove the file
|
|
// because server b still owns it (different auth token).
|
|
await a.close()
|
|
|
|
// Server b should still be healthy and reachable.
|
|
const bHealth = await readHealth(await fetch(`http://127.0.0.1:${b.httpPort}/health`))
|
|
expect(bHealth.status).toBe('no_app')
|
|
|
|
// Discovery file should still exist (owned by server b now).
|
|
const discoveryPath = await getDiscoveryPath()
|
|
const file = Bun.file(discoveryPath)
|
|
expect(await file.exists()).toBe(true)
|
|
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
|
|
expect(info.authToken).toBe('token-b')
|
|
} finally {
|
|
await b.close()
|
|
}
|
|
}, 15000)
|
|
})
|
|
|
|
describe('MCP sessions as agents', () => {
|
|
async function withAgentClient(
|
|
headers: Record<string, string>,
|
|
run: (
|
|
client: Client,
|
|
browser: MockBrowser,
|
|
transport: StreamableHTTPClientTransport
|
|
) => Promise<void>
|
|
) {
|
|
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
|
|
const handle = await startServer({
|
|
httpPort: 0,
|
|
withTcp: true,
|
|
socketPath: testSocketPath(),
|
|
authToken: TEST_CLIENT_AUTH_TOKEN,
|
|
enableEval: false,
|
|
mcpRoot: null
|
|
})
|
|
const httpPort = expectDefined(handle.httpPort, 'TCP port')
|
|
const browser = await connectMockBrowser(httpPort, new SceneGraph(), TEST_CLIENT_AUTH_TOKEN)
|
|
try {
|
|
await waitForBrowserRegistration(httpPort)
|
|
const client = new Client({ name: 'test-client', version: '0.0.0' })
|
|
const transport = new StreamableHTTPClientTransport(
|
|
new URL(`http://127.0.0.1:${httpPort}/mcp`),
|
|
{
|
|
requestInit: {
|
|
headers: { Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}`, ...headers }
|
|
}
|
|
}
|
|
)
|
|
await client.connect(transport)
|
|
await run(client, browser, transport)
|
|
} finally {
|
|
browser.close()
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
function agentOf(browser: MockBrowser): unknown {
|
|
const request = browser.requests.find((item) => item.command === 'tool')
|
|
return (request?.args as { agent?: unknown } | undefined)?.agent
|
|
}
|
|
|
|
test('a tool call names its session and client, so the app can show it as an agent', async () => {
|
|
await withAgentClient({}, async (client, browser) => {
|
|
await client.callTool({ name: 'get_page_tree', arguments: {} })
|
|
expect(agentOf(browser)).toMatchObject({ kind: 'mcp', client: 'test-client' })
|
|
expect((agentOf(browser) as { session: unknown }).session).toBeString()
|
|
})
|
|
})
|
|
|
|
test("the app's ACP chat marks its session, and closing the session tells the app", async () => {
|
|
await withAgentClient({ [MCP_AGENT_HEADER]: 'acp' }, async (client, browser, transport) => {
|
|
await client.callTool({ name: 'get_page_tree', arguments: {} })
|
|
const agent = agentOf(browser) as { session: string; kind: string }
|
|
expect(agent.kind).toBe('acp')
|
|
|
|
await transport.terminateSession()
|
|
const closed = () => browser.requests.find((item) => item.command === 'agent_session_closed')
|
|
// The notice reaches the app over its socket after the DELETE is answered.
|
|
for (let attempt = 0; attempt < 50 && !closed(); attempt++) await Bun.sleep(10)
|
|
expect(closed()?.args).toEqual({ session: agent.session })
|
|
})
|
|
})
|
|
})
|