From 9bf470d0bb80353d438ca376517fe0cb176f2823 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 19:09:21 +0800 Subject: [PATCH 1/2] feat(agent-adapter,engine,daemon): simulator MCP tools for every MCP-capable agent --- apps/daemon/package.json | 4 +- .../src/__tests__/sim-mcp-endpoint.test.ts | 129 ++++++++ apps/daemon/src/index.ts | 30 +- apps/daemon/src/sim/mcp-endpoint.ts | 308 ++++++++++++++++++ packages/client/core/src/client.ts | 18 + .../foundation/schema/src/wire/simulator.ts | 9 + packages/host/agent-adapter/AGENTS.md | 2 +- .../src/__tests__/mcp-passthrough.test.ts | 73 +++++ .../agent-adapter/src/native/claude-code.ts | 24 ++ .../agent-adapter/src/native/codex/adapter.ts | 41 ++- .../src/native/opencode/adapter.ts | 49 ++- .../agent-adapter/src/native/pi/adapter.ts | 6 + .../simulator-request-handler.test.ts | 3 +- .../src/__tests__/start-options-mcp.test.ts | 68 ++++ packages/host/engine/src/deps.ts | 12 +- packages/host/engine/src/engine.ts | 19 +- packages/host/engine/src/index.ts | 9 + .../engine/src/session/lifecycle-service.ts | 18 +- .../src/session/start-options-resolver.ts | 28 +- packages/host/engine/src/simulator/mcp.ts | 26 ++ packages/host/engine/src/simulator/service.ts | 9 +- pnpm-lock.yaml | 6 + 22 files changed, 849 insertions(+), 42 deletions(-) create mode 100644 apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts create mode 100644 apps/daemon/src/sim/mcp-endpoint.ts create mode 100644 packages/host/agent-adapter/src/__tests__/mcp-passthrough.test.ts create mode 100644 packages/host/engine/src/__tests__/start-options-mcp.test.ts create mode 100644 packages/host/engine/src/simulator/mcp.ts diff --git a/apps/daemon/package.json b/apps/daemon/package.json index bbd138cf7..59509b97c 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -27,11 +27,13 @@ "@linkcode/schema": "workspace:*", "@linkcode/sim": "workspace:*", "@linkcode/transport": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", "@sentry/node": "10.62.0", "@sentry/profiling-node": "10.62.0", "better-sqlite3": "^12.11.1", "foxts": "^5.8.0", - "pino": "^10.3.1" + "pino": "^10.3.1", + "zod": "catalog:" }, "devDependencies": { "@effect/opentelemetry": "4.0.0-beta.98", diff --git a/apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts b/apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts new file mode 100644 index 000000000..fd1769131 --- /dev/null +++ b/apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts @@ -0,0 +1,129 @@ +import type { SimulatorBackend } from '@linkcode/engine'; +import { SimulatorService } from '@linkcode/engine'; +import type { McpServer, SessionId } from '@linkcode/schema'; +// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths defeat the resolver; tsc resolves them fine +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +// eslint-disable-next-line import-x/no-unresolved -- same exports-map subpath as above +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { asyncNoop, noop } from 'foxts/noop'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SimulatorMcpEndpoint } from '../sim/mcp-endpoint'; + +const S1 = 'session-1' as SessionId; +const S2 = 'session-2' as SessionId; + +function fakeBackend(): SimulatorBackend { + return { + probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })), + list: vi.fn(() => + Promise.resolve([ + { + udid: 'U-1', + name: 'iPhone 17', + state: 'Shutdown', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', + deviceType: null, + }, + ]), + ), + boot: vi.fn(asyncNoop), + shutdownDevice: vi.fn(asyncNoop), + install: vi.fn(asyncNoop), + launch: vi.fn(() => Promise.resolve(77)), + terminate: vi.fn(asyncNoop), + openUrl: vi.fn(asyncNoop), + screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8, 0x02]))), + close: vi.fn(noop), + }; +} + +function urlOf(entry: McpServer | undefined): string { + if (entry?.type !== 'http') throw new Error('expected an http MCP endpoint'); + return entry.url; +} + +async function connect(url: string): Promise { + const client = new Client({ name: 'test', version: '0.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; +} + +describe('SimulatorMcpEndpoint', () => { + let endpoint: SimulatorMcpEndpoint | undefined; + + afterEach(() => { + endpoint?.close(); + endpoint = undefined; + }); + + it('serves session-scoped tools over MCP streamable http', async () => { + const activity: string[] = []; + endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), { + activity(a) { + activity.push(`${a.tool}:${a.phase}:${a.sessionId}`); + }, + }); + const entry = endpoint.endpointFor(S1); + expect(entry).toMatchObject({ type: 'http', name: 'linkcode-sim' }); + + const client = await connect(urlOf(entry)); + const tools = await client.listTools(); + expect(tools.tools.map((t) => t.name).sort()).toEqual([ + 'sim_boot', + 'sim_install', + 'sim_launch', + 'sim_list_devices', + 'sim_open_url', + 'sim_screenshot', + 'sim_shutdown', + 'sim_terminate', + ]); + + const listed = await client.callTool({ name: 'sim_list_devices', arguments: {} }); + expect(JSON.stringify(listed.content)).toContain('U-1'); + + const shot = await client.callTool({ + name: 'sim_screenshot', + arguments: { udid: 'U-1' }, + }); + expect(shot.content).toEqual([ + { + type: 'image', + data: Buffer.from([0xff, 0xd8, 0x02]).toString('base64'), + mimeType: 'image/jpeg', + }, + ]); + expect(activity).toContain('sim_screenshot:started:session-1'); + expect(activity).toContain('sim_screenshot:settled:session-1'); + await client.close(); + }); + + it('enforces cross-session ownership through the shared service', async () => { + const service = new SimulatorService(fakeBackend()); + endpoint = await SimulatorMcpEndpoint.create(service); + const first = await connect(urlOf(endpoint.endpointFor(S1))); + const second = await connect(urlOf(endpoint.endpointFor(S2))); + + const claimed = await first.callTool({ + name: 'sim_launch', + arguments: { udid: 'U-1', bundleId: 'com.example' }, + }); + expect(claimed.isError).toBeFalsy(); + + const stolen = await second.callTool({ + name: 'sim_launch', + arguments: { udid: 'U-1', bundleId: 'com.example' }, + }); + expect(stolen.isError).toBe(true); + expect(JSON.stringify(stolen.content)).toContain('in use by another session'); + await first.close(); + await second.close(); + }); + + it('rejects unknown tokens and released sessions', async () => { + endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend())); + const url = urlOf(endpoint.endpointFor(S1)); + endpoint.release(S1); + await expect(connect(url)).rejects.toThrow(); + }); +}); diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 46b1176ec..f93400fe4 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -8,10 +8,12 @@ import { EngineService, makeEngineInfrastructureLayer, PreviewRouteRegistry, + SimulatorService, } from '@linkcode/engine'; import type { DaemonIdentity, DaemonListenerInfo, DaemonRuntimeInfo } from '@linkcode/schema'; import { DAEMON_EXIT_ALREADY_RUNNING, ManagedAssetIdSchema } from '@linkcode/schema'; import { SimSidecarClient } from '@linkcode/sim'; +import { createWireMessage } from '@linkcode/transport'; import { Hub } from '@linkcode/transport/server'; import * as Sentry from '@sentry/node'; import type { Runtime } from 'effect'; @@ -38,6 +40,7 @@ import { import { createScheduleStore } from './schedule-store'; import { createSessionStore } from './session-store'; import { resolveSimSidecarPath } from './sim/backend'; +import { SimulatorMcpEndpoint } from './sim/mcp-endpoint'; import { createWorkspaceStore } from './workspace-store'; // After an uncaught exception the process state (live sessions, mid-writes) is untrustworthy — @@ -208,13 +211,34 @@ async function main(): Promise { // status`) that take seconds on a cold machine — listener bind must not wait on them, or // every client sits on ECONNREFUSED for the whole probe. The engine seeds from the promise. const agentRuntimesReady = agentRuntimeProber.collect(); + // Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no + // simulator surface at all, which is what the capability gate reads. The daemon owns the + // service (not just the sidecar client) so the MCP endpoint and the engine share one + // device-claims registry. const simSidecarPath = resolveSimSidecarPath(); + const simulators = simSidecarPath + ? new SimulatorService(new SimSidecarClient(simSidecarPath)) + : undefined; + const simulatorMcp = simulators + ? yield* Effect.promise(() => + SimulatorMcpEndpoint.create(simulators, { + activity(activity) { + hub.send(createWireMessage({ kind: 'simulator.activity', ...activity })); + }, + devicesChanged(devices) { + hub.send(createWireMessage({ kind: 'simulator.devices.changed', devices })); + }, + }), + ) + : undefined; + if (simulatorMcp) { + yield* Effect.addFinalizer(() => finalize(() => simulatorMcp.close())); + } const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, { providerStore: store, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), - // Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no - // simulator surface at all, which is what the capability gate reads. - simulatorBackend: simSidecarPath ? new SimSidecarClient(simSidecarPath) : undefined, + simulators, + simulatorMcp, sessionStore: createSessionStore(databasePath()), // After sessionStore so its migration-ledger reconcile runs before this store migrates. scheduleStore: createScheduleStore(databasePath()), diff --git a/apps/daemon/src/sim/mcp-endpoint.ts b/apps/daemon/src/sim/mcp-endpoint.ts new file mode 100644 index 000000000..94f0c0965 --- /dev/null +++ b/apps/daemon/src/sim/mcp-endpoint.ts @@ -0,0 +1,308 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import type { SimulatorMcpProvider, SimulatorService } from '@linkcode/engine'; +import type { McpServer as McpServerEntry, SessionId } from '@linkcode/schema'; +// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths (./server/*.js) defeat the resolver; tsc resolves them fine +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +// eslint-disable-next-line import-x/no-unresolved -- same exports-map subpath as above +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { nullthrow } from 'foxts/guard'; +import { noop } from 'foxts/noop'; +import { z } from 'zod'; +import { logger } from '../logger'; + +/** `simulator.activity` broadcast hook — the panel's "agent is driving this device" badge. */ +export type SimulatorActivityNotify = (activity: { + sessionId: SessionId; + udid?: string; + tool: string; + phase: 'started' | 'settled'; +}) => void; + +/** Broadcast hooks the daemon wires to the hub. `devicesChanged` mirrors the wire handler's + * post-command push — agent-driven boots must move the panel's device list too. */ +export interface SimulatorMcpNotifications { + activity?: SimulatorActivityNotify; + devicesChanged?: (devices: Awaited>) => void; +} + +const SERVER_NAME = 'linkcode-sim'; +const RE_MCP_PATH = /^\/mcp\/([\w-]+)$/; + +/** + * The daemon's built-in simulator MCP endpoint (CODE-395): a loopback HTTP server speaking MCP + * streamable-HTTP, one session-bound token per LinkCode session. Tools call the engine's + * {@link SimulatorService} under that session, so agents obey the same ownership, caps, and + * reclaim rules as wire clients — nothing here reaches around the policy layer to the sidecar. + * + * Requests are handled statelessly (a fresh server+transport per POST): agent SDK clients + * re-initialize on reconnect anyway, and per-session state already lives in the engine. + */ +export class SimulatorMcpEndpoint implements SimulatorMcpProvider { + private readonly tokens = new Map(); + private readonly tokenBySession = new Map(); + + private constructor( + private readonly server: Server, + private readonly simulators: SimulatorService, + private readonly notify?: SimulatorMcpNotifications, + ) {} + + static create( + this: void, + simulators: SimulatorService, + notify?: SimulatorMcpNotifications, + ): Promise { + return new Promise((resolve, reject) => { + let endpoint: SimulatorMcpEndpoint | undefined; + const server = createServer((req, res) => { + // handle() never rejects (it catches internally); the catch is stream-error paranoia. + endpoint?.handle(req, res).catch(noop); + }); + endpoint = new SimulatorMcpEndpoint(server, simulators, notify); + server.once('error', reject); + // Loopback only, ephemeral port: the endpoint carries no auth beyond its per-session + // token path, so it must never be reachable off-host. + server.listen(0, '127.0.0.1', () => resolve(nullthrow(endpoint))); + }); + } + + private boundPort(): number { + const address = this.server.address(); + if (typeof address !== 'object' || address === null) { + throw new Error('sim MCP endpoint is not listening'); + } + return address.port; + } + + endpointFor(sessionId: SessionId): McpServerEntry | undefined { + let token = this.tokenBySession.get(sessionId); + if (!token) { + token = randomUUID(); + this.tokenBySession.set(sessionId, token); + this.tokens.set(token, sessionId); + } + return { + type: 'http', + name: SERVER_NAME, + url: `http://127.0.0.1:${this.boundPort()}/mcp/${token}`, + }; + } + + release(sessionId: SessionId): void { + const token = this.tokenBySession.get(sessionId); + if (!token) return; + this.tokenBySession.delete(sessionId); + this.tokens.delete(token); + } + + close(): void { + this.tokens.clear(); + this.tokenBySession.clear(); + this.server.close(); + } + + /** Reached only through `create()`'s request closure — not via `this`, which the lint rule + * tracks; keep it package-private by convention rather than `private`. */ + async handle(req: IncomingMessage, res: ServerResponse): Promise { + const token = RE_MCP_PATH.exec(req.url?.split('?', 1)[0] ?? '')?.[1]; + const sessionId = token === undefined ? undefined : this.tokens.get(token); + if (sessionId === undefined) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'unknown MCP endpoint' })); + return; + } + if (req.method !== 'POST') { + // Stateless mode: no SSE stream to GET, no MCP session to DELETE. + res.writeHead(405).end(); + return; + } + try { + const body: unknown = JSON.parse(await readBody(req)); + const server = this.buildServer(sessionId); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } catch (err) { + logger.warn({ err, operation: 'sim.mcp' }, 'sim MCP request failed'); + if (!res.headersSent) res.writeHead(500).end(); + } + } + + /** Best-effort mirror of the wire handler's post-command push; the tool already succeeded. */ + private async broadcastDevices(): Promise { + if (!this.notify?.devicesChanged) return; + try { + this.notify.devicesChanged(await this.simulators.list()); + } catch { + // The next explicit list reconciles; failing the completed tool call would be worse. + } + } + + /** One MCP server per request, scoped to the token's session; tools are thin wrappers over the + * engine service, so ownership conflicts and caps surface as tool errors the agent can read. */ + private buildServer(sessionId: SessionId): McpServer { + const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' }); + const simulators = this.simulators; + const run = async ( + tool: string, + udid: string | undefined, + op: () => Promise, + ): Promise<{ content: [{ type: 'text'; text: string }]; isError?: true }> => { + this.notify?.activity?.({ sessionId, udid, tool, phase: 'started' }); + try { + return { content: [{ type: 'text', text: await op() }] }; + } catch (err) { + return { + content: [{ type: 'text', text: extractErrorMessage(err) ?? 'simulator call failed' }], + isError: true, + }; + } finally { + this.notify?.activity?.({ sessionId, udid, tool, phase: 'settled' }); + } + }; + + server.registerTool( + 'sim_list_devices', + { + description: + 'List the available iOS Simulator devices with their udid, name, state (Booted/Shutdown), and runtime.', + inputSchema: {}, + }, + () => run('sim_list_devices', undefined, async () => JSON.stringify(await simulators.list())), + ); + server.registerTool( + 'sim_boot', + { + description: + 'Boot an iOS Simulator device by udid and wait until it is fully usable. Booting an already-booted device succeeds.', + inputSchema: { udid: z.string().min(1) }, + }, + ({ udid }) => + run('sim_boot', udid, async () => { + await simulators.boot(sessionId, udid); + await this.broadcastDevices(); + return `booted ${udid}`; + }), + ); + server.registerTool( + 'sim_shutdown', + { + description: 'Shut down an iOS Simulator device by udid.', + inputSchema: { udid: z.string().min(1) }, + }, + ({ udid }) => + run('sim_shutdown', udid, async () => { + await simulators.shutdownDevice(sessionId, udid); + await this.broadcastDevices(); + return `shut down ${udid}`; + }), + ); + server.registerTool( + 'sim_install', + { + description: + 'Install a built .app bundle onto a booted iOS Simulator device. appPath must be an absolute path to the .app directory (e.g. from DerivedData or xcodebuild -derivedDataPath).', + inputSchema: { udid: z.string().min(1), appPath: z.string().min(1) }, + }, + ({ udid, appPath }) => + run('sim_install', udid, async () => { + await simulators.install(sessionId, udid, appPath); + return `installed ${appPath}`; + }), + ); + server.registerTool( + 'sim_launch', + { + description: 'Launch an installed app by bundle id on a booted iOS Simulator device.', + inputSchema: { udid: z.string().min(1), bundleId: z.string().min(1) }, + }, + ({ udid, bundleId }) => + run('sim_launch', udid, async () => { + const pid = await simulators.launch(sessionId, udid, bundleId); + return pid === null ? `launched ${bundleId}` : `launched ${bundleId} (pid ${pid})`; + }), + ); + server.registerTool( + 'sim_terminate', + { + description: 'Terminate a running app by bundle id on an iOS Simulator device.', + inputSchema: { udid: z.string().min(1), bundleId: z.string().min(1) }, + }, + ({ udid, bundleId }) => + run('sim_terminate', udid, async () => { + await simulators.terminate(sessionId, udid, bundleId); + return `terminated ${bundleId}`; + }), + ); + server.registerTool( + 'sim_open_url', + { + description: + 'Open a URL on a booted iOS Simulator device (deep links, or web pages in Safari).', + inputSchema: { udid: z.string().min(1), url: z.string().min(1) }, + }, + ({ udid, url }) => + run('sim_open_url', udid, async () => { + await simulators.openUrl(sessionId, udid, url); + return `opened ${url}`; + }), + ); + server.registerTool( + 'sim_screenshot', + { + description: + 'Capture the current screen of a booted iOS Simulator device and return it as an image. Use this to see what the app looks like right now.', + inputSchema: { udid: z.string().min(1), format: z.enum(['jpeg', 'png']).optional() }, + }, + async ({ udid, format }) => { + const tool = 'sim_screenshot'; + this.notify?.activity?.({ sessionId, udid, tool, phase: 'started' }); + try { + const chosen = format ?? 'jpeg'; + const image = await simulators.screenshot(sessionId, udid, chosen); + return { + content: [ + { + type: 'image' as const, + data: Buffer.from(image).toString('base64'), + mimeType: chosen === 'jpeg' ? 'image/jpeg' : 'image/png', + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: extractErrorMessage(err) ?? 'simulator screenshot failed', + }, + ], + isError: true as const, + }; + } finally { + this.notify?.activity?.({ sessionId, udid, tool, phase: 'settled' }); + } + }, + ); + return server; + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index d4528c706..88c272daa 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -107,6 +107,12 @@ type AssetProgressCb = (event: AssetProgressEvent) => void; type AssetSettledCb = (event: AssetSettledEvent) => void; type AgentRuntimesChangedCb = (runtimes: AgentRuntimes) => void; type SimulatorDevicesChangedCb = (devices: SimulatorDevice[]) => void; +type SimulatorActivityCb = (activity: { + sessionId: SessionId; + udid?: string; + tool: string; + phase: 'started' | 'settled'; +}) => void; type ConnectionCloseCb = (error: Error) => void; /** A broadcast about a schedule's or its runs' state — the three `schedule.*` push variants. */ @@ -147,6 +153,7 @@ export class LinkCodeClient { private readonly assetSettledSubs = new Set(); private readonly agentRuntimesChangedSubs = new Set(); private readonly simulatorDevicesChangedSubs = new Set(); + private readonly simulatorActivitySubs = new Set(); private readonly connectionCloseSubs = new Set(); private unsub: Unsubscribe | null = null; private offClose: Unsubscribe | null = null; @@ -327,6 +334,11 @@ export class LinkCodeClient { case 'simulator.devices.changed': for (const cb of this.simulatorDevicesChangedSubs) cb(p.devices); break; + case 'simulator.activity': + for (const cb of this.simulatorActivitySubs) { + cb({ sessionId: p.sessionId, udid: p.udid, tool: p.tool, phase: p.phase }); + } + break; case 'asset.listed': this.pending.resolve('assetList', p.replyTo, p.assets); break; @@ -669,6 +681,12 @@ export class LinkCodeClient { return () => this.simulatorDevicesChangedSubs.delete(cb); } + /** Agent MCP tool activity on simulators — drives the "agent is using this device" badge. */ + subscribeSimulatorActivity(cb: SimulatorActivityCb): Unsubscribe { + this.simulatorActivitySubs.add(cb); + return () => this.simulatorActivitySubs.delete(cb); + } + setProviderConfig(providers: ProvidersConfig): Promise { return this.control.setProviderConfig(providers); } diff --git a/packages/foundation/schema/src/wire/simulator.ts b/packages/foundation/schema/src/wire/simulator.ts index b6bb04372..001e244ca 100644 --- a/packages/foundation/schema/src/wire/simulator.ts +++ b/packages/foundation/schema/src/wire/simulator.ts @@ -34,6 +34,15 @@ export const simulatorWireVariants = [ kind: z.literal('simulator.devices.changed'), devices: z.array(SimulatorDeviceSchema), }), + /** An agent MCP tool call started/settled on a device — the panel's "agent is driving this + * device" badge. Broadcast, uncorrelated; `udid` is absent for device-less tools (list). */ + z.object({ + kind: z.literal('simulator.activity'), + sessionId: SessionIdSchema, + udid: z.string().min(1).optional(), + tool: z.string(), + phase: z.enum(['started', 'settled']), + }), z.object({ kind: z.literal('simulator.boot'), clientReqId: WireRequestIdSchema, diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 67061ec5c..4ae3964c1 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -110,7 +110,7 @@ engine caches the emitted effort and replays it when the newly created session a - **Interactive login** (`login.ts` dispatcher `startAgentCliLogin`, kinds in `AGENT_LOGIN_KINDS`): claude-code drives `claude auth login --claudeai` (remote callback page; the user pastes the code back via stdin); codex drives `account/login/start {type:'chatgpt'}` on a short-lived app-server (`native/codex/login.ts`) whose OWN localhost callback completes the flow — no code hand-back (`submitCode` is a no-op), settle = `account/login/completed {success, error?}`. Auth probing: claude `auth status --json` (stdout, structured); codex `login status` (TEXT only — signed-out rides STDERR + exit 1, parse both streams; `parseCodexLoginStatus` fails open on rewording). - **Fixed bypass visibility**: Pi's in-process tools and Grok Build's headless `--permission-mode bypassPermissions` have no interactive approval path. Both advertise a single `bypassPermissions` policy with an explicit non-switchable description; this is visibility only, never a claim that approval is available. `set-approval-policy` continues to reject. - **Cancellation**: codex `turn/interrupt` (queued prompts dropped; a cancel before the turn id is known is armed and fired on arrival); pi `session.abort()`; opencode `client.session.abort({sessionID, directory})` with the wait capped at 2s (opencode has blocked the abort RPC until the running tool exits — tens of seconds on 1.14.42+; the local cancel proceeds and the abort settles server-side); claude-code `Query#interrupt()` — if no terminal frame arrives before its ack, the old Query is detached and the next prompt rebuilds from the last session id so late cancellation fallout cannot settle a new turn; grok-build kills the active headless process. After any cancel, base `send()` also calls `teardown()`. -- **MCP gap**: `StartOptions.mcpServers` is DEFINED in `schema/agent.ts` but consumed by NO native adapter (claude `query()` doesn't forward it) — a ready-but-unwired slot blocking all MCP passthrough (CODE-93 prerequisite). Pi's SDK has no `mcpServers` support at all; the other three could inject stdio MCP once wired. +- **MCP passthrough** (CODE-93/395): `StartOptions.mcpServers` (stdio + http) is wired into claude-code (`options.mcpServers` record — `claudeMcpServers`), codex (`thread/start` `config` dotted keys `mcp_servers..*`; an http entry also arms `experimental_use_rmcp_client` — live-verified on 0.144.1 against the daemon's streamable-HTTP endpoint; http `headers` have no config key and reject loudly), and opencode (`createOpencode` `config.mcp` — http→`remote`, stdio→`local`; `opencodeMcpConfig`). Pi's SDK has no MCP support: its adapter rejects a non-empty `mcpServers` loudly (the engine's injection gate `MCP_CAPABLE_AGENT_KINDS` never includes it). The daemon's built-in simulator MCP endpoint is the first injected server (`apps/daemon/src/sim/mcp-endpoint.ts`). ## Version seams diff --git a/packages/host/agent-adapter/src/__tests__/mcp-passthrough.test.ts b/packages/host/agent-adapter/src/__tests__/mcp-passthrough.test.ts new file mode 100644 index 000000000..57e708bbd --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/mcp-passthrough.test.ts @@ -0,0 +1,73 @@ +import type { McpServer } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { claudeMcpServers } from '../native/claude-code'; +import { codexMcpConfigOverrides } from '../native/codex/adapter'; +import { opencodeMcpConfig } from '../native/opencode/adapter'; + +const HTTP: McpServer = { type: 'http', name: 'linkcode-sim', url: 'http://127.0.0.1:7777/mcp/t' }; +const STDIO: McpServer = { + type: 'stdio', + name: 'files', + command: '/usr/local/bin/files-mcp', + args: ['--root', '/tmp'], + env: { DEBUG: '1' }, +}; + +describe('claudeMcpServers', () => { + it('maps http and stdio entries onto the SDK record shape', () => { + expect(claudeMcpServers([HTTP, STDIO])).toEqual({ + 'linkcode-sim': { type: 'http', url: HTTP.url }, + files: { + type: 'stdio', + command: '/usr/local/bin/files-mcp', + args: ['--root', '/tmp'], + env: { DEBUG: '1' }, + }, + }); + }); + + it('returns undefined for empty input so query options omit the key', () => { + expect(claudeMcpServers(undefined)).toBeUndefined(); + expect(claudeMcpServers([])).toBeUndefined(); + }); +}); + +describe('codexMcpConfigOverrides', () => { + it('maps entries onto dotted config keys and arms the rmcp client for http', () => { + expect(codexMcpConfigOverrides([HTTP, STDIO])).toEqual({ + 'mcp_servers.linkcode-sim.url': HTTP.url, + 'mcp_servers.files.command': '/usr/local/bin/files-mcp', + 'mcp_servers.files.args': ['--root', '/tmp'], + 'mcp_servers.files.env': { DEBUG: '1' }, + experimental_use_rmcp_client: true, + }); + }); + + it('rejects http servers with headers instead of silently dropping auth', () => { + expect(() => + codexMcpConfigOverrides([{ ...HTTP, headers: { authorization: 'Bearer x' } }]), + ).toThrow('HTTP headers are not supported'); + }); + + it('returns an empty record for no servers', () => { + expect(codexMcpConfigOverrides(undefined)).toEqual({}); + }); +}); + +describe('opencodeMcpConfig', () => { + it('maps http to remote and stdio to local command arrays', () => { + expect(opencodeMcpConfig([HTTP, STDIO])).toEqual({ + 'linkcode-sim': { type: 'remote', url: HTTP.url, enabled: true }, + files: { + type: 'local', + command: ['/usr/local/bin/files-mcp', '--root', '/tmp'], + enabled: true, + environment: { DEBUG: '1' }, + }, + }); + }); + + it('returns undefined for empty input so server options omit the key', () => { + expect(opencodeMcpConfig(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index addaa2d88..95b8738f9 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -5,6 +5,7 @@ import { env } from 'node:process'; import type { CanUseTool, HookCallback, + McpServerConfig, PermissionMode, PermissionResult, Query, @@ -35,6 +36,7 @@ import type { ApprovalPolicyState, ContentBlock, EffortLevel, + McpServer, PermissionOption, StartOptions, StopReason, @@ -235,6 +237,26 @@ function effortFlagSettings( return { ultracode: null, effortLevel: effort }; } +/** Map wire `McpServer` entries onto the SDK's keyed record shape (undefined when none). */ +export function claudeMcpServers( + servers: McpServer[] | undefined, +): Record | undefined { + if (!servers?.length) return undefined; + const out: Record = {}; + for (const server of servers) { + out[server.name] = + server.type === 'http' + ? { type: 'http', url: server.url, ...(server.headers && { headers: server.headers }) } + : { + type: 'stdio', + command: server.command, + ...(server.args && { args: server.args }), + ...(server.env && { env: server.env }), + }; + } + return out; +} + /** Map Claude's stop reason to our ACP-aligned StopReason. */ export function mapClaudeStop(reason: string | null): StopReason { switch (reason) { @@ -639,6 +661,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { // The SDK has no apiKey/baseURL option — the resolved account reaches the subprocess via `env` // (see `claudeCodeEnv` for the replace-vs-spread and omit-to-inherit semantics). const credentialEnv = claudeCodeEnv(env, readAgentCredential(opts.config)); + const mcpServers = claudeMcpServers(opts.mcpServers); let q: Query | null = null; const reflectCurrentQueryEffort: HookCallback = (input, toolUseID, hookOptions) => { if (q === null || this.q !== q) return Promise.resolve({ continue: true }); @@ -679,6 +702,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { allowDangerouslySkipPermissions: true, resume, additionalDirectories: opts.additionalDirectories, + ...(mcpServers && { mcpServers }), ...(credentialEnv && { env: credentialEnv }), }, }); diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index a378ebbfc..a98afc2f6 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -21,6 +21,7 @@ import { EffortLevelSchema, textBlock } from '@linkcode/schema'; import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant, nullthrow } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { AUTH_FAILED_ERROR_CODE } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { codexEnv, readAgentCredential } from '../../credential'; @@ -193,6 +194,36 @@ function sandboxPolicyFor(policyId: CodexPolicyId, writableRoots: string[]): unk }; } +/** + * `thread/start` `config` dotted-key overrides for MCP servers — the same override channel as + * `sandbox_workspace_write.writable_roots`. HTTP servers ride codex's streamable-HTTP MCP client, + * which sits behind `experimental_use_rmcp_client`. Header forwarding has no known config key, so + * a headered HTTP server is rejected loudly instead of silently connecting unauthenticated. + */ +export function codexMcpConfigOverrides( + servers: StartOptions['mcpServers'], +): Record { + const out: Record = {}; + if (!servers?.length) return out; + let hasHttp = false; + for (const server of servers) { + const prefix = `mcp_servers.${server.name}`; + if (server.type === 'http') { + if (server.headers && !isObjectEmpty(server.headers)) { + throw new Error(`codex: MCP server ${server.name}: HTTP headers are not supported`); + } + hasHttp = true; + out[`${prefix}.url`] = server.url; + } else { + out[`${prefix}.command`] = server.command; + if (server.args) out[`${prefix}.args`] = server.args; + if (server.env) out[`${prefix}.env`] = server.env; + } + } + if (hasHttp) out.experimental_use_rmcp_client = true; + return out; +} + /** Map an app-server item status (`inProgress`/`completed`/`failed`/`declined`) to ours; * `declined` (approval denied) lands on `failed`, same as claude-code's denied tool_result. */ export function mapCodexItemStatus(status: string | undefined): ToolCallStatus { @@ -592,14 +623,18 @@ export class CodexAdapter extends BaseAgentAdapter { const resume = this.resumeFrom ?? undefined; this.resumeFrom = undefined; const preset = POLICY_PRESETS[this.policyId]; + const configOverrides = { + ...(opts.additionalDirectories?.length && { + 'sandbox_workspace_write.writable_roots': opts.additionalDirectories, + }), + ...codexMcpConfigOverrides(opts.mcpServers), + }; const params = { cwd: opts.cwd, model: this.model, approvalPolicy: preset.approvalPolicy, ...(this.sandboxOverrideAllowed() && { sandbox: preset.sandboxMode }), - ...(opts.additionalDirectories?.length && { - config: { 'sandbox_workspace_write.writable_roots': opts.additionalDirectories }, - }), + ...(!isObjectEmpty(configOverrides) && { config: configOverrides }), }; // A fresh thread's rollout holds nothing yet: announcing its history id now would trigger the // clients' transcript seed read, whose uptoSeq cut can swallow the first prompt. Hold the diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 402f05241..8b26fbf47 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -16,9 +16,17 @@ import type { Question, StartOptions, } from '@linkcode/schema'; -import type { Event, FilePartInput, Part, TextPartInput } from '@opencode-ai/sdk/v2'; +import type { + Event, + FilePartInput, + McpLocalConfig, + McpRemoteConfig, + Part, + TextPartInput, +} from '@opencode-ai/sdk/v2'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { falseFn } from 'foxts/noop'; import { wait } from 'foxts/wait'; import { AUTH_FAILED_ERROR_CODE, nextToolCallId } from '../../adapter'; @@ -60,6 +68,32 @@ const ABORT_TIMED_OUT = Symbol('opencode-abort-timeout'); /** Prevent a server that repeatedly closes an empty SSE response from spinning a hot subscribe loop. */ const EVENT_RESUBSCRIBE_DELAY_MS = 100; +/** Map wire `McpServer` entries onto opencode's keyed `config.mcp` shape (undefined when none). + * http → `remote`; stdio → `local` with `[command, ...args]` and `environment`. */ +export function opencodeMcpConfig( + servers: StartOptions['mcpServers'], +): Record | undefined { + if (!servers?.length) return undefined; + const out: Record = {}; + for (const server of servers) { + out[server.name] = + server.type === 'http' + ? { + type: 'remote', + url: server.url, + enabled: true, + ...(server.headers && { headers: server.headers }), + } + : { + type: 'local', + command: [server.command, ...(server.args ?? [])], + enabled: true, + ...(server.env && { environment: server.env }), + }; + } + return out; +} + /** Most `session.error` variants carry `data.message`; fall back to the variant name. */ function sessionErrorMessage(error: NonNullable): string { const message = (error.data as { message?: unknown } | undefined)?.message; @@ -201,13 +235,16 @@ export class OpenCodeAdapter extends BaseAgentAdapter { const key = cred.apiKey ?? cred.authToken; if (key) options.apiKey = key; if (cred.baseUrl) options.baseURL = cred.baseUrl; - const serverOptions = - providerID && (options.apiKey || options.baseURL) - ? { config: { provider: { [providerID]: { options } } } } - : undefined; + const providerInjected = Boolean(providerID && (options.apiKey || options.baseURL)); + const mcp = opencodeMcpConfig(opts.mcpServers); + const config = { + ...(providerInjected && providerID && { provider: { [providerID]: { options } } }), + ...(mcp && { mcp }), + }; + const serverOptions = isObjectEmpty(config) ? undefined : { config }; // The injection is spawn-time-only: remember which provider it scoped to so a later // set-model can refuse a cross-provider switch the running server holds no credentials for. - this.credentialProviderId = serverOptions ? (providerID ?? null) : null; + this.credentialProviderId = providerInjected ? (providerID ?? null) : null; try { // The SDK's server port is a FIXED default of 4096 (opencode's own `--port=0` does not // auto-allocate either), and this adapter spawns one server per session — without an diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 2ff0088c6..352d722e8 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -192,6 +192,12 @@ export class PiAdapter extends BaseAgentAdapter { } protected async onStart(opts: StartOptions): Promise { + // Reject rather than silently drop (the historyCapabilities discipline): pi's SDK has no MCP + // support at all, and the engine's injection gate already skips pi — an explicit request + // reaching here is a caller bug that must not degrade into missing tools. + if (opts.mcpServers?.length) { + throw new Error('pi: MCP servers are not supported'); + } // Managed closure entry first (the packaged source, CODE-219), then node_modules // self-resolution (dev/standalone). The entry import is type-erased by the dynamic path; // the closure manifest is lockfile-generated, so its bytes match the compiled-against types. diff --git a/packages/host/engine/src/__tests__/simulator-request-handler.test.ts b/packages/host/engine/src/__tests__/simulator-request-handler.test.ts index 16636c746..4782ed004 100644 --- a/packages/host/engine/src/__tests__/simulator-request-handler.test.ts +++ b/packages/host/engine/src/__tests__/simulator-request-handler.test.ts @@ -5,6 +5,7 @@ import { nullthrow } from 'foxts/guard'; import { asyncNoop, noop } from 'foxts/noop'; import { describe, expect, it, vi } from 'vitest'; import type { SimulatorBackend } from '../simulator/backend'; +import { SimulatorService } from '../simulator/service'; import { FakeAdapter, settleEngineTasks, startedSessionId } from './fixtures/session-harness'; import { createTestEngine } from './fixtures/test-engine'; @@ -51,7 +52,7 @@ function harness(backend?: SimulatorBackend) { close: noop, }; const engine = createTestEngine(transport, { - simulatorBackend: backend, + simulators: backend ? new SimulatorService(backend) : undefined, factory: () => new FakeAdapter(), }); async function inject(payload: WirePayload): Promise { diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts new file mode 100644 index 000000000..f862bc607 --- /dev/null +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -0,0 +1,68 @@ +import type { McpServer, SessionId } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { describe, expect, it } from 'vitest'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { SessionStartOptionsResolver } from '../session/start-options-resolver'; +import type { SimulatorMcpProvider } from '../simulator/mcp'; + +const SESSION = 'session-1' as SessionId; +const ENDPOINT: McpServer = { + type: 'http', + name: 'linkcode-sim', + url: 'http://127.0.0.1:7777/mcp/token-1', +}; + +function provider(endpoint: McpServer | undefined): SimulatorMcpProvider { + return { + endpointFor: () => endpoint, + release: noop, + }; +} + +describe('simulator MCP injection at session start', () => { + it('appends the session endpoint for MCP-capable agents', async () => { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + ); + const resolved = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + expect(resolved.mcpServers).toEqual([ENDPOINT]); + }); + + it('preserves explicitly requested servers ahead of the injected one', async () => { + const explicit: McpServer = { type: 'http', name: 'custom', url: 'http://127.0.0.1:9/x' }; + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + ); + const resolved = await Effect.runPromise( + resolver.resolve({ kind: 'opencode', cwd: '/repo', mcpServers: [explicit] }, SESSION), + ); + expect(resolved.mcpServers).toEqual([explicit, ENDPOINT]); + }); + + it('never injects for pi, and copes with an absent capability', async () => { + const withProvider = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + ); + const pi = await Effect.runPromise(withProvider.resolve({ kind: 'pi', cwd: '/repo' }, SESSION)); + expect(pi.mcpServers).toBeUndefined(); + + const unavailable = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(undefined), + ); + const resolved = await Effect.runPromise( + unavailable.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + expect(resolved.mcpServers).toBeUndefined(); + }); +}); diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 7f3e24580..65b65d10c 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -8,7 +8,8 @@ import type { LoopStore, ScheduleStore } from './automation'; import type { GitService } from './git/git-service'; import type { PreviewRouteRegistry } from './preview/route-registry'; import type { SessionStore } from './session/session-store'; -import type { SimulatorBackend } from './simulator/backend'; +import type { SimulatorMcpProvider } from './simulator/mcp'; +import type { SimulatorService } from './simulator/service'; import type { PtyBackend } from './terminal/pty-backend'; import type { FileSuggestService } from './workspace/file-suggest-service'; import type { WorkspaceStore } from './workspace/workspace-store'; @@ -18,8 +19,13 @@ export interface EngineDeps { factory?: AdapterFactory; sessionStore?: SessionStore; ptyBackend?: PtyBackend; - /** iOS Simulator sidecar client (macOS hosts only); absent Engines have no simulator surface. */ - simulatorBackend?: SimulatorBackend; + /** iOS Simulator policy service, daemon-constructed around the sidecar client so the daemon's + * MCP endpoint and the engine share one claims registry (macOS hosts only); absent Engines + * have no simulator surface. */ + simulators?: SimulatorService; + /** Mints the per-session simulator MCP endpoint injected into MCP-capable agents' start + * options; absent hosts inject nothing. */ + simulatorMcp?: SimulatorMcpProvider; providerStore?: ProviderConfigStore; git?: GitService; fileSuggest?: FileSuggestService; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 4b4a2037d..a5f808a96 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -36,7 +36,6 @@ import { SessionRecordRegistry } from './session/session-record-registry'; import { InMemorySessionStore } from './session/session-store'; import { SessionStartOptionsResolver } from './session/start-options-resolver'; import { SimulatorRequestHandler } from './simulator/request-handler'; -import { SimulatorService } from './simulator/service'; import { TerminalRequestHandler } from './terminal/request-handler'; import { TerminalService } from './terminal/service'; import { WireRequestRouter } from './wire/request-router'; @@ -84,9 +83,10 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runTask, ); let terminals: TerminalService | undefined; - // Constructed after `sessions` (both reference it lazily), mirroring `terminals`, so the - // session-existence predicate can gate simulator claims on a live session. - let simulators: SimulatorService | undefined; + // The daemon builds the service (so it can share the instance with the MCP endpoint) and injects + // it here; the engine owns the session registry, so it hands the service a session-existence + // predicate that gates claims on a live session. + const simulators = deps.simulators; const sessions = new SessionOrchestrator( transport, factory, @@ -97,11 +97,10 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( (sessionId) => { terminals?.killBySession(sessionId); simulators?.releaseSession(sessionId); + deps.simulatorMcp?.release(sessionId); }, ); - simulators = deps.simulatorBackend - ? new SimulatorService(deps.simulatorBackend, { hasSession: (id) => sessions.has(id) }) - : undefined; + simulators?.setSessionValidator((id) => sessions.has(id)); terminals = deps.ptyBackend ? new TerminalService(deps.ptyBackend, transport, (id) => sessions.has(id)) : undefined; @@ -128,7 +127,11 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const artifacts = new ArtifactHostService(routes); const artifactRequests = new ArtifactRequestHandler(transport, artifacts, responder); const translator = deps.translator; - const startOptions = new SessionStartOptionsResolver(providerStore, translator); + const startOptions = new SessionStartOptionsResolver( + providerStore, + translator, + deps.simulatorMcp, + ); const sessionLifecycle = new SessionLifecycleService( sessions, records, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index d0bdbac5a..71b81df2d 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -18,5 +18,14 @@ export { makeEngineLayer, } from './service'; export type { SessionStore } from './session/session-store'; +export type { + SimulatorBackend, + SimulatorDeviceInfo, + SimulatorImageFormat, + SimulatorProbe, +} from './simulator/backend'; +export type { SimulatorMcpProvider } from './simulator/mcp'; +export { MCP_CAPABLE_AGENT_KINDS } from './simulator/mcp'; +export { SimulatorService } from './simulator/service'; export type { PtyBackend, PtyOpenOptions, PtyProcess } from './terminal/pty-backend'; export type { WorkspaceStore } from './workspace/workspace-store'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 31b854a62..035dce0e2 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -57,7 +57,7 @@ export class SessionLifecycleService { const { sessions, startOptions, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const resolved = yield* startOptions.resolve(options); + const resolved = yield* startOptions.resolve(options, sessionId); const now = Date.now(); const record: SessionRecord = { sessionId, @@ -122,7 +122,7 @@ export class SessionLifecycleService { const { history, sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ ...options, kind }); + const startOptions = yield* resolver.resolve({ ...options, kind }, sessionId); const now = Date.now(); const record: SessionRecord = { sessionId, @@ -165,7 +165,10 @@ export class SessionLifecycleService { const historyId = this.records.historyId(sessionId); const { history, sessions, startOptions: resolver, workspaces } = this; return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ kind: record.kind, cwd: record.cwd }); + const startOptions = yield* resolver.resolve( + { kind: record.kind, cwd: record.cwd }, + sessionId, + ); // Register before starting so a persistence failure cannot follow a successful // `session.started` reply with a contradictory request failure. if (record.cwd) yield* workspaceTouch(workspaces, record.cwd); @@ -189,11 +192,10 @@ export class SessionLifecycleService { const { sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ - kind: options.kind, - cwd: options.cwd, - model: options.model, - }); + const startOptions = yield* resolver.resolve( + { kind: options.kind, cwd: options.cwd, model: options.model }, + sessionId, + ); const now = Date.now(); const record: SessionRecord = { sessionId, diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index a6747d975..1da123dd5 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,23 +1,29 @@ -import type { StartOptions } from '@linkcode/schema'; +import type { SessionId, StartOptions } from '@linkcode/schema'; import { Effect } from 'effect'; import type { ProviderConfigStore } from '../agent/provider-config'; import { applyProviderDefaults } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; import { translationUpstream, withTranslatorEndpoint } from '../agent/translator'; import { OperationError, RequestError } from '../failure'; +import type { SimulatorMcpProvider } from '../simulator/mcp'; +import { MCP_CAPABLE_AGENT_KINDS } from '../simulator/mcp'; -/** Resolves daemon-owned provider defaults and the optional cross-protocol translation endpoint. */ +/** Resolves daemon-owned provider defaults, the optional cross-protocol translation endpoint, + * and the per-session simulator MCP injection for MCP-capable agents. */ export class SessionStartOptionsResolver { constructor( private readonly providers: ProviderConfigStore, private readonly translator: TranslatorService | undefined, + private readonly simulatorMcp?: SimulatorMcpProvider, ) {} - resolve(options: StartOptions): Effect.Effect { - const resolved = applyProviderDefaults( - options, - this.providers.get(), - this.providers.getAccounts(), + resolve( + options: StartOptions, + sessionId: SessionId, + ): Effect.Effect { + const resolved = this.withSimulatorMcp( + applyProviderDefaults(options, this.providers.get(), this.providers.getAccounts()), + sessionId, ); const upstream = translationUpstream(resolved); if (!upstream) return Effect.succeed(resolved); @@ -41,4 +47,12 @@ export class SessionStartOptionsResolver { }), }).pipe(Effect.map((url) => withTranslatorEndpoint(resolved, url))); } + + /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ + private withSimulatorMcp(options: StartOptions, sessionId: SessionId): StartOptions { + if (!this.simulatorMcp || !MCP_CAPABLE_AGENT_KINDS.has(options.kind)) return options; + const endpoint = this.simulatorMcp.endpointFor(sessionId); + if (!endpoint) return options; + return { ...options, mcpServers: [...(options.mcpServers ?? []), endpoint] }; + } } diff --git a/packages/host/engine/src/simulator/mcp.ts b/packages/host/engine/src/simulator/mcp.ts new file mode 100644 index 000000000..32018bfe6 --- /dev/null +++ b/packages/host/engine/src/simulator/mcp.ts @@ -0,0 +1,26 @@ +import type { AgentKind, McpServer, SessionId } from '@linkcode/schema'; + +/** + * Agent kinds whose SDKs accept MCP server configuration. pi runs in-process with no MCP support + * at all, and grok-build's headless CLI exposes none — the engine never injects for those, and + * their adapters loudly reject explicit `mcpServers` rather than silently dropping them. + */ +export const MCP_CAPABLE_AGENT_KINDS: ReadonlySet = new Set([ + 'claude-code', + 'codex', + 'opencode', +]); + +/** + * Daemon-owned provider of the per-session simulator MCP endpoint (CODE-395). The daemon mints a + * loopback URL with a session-bound token, so every tool call lands in the engine's + * {@link ../simulator/service!SimulatorService} under the right session — ownership and caps + * apply to agents exactly as they do to wire clients. + */ +export interface SimulatorMcpProvider { + /** The MCP server entry to inject into a session's start options, or undefined when the + * simulator capability is absent on this host. */ + endpointFor(sessionId: SessionId): McpServer | undefined; + /** Forget a session's endpoint token (called when the session stops). */ + release(sessionId: SessionId): void; +} diff --git a/packages/host/engine/src/simulator/service.ts b/packages/host/engine/src/simulator/service.ts index 2135aa41e..f348ab06c 100644 --- a/packages/host/engine/src/simulator/service.ts +++ b/packages/host/engine/src/simulator/service.ts @@ -44,7 +44,7 @@ interface DeviceClaim { export class SimulatorService { private readonly claims = new Map(); private readonly idleReclaimMs: number; - private readonly hasSession?: (sessionId: SessionId) => boolean; + private hasSession?: (sessionId: SessionId) => boolean; private probedStatus?: SimulatorHostStatus; constructor( @@ -55,6 +55,13 @@ export class SimulatorService { this.hasSession = opts?.hasSession; } + /** Install the live-session predicate. The daemon builds this service (to share it with the MCP + * endpoint) before the engine's session registry exists, so the engine wires the validator here + * once it has one; `claim()` then rejects operations from sessions the engine never started. */ + setSessionValidator(hasSession: (sessionId: SessionId) => boolean): void { + this.hasSession = hasSession; + } + probe(): Promise<{ simctlPath: string; developerDir: string }> { return this.backend.probe(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87745335d..70e03fb97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: '@linkcode/transport': specifier: workspace:* version: link:../../packages/foundation/transport + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@sentry/node': specifier: 10.62.0 version: 10.62.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) @@ -219,6 +222,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@effect/opentelemetry': specifier: 4.0.0-beta.98 From 90abf338056a0c186358a865650625ed90bfb80f Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 03:34:37 +0800 Subject: [PATCH 2/2] fix(sim): release MCP token on failed start, don't shadow user servers, cap MCP body (wire 45) --- apps/daemon/src/sim/mcp-endpoint.ts | 22 +++++++++++++++++-- .../foundation/schema/src/wire/message.ts | 3 ++- .../src/__tests__/start-options-mcp.test.ts | 18 +++++++++++++++ .../host/engine/src/session/orchestrator.ts | 11 +++++++--- .../src/session/start-options-resolver.ts | 7 +++++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/daemon/src/sim/mcp-endpoint.ts b/apps/daemon/src/sim/mcp-endpoint.ts index 94f0c0965..527361191 100644 --- a/apps/daemon/src/sim/mcp-endpoint.ts +++ b/apps/daemon/src/sim/mcp-endpoint.ts @@ -298,11 +298,29 @@ export class SimulatorMcpEndpoint implements SimulatorMcpProvider { } } +/** MCP tool calls are small JSON envelopes; cap the body so a buggy or hostile client on the + * loopback endpoint can't grow it without bound and exhaust memory. */ +const MAX_MCP_BODY_BYTES = 4 * 1024 * 1024; + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + let size = 0; + let aborted = false; + req.on('data', (chunk: Buffer) => { + if (aborted) return; + size += chunk.length; + if (size > MAX_MCP_BODY_BYTES) { + aborted = true; + req.destroy(); + reject(new Error('MCP request body exceeds the size limit')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (!aborted) resolve(Buffer.concat(chunks).toString('utf8')); + }); req.on('error', reject); }); } diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 9ce58e1ff..c303d3249 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -14,7 +14,8 @@ import { WirePayloadSchema } from './payload'; // 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for // file.host/file.hosted, keeping every distinct schema on a distinct protocol version. // 44 adds the simulator.* variants (CODE-394). -export const WIRE_PROTOCOL_VERSION = 44 as const; +// 45 adds the simulator.activity broadcast (CODE-395). +export const WIRE_PROTOCOL_VERSION = 45 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index f862bc607..9a3a9529f 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -46,6 +46,24 @@ describe('simulator MCP injection at session start', () => { expect(resolved.mcpServers).toEqual([explicit, ENDPOINT]); }); + it('does not shadow a user server that already claims the injected name', async () => { + const userOwned: McpServer = { + type: 'http', + name: 'linkcode-sim', + url: 'http://127.0.0.1:9/u', + }; + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + ); + const resolved = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo', mcpServers: [userOwned] }, SESSION), + ); + // The user's server keeps the name; ours is not appended over it. + expect(resolved.mcpServers).toEqual([userOwned]); + }); + it('never injects for pi, and copes with an absent capability', async () => { const withProvider = new SessionStartOptionsResolver( new InMemoryProviderConfigStore(), diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 67d142a93..ee5844f8e 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -277,9 +277,14 @@ export class SessionOrchestrator { ), Effect.andThen(stopBestEffort(session.adapter)), Effect.ensuring( - Effect.suspend(() => - this.remove(sessionId, session) ? recordLiveSessions(this.sessions.size) : Effect.void, - ), + Effect.suspend(() => { + if (!this.remove(sessionId, session)) return Effect.void; + // Release what a partial start may have reserved — notably the simulator MCP endpoint + // token minted while resolving start options. Normal teardown does this via `onStopped`; + // a discarded failed start must too, or that token leaks until daemon shutdown. + this.onStopped(sessionId); + return recordLiveSessions(this.sessions.size); + }), ), Effect.onExit((exit) => Deferred.done(session.closed, exit).pipe(Effect.asVoid)), ); diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 1da123dd5..f091d7214 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -53,6 +53,11 @@ export class SessionStartOptionsResolver { if (!this.simulatorMcp || !MCP_CAPABLE_AGENT_KINDS.has(options.kind)) return options; const endpoint = this.simulatorMcp.endpointFor(sessionId); if (!endpoint) return options; - return { ...options, mcpServers: [...(options.mcpServers ?? []), endpoint] }; + const existing = options.mcpServers ?? []; + // Never shadow a user-configured server of the same name: many SDKs key servers by name and let + // the last one win, so appending ours would silently replace theirs. If the user already claims + // the name, leave their config untouched (the token is released with the session). + if (existing.some((server) => server.name === endpoint.name)) return options; + return { ...options, mcpServers: [...existing, endpoint] }; } }