openpencil/tests/engine/mcp/server/index.test.ts
Victor Wads 68ffd72839
feat(mcp): let MCP clients read only the selection, with a compact get_selection (#732)
* 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>
2026-10-07 13:01:28 +00:00

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 })
})
})
})