openpencil/tests/engine/mcp/server/index.test.ts
Victor Wads 690c1247e4
feat(collab): show MCP agents, follow streamed JSX, and follow your agents as they work (#725)
* feat(MCP): follow agent activity in canvas

* feat(collab): show MCP, ACP, and harness sessions as agents

MCP clients worked on the document unseen: only the built-in chat had a
presence, and following agent activity meant a separate setting that
moved the viewport after every MCP tool. Each MCP session now shows as
an agent with a callsign in its owner's color, like the chat: the MCP
server forwards the session and the client's name with each tool call,
the app's own ACP and Pi harness chats mark their sessions with a
header, and the agent points at the layers each call reads or changes
on their page. It rests after a quiet spell, leaves when its session
ends or the server disconnects, and collaborators see it through
awareness. Following it works like following anyone else, from the
avatars, so the default-on follow setting and its viewport fitting go.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(ai): move the chat's agent through JSX as it streams

While the built-in chat streamed a render call, its preview grew on the
canvas but the agent stood still until the tool finished. The preview
now reports, after each update, the element that appeared last and the
bounds of what the JSX builds; the agent's cursor follows that element
and its outline traces the preview, for collaborators too, until the
tool runs and the agent outlines the real layers. Peers' outlines are
validated and capped like their selections.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(collab): glide cursors and the followed view instead of jumping

People's and agents' cursors jumped to each new point, which with
throttled awareness and an agent streaming JSX made them stutter, and
following re-centered the view in one jump on every update. Cursors now
ease to each new point from wherever they are drawn, and following pans
and zooms the view there the same way, stopping in place when you take
over. With animations off or reduced motion, both move at once. Each
cursor carries an id so it keeps its glide between updates.

Co-authored-by: Victor Wads <victor@wads.dev>

* test(collab): cover MCP agents and the streaming agent in the browser

Test runs send MCP requests through the bridge's own command handler, so
a browser test can show an MCP session as an agent to the editor and to
a collaborator without a separate server. The streaming JSX test checks
that the chat's agent cursor and outline follow the newest element.

Co-authored-by: Victor Wads <victor@wads.dev>

* docs: describe agent sessions, streaming cursors, and gliding follow

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(ai): keep the streaming agent's name off the text it writes

The chat's agent sat at the newest streamed element's top-left corner,
so its name label covered the text being written. It now sits at the
element's trailing corner, where content grows.

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(collab): end only a closed connection's own MCP sessions

When the app's connection to the MCP server closed, every MCP session's
agent left, including sessions that never came over that connection,
such as a second editor's. The bridge now remembers the sessions each
connection carried and ends only those.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(ai): follow your agents automatically while they work

Agents spun up from the chat or an MCP client worked out of sight and
then went idle, so people had to find what changed, and the agent skill
told agents to move the user's view and selection after every edit. A
Follow agents toggle in the AI panel's header, on by default, now has
the view follow our agents from the start of each run: whichever starts
first, then the next one at work once the followed agent rests. Leaving
the page, moving the view, Escape, or Stop following leaves that agent
alone until it rests; people are never followed this way. The skill no
longer asks agents to select and zoom to their work.

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(collab): keep an MCP agent when a restarted bridge carries its session

Restarting MCP disconnects the old bridge and opens a new one at once,
but the browser reports the old socket's close only after its close
handshake. A tool call that reached the new connection in between was
undone by that close, which ended the session and removed its agent.
Sessions now record every connection their calls came over, across
bridges, and end only when the last of them closes.

The docs no longer say that following an agent keeps it from editing
out of sight: following moves your view, and the stale variables and
follow bullets the changelog's union merge brought back are removed.

Co-authored-by: Victor Wads <victor@wads.dev>

* test(collab): record what the bridge sends instead of an empty fake method

Co-authored-by: Victor Wads <victor@wads.dev>

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-07 10:04:58 +00:00

774 lines
27 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 { 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[] = []) {
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,
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,
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 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 })
})
})
})