diff --git a/.changeset/live-host-mcp-proxy.md b/.changeset/live-host-mcp-proxy.md new file mode 100644 index 000000000..ab3ef4986 --- /dev/null +++ b/.changeset/live-host-mcp-proxy.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Keep development hosts connected across rebuilds with an epoch-aware Streamable HTTP MCP endpoint and stable stdio proxy. New calls use the active artifact while in-flight calls retain their original epoch, and catalog changes reach hosts without reinstalling the plugin. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 38beb0f46..235f19825 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -169,11 +169,39 @@ promote selected durable outcome/assertion evidence into a draft eval case. The concise events and raw stdout/stderr/protocol streams by producer: normalization, build, diagnostics, MCP, hook, host trial, and grader. -MCP sessions bind `{ epochId, target, serverName }` when opened and never move to a new epoch -automatically. Use **Restart MCP session** to respawn that generated server on its selected epoch; -open a new session to use a newly published epoch. Compatible MCP Apps preview through the same bound -session. A host may need an explicit MCP reload when a server's catalog changes — -`notifications/tools/list_changed` is not UI HMR. +Workbench MCP sessions bind `{ epochId, target, serverName }` when opened and never move to a new +epoch automatically. Use **Restart MCP session** to respawn that generated server on its selected +epoch; open a new session to use a newly published epoch. Compatible MCP Apps preview through the +same bound session. + +### Live host MCP proxy + +During development, a host can keep one stdio MCP process connected while `agent-bundle dev` +rebuilds the generated server behind it. Configure the host's MCP server command as: + +```json +{ + "command": "agent-bundle", + "args": [ + "dev", + "proxy", + "--root", + "/absolute/path/to/plugin", + "--server", + "tools" + ] +} +``` + +The proxy discovers the loopback server through the project's development lock and connects to the +stable Streamable HTTP endpoint at `/mcp/host/`. `--target` defaults to `portable`; +`--url http://127.0.0.1:` overrides discovery. The endpoint is intentionally unauthenticated +because the development server binds only to loopback and is not exposed beyond the local machine. Successful +rebuilds keep the stdio connection open, route new calls to the active epoch, allow admitted calls +to finish against their original epoch, and forward MCP catalog change notifications. If the epoch +or development server disappears, the proxy fails closed with an MCP error and an `AB8024` or +`AB8025` diagnostic. A generated server that crashes is not silently respawned within the same +epoch; calls remain failed until a successful rebuild swaps in a newly primed epoch session. ### Optional Agent API diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index e85d030f4..c421a1f74 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -31,6 +31,7 @@ import type { DoctorReport, runDoctor, } from './install/doctor.ts'; +import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; @@ -56,6 +57,7 @@ export interface CliDependencies { readonly installBundle?: typeof installBundle; readonly prepack?: typeof prepack; readonly runDoctor?: typeof runDoctor; + readonly runHostMcpProxy?: typeof runHostMcpProxy; /** Injectable only to make foreground shutdown behavior deterministic in tests. */ readonly signals?: CliSignalSource; readonly startDevServer?: typeof startDevServer; @@ -131,6 +133,12 @@ interface DevCommandOptions { readonly root: string; } +interface DevProxyCommandOptions { + readonly server: string; + readonly target: string; + readonly url?: string; +} + const collect = (value: string, previous: string[]): string[] => [...previous, value]; const port = (value: string): number => { @@ -474,6 +482,22 @@ export const runCli = async ( closeForegroundOnSignal(session, dependencies.signals ?? process, stderr); }); + const devProxyCommand = devCommand.command('proxy') + .description('Bridge host stdio MCP traffic to a running development server') + .requiredOption('--server ', 'Generated MCP server name') + .option('--target ', 'Generated target containing the MCP server', 'portable') + .option('--url ', 'Explicit loopback development server origin'); + devProxyCommand.action(async (options: DevProxyCommandOptions) => { + const proxy = dependencies.runHostMcpProxy ?? (await import('./dev/host-mcp-proxy.ts')).runHostMcpProxy; + exitCode = await proxy({ + projectRoot: devCommand.opts().root, + serverName: options.server, + target: options.target, + ...(options.url === undefined ? {} : { url: options.url }), + writeDiagnostic: (message) => { stderr.write(`${message}\n`); }, + }); + }); + const buildCommand = configureSourceOptions( program.command('build').description('Build a validated Agent Bundle artifact'), ).option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default dist)'); diff --git a/packages/agent-bundle/src/dev/dev-lock.ts b/packages/agent-bundle/src/dev/dev-lock.ts index e3af580f0..57cd5f034 100644 --- a/packages/agent-bundle/src/dev/dev-lock.ts +++ b/packages/agent-bundle/src/dev/dev-lock.ts @@ -22,6 +22,12 @@ export interface DevLockOptions { readonly storage?: DevLockStorage; } +export interface DiscoverDevServerOptions { + readonly probeProcess?: (pid: number) => boolean; + readonly projectRoot: string; + readonly storage?: Pick; +} + export interface DevLockStorage { readonly link: typeof link; readonly lstat: typeof lstat; @@ -137,7 +143,7 @@ const parseServerUrl = (contents: string, owner: DevLockOwner): string | undefin }; const readServerUrl = async ( - storage: DevLockStorage, + storage: Pick, path: string, owner: DevLockOwner, ): Promise => { @@ -489,3 +495,31 @@ export const acquireDevLock = async (options: DevLockOptions): Promise }, }); }; + +/** Resolves the loopback foreground origin published by the live owner of one project lock. */ +export const discoverDevServerUrl = async (options: DiscoverDevServerOptions): Promise => { + const projectRoot = resolve(options.projectRoot); + const path = join(projectRoot, '.agent-bundle', devLockName); + const storage = options.storage ?? defaultStorage; + let contents: string; + try { + contents = await storage.readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw new DevLockError('DEV_LOCK_INVALID', 'No agent-bundle dev process is running for this project.'); + } + throw error; + } + const owner = parseOwner(contents, projectRoot); + if (owner === undefined) { + throw new DevLockError('DEV_LOCK_INVALID', 'The development lock does not contain valid owner metadata.'); + } + if (!(options.probeProcess ?? isProcessAlive)(owner.pid)) { + throw new DevLockError('DEV_LOCK_INVALID', 'The agent-bundle dev process recorded for this project is no longer running.'); + } + const url = await readServerUrl(storage, path, owner); + if (url === undefined) { + throw new DevLockError('DEV_LOCK_INVALID', 'The running agent-bundle dev process has not published its server URL.'); + } + return url; +}; diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 04b31becc..e319a6ebf 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -14,6 +14,7 @@ import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts'; import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts'; import { HostDiscoveryRoutes, type HostDiscoveryRouteService } from './playground/host-discovery-routes.ts'; +import type { HostMcpRoutes } from './host-mcp-routes.ts'; import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts'; import { McpProbeRoutes, type McpProbeRouteService } from './playground/mcp-probe-routes.ts'; import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; @@ -136,6 +137,8 @@ export interface ForegroundServerOptions { readonly hookPlayground?: HookPlaygroundRouteService; /** Read-only host probes, install inventory, bundle drift, and runtime endpoint health. */ readonly hostDiscovery?: HostDiscoveryRouteService; + /** Stateful MCP surface used only by stable development host proxies. */ + readonly hostMcp?: HostMcpRoutes; /** User-initiated read-only initialize and tools/list probing over trusted artifact servers. */ readonly mcpProbe?: McpProbeRouteService; /** Read-only semantic lifecycle replay over the latest valid prepared graph. */ @@ -442,6 +445,7 @@ export class ForegroundServer { readonly #eventHub: ProjectEventHub; readonly #hookPlaygroundRoutes: HookPlaygroundRoutes; readonly #hostDiscoveryRoutes: HostDiscoveryRoutes; + readonly #hostMcpRoutes: HostMcpRoutes | undefined; readonly #host: string; readonly #inspectorRoutes: InspectorRoutes; readonly #lifecycleReplayRoutes: LifecycleReplayRoutes; @@ -487,6 +491,7 @@ export class ForegroundServer { this.#evalLifecycle = options.evalLifecycle; this.#eventHub = options.eventHub; this.#host = host; + this.#hostMcpRoutes = options.hostMcp; this.instanceId = instanceId; this.#mcpAppPreviews = options.mcpAppPreviews; this.#now = options.now ?? (() => new Date()); @@ -682,6 +687,7 @@ export class ForegroundServer { async #release(): Promise { this.#mcpAppRoutes.close(); + this.#hostMcpRoutes?.close(); this.#mcpSessionRoutes.close(); this.#runtimeMcpRoutes.close(); this.#runtimeRoutes.close(); @@ -773,6 +779,7 @@ export class ForegroundServer { } const pathname = new URL(request.url ?? '/', this.url).pathname; const method = request.method ?? 'GET'; + if (await this.#hostMcpRoutes?.handle(request, response)) return; if (pathname === '/mcp') { if (this.#agentApi === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404)); this.#assertAgentApiOrigin(request); diff --git a/packages/agent-bundle/src/dev/host-mcp-proxy.ts b/packages/agent-bundle/src/dev/host-mcp-proxy.ts new file mode 100644 index 000000000..2426591d1 --- /dev/null +++ b/packages/agent-bundle/src/dev/host-mcp-proxy.ts @@ -0,0 +1,133 @@ +import { + StreamableHTTPClientTransport, + type JSONRPCMessage, + type Transport, +} from '@modelcontextprotocol/client'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { resolve } from 'node:path'; + +import { isRecord } from '../core/strict-json.ts'; +import { discoverDevServerUrl } from './dev-lock.ts'; + +export const hostMcpUnavailableCode = 'AB8025'; + +export interface RunHostMcpProxyOptions { + readonly projectRoot: string; + readonly serverName: string; + readonly target?: string; + readonly url?: string; + readonly writeDiagnostic?: (message: string) => void; +} + +const unavailableMessage = 'Development MCP server is unavailable.'; + +const loopbackOrigin = (value: string): string => { + const url = new URL(value); + if ( + url.protocol !== 'http:' || + (url.hostname !== '127.0.0.1' && url.hostname !== '[::1]') || + url.origin !== value + ) { + throw new TypeError('Development MCP proxy URL must be a loopback HTTP origin.'); + } + return url.origin; +}; + +const requestId = (message: JSONRPCMessage): string | number | undefined => { + const value: unknown = message; + if (!isRecord(value) || !Object.hasOwn(value, 'method') || !Object.hasOwn(value, 'id')) return undefined; + const id = value.id; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; +}; + +const errorResponse = ( + id: string | number, + cause: unknown, +): JSONRPCMessage => ({ + error: { + code: -32_603, + data: { + code: hostMcpUnavailableCode, + detail: cause instanceof Error ? cause.message : String(cause), + }, + message: unavailableMessage, + }, + id, + jsonrpc: '2.0', +}); + +const hostEndpoint = (origin: string, serverName: string, target: string): URL => { + const endpoint = new URL(`/mcp/host/${encodeURIComponent(serverName)}`, origin); + endpoint.searchParams.set('target', target); + return endpoint; +}; + +/** + * Stable stdio transport bridge used by host MCP configuration. The stdio + * process owns no plugin artifact and remains connected while the foreground + * server swaps epochs behind its stateful HTTP session. + */ +export const runHostMcpProxy = async (options: RunHostMcpProxyOptions): Promise => { + if (options.serverName.trim().length === 0) throw new TypeError('Development MCP proxy server name must be nonempty.'); + const target = options.target ?? 'portable'; + if (target.trim().length === 0) throw new TypeError('Development MCP proxy target must be nonempty.'); + const projectRoot = resolve(options.projectRoot); + const writeDiagnostic = options.writeDiagnostic ?? ((message: string) => { + process.stderr.write(`${message}\n`); + }); + const stdio = new StdioServerTransport(); + let remote: Transport | undefined; + let failed = false; + let reportedUnavailable = false; + let shuttingDown = false; + const reportUnavailable = (cause: unknown): void => { + failed = true; + if (reportedUnavailable) return; + reportedUnavailable = true; + const detail = cause instanceof Error ? ` ${cause.message}` : ''; + writeDiagnostic(`[${hostMcpUnavailableCode}] ${unavailableMessage}${detail}`); + }; + const rejectRequest = async (message: JSONRPCMessage, cause: unknown): Promise => { + reportUnavailable(cause); + const id = requestId(message); + if (id !== undefined) await stdio.send(errorResponse(id, cause)); + await stdio.close(); + }; + + try { + const origin = loopbackOrigin(options.url ?? await discoverDevServerUrl({ projectRoot })); + const transport = new StreamableHTTPClientTransport(hostEndpoint(origin, options.serverName, target)); + remote = transport; + transport.onmessage = (message) => { + void stdio.send(message).catch(reportUnavailable); + }; + transport.onerror = reportUnavailable; + transport.onclose = () => { + if (shuttingDown) return; + reportUnavailable(new Error('The foreground HTTP transport closed.')); + void stdio.close(); + }; + await transport.start(); + } catch (error) { + reportUnavailable(error); + } + + const closed = Promise.withResolvers(); + stdio.onclose = () => { + shuttingDown = true; + void remote?.close().finally(closed.resolve); + if (remote === undefined) closed.resolve(); + }; + stdio.onerror = reportUnavailable; + stdio.onmessage = (message) => { + const transport = remote; + if (transport === undefined) { + void rejectRequest(message, new Error('No running development server was discovered.')); + return; + } + void transport.send(message).catch((error: unknown) => rejectRequest(message, error)); + }; + await stdio.start(); + await closed.promise; + return failed ? 1 : 0; +}; diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts new file mode 100644 index 000000000..7c7ae349f --- /dev/null +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -0,0 +1,404 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { ProtocolError, Server, type ReadResourceResult } from '@modelcontextprotocol/server'; + +import { EpochStoreError, type EpochStore } from './epoch-store.ts'; +import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; +import { + McpSessionStaleEpochError, + type McpSession, + type McpSessionService, +} from './mcp-session/mcp-session-service.ts'; + +const hostMcpPathPrefix = '/mcp/host/'; +const internalErrorCode = -32_603; + +export const hostMcpEpochDriftCode = 'AB8024'; + +export class HostMcpEpochDriftError extends Error { + readonly code = hostMcpEpochDriftCode; + readonly epochId: string; + + constructor(epochId: string, options?: Readonly<{ readonly cause?: unknown }>) { + super( + `Active MCP epoch ${JSON.stringify(epochId)} is no longer available; the host session was invalidated.`, + options?.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = 'HostMcpEpochDriftError'; + this.epochId = epochId; + } +} + +interface HostMcpBinding { + readonly serverName: string; + readonly target?: string; +} + +interface HostMcpEpochSession { + closePromise?: Promise; + readonly epochId: string; + inFlight: number; + retired: boolean; + readonly session: McpSession; +} + +interface HostMcpRoutesOptions { + readonly epochStore: EpochStore; + readonly eventHub: ProjectEventHub; + readonly mcpSessions: McpSessionService; +} + +const requestSessionId = (request: IncomingMessage): string | undefined => { + const value = request.headers['mcp-session-id']; + return Array.isArray(value) ? value[0] : value; +}; + +const routeBinding = (requestTarget: string | undefined): HostMcpBinding | undefined => { + const parsed = new URL(requestTarget ?? '/', 'http://127.0.0.1'); + if (!parsed.pathname.startsWith(hostMcpPathPrefix)) return undefined; + const encodedName = parsed.pathname.slice(hostMcpPathPrefix.length); + if (encodedName.length === 0 || encodedName.includes('/')) return undefined; + const serverName = decodeURIComponent(encodedName); + const targets = parsed.searchParams.getAll('target'); + if (serverName.trim().length === 0 || targets.length > 1) return undefined; + const target = targets[0]; + if (target?.trim().length === 0 || [...parsed.searchParams.keys()].some((key) => key !== 'target')) return undefined; + return Object.freeze({ serverName, ...(target === undefined ? {} : { target }) }); +}; + +const isEpochDrift = (error: unknown): boolean => + error instanceof McpSessionStaleEpochError || + (error instanceof EpochStoreError && + (error.code === 'EPOCH_NOT_FOUND' || error.code === 'EPOCH_METADATA_INVALID')); + +class HostMcpConnection { + readonly #binding: HostMcpBinding; + readonly #epochStore: EpochStore; + readonly #mcpSessions: McpSessionService; + readonly #onSessionInitialized: (sessionId: string, connection: HostMcpConnection) => void; + readonly #epochSessions = new Set(); + readonly #server: Server; + readonly transport: NodeStreamableHTTPServerTransport; + #activeEpochSession: HostMcpEpochSession | undefined; + #closed = false; + #drift: HostMcpEpochDriftError | undefined; + #failed = false; + #failure: unknown; + #lastEpochId: string | undefined; + #transition = Promise.resolve(); + + constructor( + binding: HostMcpBinding, + options: Pick, + onSessionInitialized: (sessionId: string, connection: HostMcpConnection) => void, + ) { + this.#binding = binding; + this.#epochStore = options.epochStore; + this.#mcpSessions = options.mcpSessions; + this.#onSessionInitialized = onSessionInitialized; + this.#server = new Server( + { name: `agent-bundle-dev:${binding.serverName}`, version: '0.1.0' }, + { + capabilities: { + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: false }, + tools: { listChanged: true }, + }, + }, + ); + this.transport = new NodeStreamableHTTPServerTransport({ + onsessioninitialized: (sessionId) => this.#onSessionInitialized(sessionId, this), + sessionIdGenerator: randomUUID, + }); + this.#registerHandlers(); + } + + get binding(): HostMcpBinding { + return this.#binding; + } + + get sessionId(): string | undefined { + return this.transport.sessionId; + } + + async start(): Promise { + await this.#server.connect(this.transport); + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + await this.transport.handleRequest(request, response); + } + + refreshCatalog(epochId: string): void { + this.#scheduleTransition(epochId, () => this.#swapEpoch(epochId)); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#transition; + await Promise.allSettled([ + ...[...this.#epochSessions].map((binding) => this.#closeEpochSession(binding)), + this.#server.close(), + ]); + } + + #registerHandlers(): void { + this.#server.setRequestHandler('tools/list', async () => ({ + tools: [...await this.#withActiveSession((session) => session.listTools())], + })); + this.#server.setRequestHandler('tools/call', async (request) => + this.#withActiveSession((session) => session.callTool({ + arguments: request.params.arguments ?? {}, + name: request.params.name, + }))); + this.#server.setRequestHandler('resources/list', async () => ({ + resources: [...await this.#withActiveSession((session) => session.listResources())], + })); + this.#server.setRequestHandler('resources/templates/list', async () => ({ + resourceTemplates: [...await this.#withActiveSession((session) => session.listResourceTemplates())], + })); + this.#server.setRequestHandler('resources/read', async (request) => { + const result = await this.#withActiveSession((session) => session.readResource({ uri: request.params.uri })); + return { ...result, contents: [...result.contents] } as ReadResourceResult; + }); + this.#server.setRequestHandler('prompts/list', async () => ({ + prompts: [...await this.#withActiveSession((session) => session.listPrompts())], + })); + this.#server.setRequestHandler('prompts/get', async (request) => + this.#withActiveSession((session) => session.getPrompt({ + ...(request.params.arguments === undefined ? {} : { arguments: request.params.arguments }), + name: request.params.name, + }))); + } + + async #withActiveSession(operation: (session: McpSession) => Promise): Promise { + const binding = await this.#activeSession(); + binding.inFlight += 1; + try { + const reference = await this.#epochStore.acquireEpochReference(binding.epochId); + await reference.close(); + return await operation(binding.session); + } catch (error) { + if (!isEpochDrift(error)) throw error; + throw this.#protocolDrift(this.#invalidate(binding.epochId, error)); + } finally { + binding.inFlight -= 1; + if (binding.retired && binding.inFlight === 0) { + void this.#closeEpochSession(binding).catch(() => undefined); + } + } + } + + async #activeSession(): Promise { + await this.#transition; + this.#assertUsable(); + if (this.#activeEpochSession === undefined) { + this.#scheduleTransition(this.#lastEpochId ?? 'unknown', async () => { + if (this.#activeEpochSession !== undefined) return; + const reference = await this.#epochStore.acquireActiveEpochReference(); + this.#lastEpochId = reference.epoch.id; + try { + this.#activeEpochSession = await this.#openEpochSession(reference.epoch.id); + } finally { + await reference.close(); + } + }); + await this.#transition; + this.#assertUsable(); + } + const binding = this.#activeEpochSession; + if (binding === undefined) throw new Error('Host MCP epoch session did not initialize.'); + return binding; + } + + async #openEpochSession(epochId: string): Promise { + const target = this.#binding.target ?? await this.#targetFor(epochId); + const session = await this.#mcpSessions.open({ + epochId, + serverName: this.#binding.serverName, + target, + }); + const binding: HostMcpEpochSession = { + epochId, + inFlight: 0, + retired: false, + session, + }; + this.#epochSessions.add(binding); + return binding; + } + + async #swapEpoch(epochId: string): Promise { + if (this.#closed || this.#drift !== undefined || this.#failed) return; + const current = this.#activeEpochSession; + if (current?.epochId === epochId) return; + const next = await this.#openEpochSession(epochId); + try { + await Promise.all([ + next.session.listTools(), + next.session.listResources(), + next.session.listResourceTemplates(), + next.session.listPrompts(), + ]); + if (this.#closed) { + await this.#closeEpochSession(next); + return; + } + this.#lastEpochId = epochId; + this.#activeEpochSession = next; + if (current !== undefined) this.#retireEpochSession(current); + await Promise.all([ + this.#server.sendToolListChanged(), + this.#server.sendResourceListChanged(), + this.#server.sendPromptListChanged(), + ]); + } catch (error) { + await this.#closeEpochSession(next).catch(() => undefined); + throw error; + } + } + + #scheduleTransition(epochId: string, transition: () => Promise): void { + this.#transition = this.#transition.then(transition).catch((error: unknown) => { + if (isEpochDrift(error)) { + this.#invalidate(epochId === 'unknown' ? this.#lastEpochId ?? epochId : epochId, error); + return; + } + this.#fail(error); + }); + } + + #retireEpochSession(binding: HostMcpEpochSession): void { + binding.retired = true; + if (this.#activeEpochSession === binding) this.#activeEpochSession = undefined; + if (binding.inFlight === 0) void this.#closeEpochSession(binding).catch(() => undefined); + } + + #closeEpochSession(binding: HostMcpEpochSession): Promise { + binding.closePromise ??= binding.session.close().finally(() => { + this.#epochSessions.delete(binding); + }); + return binding.closePromise; + } + + async #targetFor(epochId: string): Promise { + const reference = await this.#epochStore.acquireEpochReference(epochId); + try { + const targets = Object.keys(reference.epoch.targetDigests).sort(); + const target = targets.includes('portable') ? 'portable' : targets[0]; + if (target === undefined) { + throw new EpochStoreError('EPOCH_METADATA_INVALID', 'Active artifact epoch has no generated targets.'); + } + return target; + } finally { + await reference.close(); + } + } + + #invalidate(epochId: string, cause: unknown): HostMcpEpochDriftError { + this.#drift ??= new HostMcpEpochDriftError(epochId, { cause }); + const failure = this.#drift; + this.#activeEpochSession = undefined; + for (const binding of this.#epochSessions) { + void this.#closeEpochSession(binding).catch(() => undefined); + } + return failure; + } + + #fail(cause: unknown): void { + if (!this.#failed) this.#failure = cause; + this.#failed = true; + this.#activeEpochSession = undefined; + for (const binding of this.#epochSessions) { + void this.#closeEpochSession(binding).catch(() => undefined); + } + } + + #assertUsable(): void { + if (this.#drift !== undefined) throw this.#protocolDrift(this.#drift); + if (this.#failed) throw this.#failure; + if (this.#closed) throw new Error('Host MCP connection is closed.'); + } + + #protocolDrift(error: HostMcpEpochDriftError): ProtocolError { + return new ProtocolError(internalErrorCode, error.message, { + code: error.code, + epochId: error.epochId, + }); + } +} + +/** Stateful host-facing MCP transport whose handlers resolve the active artifact epoch per operation. */ +export class HostMcpRoutes { + readonly #connections = new Set(); + readonly #epochStore: EpochStore; + readonly #mcpSessions: McpSessionService; + readonly #sessions = new Map(); + readonly #subscription: ProjectEventSubscription; + #closed = false; + + constructor(options: HostMcpRoutesOptions) { + this.#epochStore = options.epochStore; + this.#mcpSessions = options.mcpSessions; + this.#subscription = options.eventHub.subscribe((event) => { + if (event.type !== 'artifact.available') return; + for (const connection of this.#connections) connection.refreshCatalog(event.epochId); + }); + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const binding = routeBinding(request.url); + if (binding === undefined) return false; + if (this.#closed) { + response.writeHead(503).end(); + return true; + } + const sessionId = requestSessionId(request); + if (sessionId !== undefined) { + const connection = this.#sessions.get(sessionId); + if ( + connection === undefined || + connection.binding.serverName !== binding.serverName || + connection.binding.target !== binding.target + ) { + response.writeHead(404).end(); + return true; + } + await connection.handle(request, response); + if (request.method === 'DELETE') await this.#remove(connection); + return true; + } + + const connection = new HostMcpConnection( + binding, + { epochStore: this.#epochStore, mcpSessions: this.#mcpSessions }, + (id, initialized) => this.#sessions.set(id, initialized), + ); + this.#connections.add(connection); + try { + await connection.start(); + await connection.handle(request, response); + if (connection.sessionId === undefined) await this.#remove(connection); + } catch (error) { + await this.#remove(connection); + throw error; + } + return true; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#subscription.unsubscribe(); + for (const connection of this.#connections) void this.#remove(connection); + } + + async #remove(connection: HostMcpConnection): Promise { + this.#connections.delete(connection); + if (connection.sessionId !== undefined) this.#sessions.delete(connection.sessionId); + await connection.close(); + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index cb9016d22..9a09bfdf0 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -17,6 +17,7 @@ import { HostDiscoveryService, type HostDiscoveryServiceOptions, } from './playground/host-discovery-service.ts'; +import { HostMcpRoutes } from './host-mcp-routes.ts'; import { LifecycleReplayService } from './playground/lifecycle-replay-service.ts'; import { McpProbeService, @@ -506,6 +507,7 @@ const withMcpSessionLifecycle = ( runtimeResources: { clientSurfaces, runtime }, }); }, + publishServerUrl: (url: string) => coordinator.publishServerUrl(url), rebuild: (invalidation: Invalidation) => coordinator.rebuild(invalidation), start: async () => { await coordinator.start(); @@ -710,6 +712,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { const prepared = latestValidPreparedProject; @@ -826,6 +829,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { + let received: Parameters>[0] | undefined; + const result = await runSourceCliWithOutput([ + 'dev', + 'proxy', + '--root', + '/tmp/plugin project', + '--server', + 'fixture', + '--url', + 'http://127.0.0.1:4312', + ], { + runHostMcpProxy: async (options) => { + received = options; + return 0; + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: '', stdout: '' }); + expect(received).toMatchObject({ + projectRoot: '/tmp/plugin project', + serverName: 'fixture', + url: 'http://127.0.0.1:4312', + }); +}); + +it('requires a server name for the nested development proxy command', async () => { + const result = await runSourceCliWithOutput(['dev', 'proxy', '--root', '/tmp/plugin']); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("required option '--server ' not specified"); +}); + it('builds a selected target through the built executable from a path containing spaces', async () => { await buildCliPackage(); const project = await createCliProject(); diff --git a/packages/agent-bundle/tests/dev-lock.test.ts b/packages/agent-bundle/tests/dev-lock.test.ts index c012e6fe2..d44350ed2 100644 --- a/packages/agent-bundle/tests/dev-lock.test.ts +++ b/packages/agent-bundle/tests/dev-lock.test.ts @@ -4,11 +4,27 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; -import { acquireDevLock, type DevLockStorage } from '../src/dev/dev-lock.ts'; +import { acquireDevLock, discoverDevServerUrl, type DevLockStorage } from '../src/dev/dev-lock.ts'; const lockPathFor = (root: string): string => join(root, '.agent-bundle', 'dev.lock'); const recoveryPathFor = (root: string): string => `${lockPathFor(root)}.recovery`; +it('discovers only a URL published by a live development lock owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent bundle dev discovery ')); + const lock = await acquireDevLock({ projectRoot: root }); + try { + await lock.publishServerUrl('http://127.0.0.1:48721'); + await expect(discoverDevServerUrl({ projectRoot: root })).resolves.toBe('http://127.0.0.1:48721'); + await expect(discoverDevServerUrl({ + probeProcess: () => false, + projectRoot: root, + })).rejects.toMatchObject({ code: 'DEV_LOCK_INVALID' }); + } finally { + await lock.close(); + await rm(root, { force: true, recursive: true }); + } +}); + it('rejects a second writer with the live owning process URL', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle dev lock with spaces ')); diff --git a/packages/agent-bundle/tests/host-mcp-proxy.test.ts b/packages/agent-bundle/tests/host-mcp-proxy.test.ts new file mode 100644 index 000000000..108d3e3c3 --- /dev/null +++ b/packages/agent-bundle/tests/host-mcp-proxy.test.ts @@ -0,0 +1,263 @@ +import { spawn } from 'node:child_process'; +import { access, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { expect, it } from '@rstest/core'; + +import { discoverDevServerUrl } from '../src/dev/dev-lock.ts'; +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; + +const cliEntry = join(import.meta.dirname, '..', 'bin', 'agent-bundle.js'); + +const within = async (promise: Promise, milliseconds = 10_000): Promise => Promise.race([ + promise, + new Promise((_resolvePromise, rejectPromise) => { + setTimeout(() => rejectPromise(new Error(`Timed out after ${milliseconds}ms.`)), milliseconds); + }), +]); + +const serverSource = (root: string, version: 'v1' | 'v2'): string => [ + "import { access } from 'node:fs/promises';", + "import { McpServer } from '@modelcontextprotocol/server';", + "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", + '', + `const version = ${JSON.stringify(version)};`, + 'let count = 0;', + "const server = new McpServer({ name: 'host-proxy-fixture', version: '1.0.0' });", + "server.registerTool('count', { description: 'Increment session-local state.' }, async () => ({", + " content: [{ type: 'text', text: String(++count) }],", + '}));', + "server.registerTool('version', { description: 'Report the built fixture version.' }, async () => ({", + " content: [{ type: 'text', text: version }],", + '}));', + "server.registerTool('slow-version', { description: 'Complete after the test releases the call.' }, async () => {", + ` const release = ${JSON.stringify(join(root, 'release-slow-call'))};`, + ' while (true) {', + ' try { await access(release); break; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); }', + ' }', + " return { content: [{ type: 'text', text: version }] };", + '});', + ...(version === 'v2' + ? [ + "server.registerTool('new-tool', { description: 'Added by the rebuild.' }, async () => ({", + " content: [{ type: 'text', text: 'new' }],", + '}));', + ] + : []), + 'await server.connect(new StdioServerTransport());', + '', +].join('\n'); + +const writeProxyProject = async (root: string): Promise => { + const entry = join(root, 'src', 'server.ts'); + await Promise.all([ + mkdir(join(root, 'src'), { recursive: true }), + symlink( + join(agentBundleNodeModules, '@modelcontextprotocol'), + join(root, 'node_modules', '@modelcontextprotocol'), + 'dir', + ), + ]); + await Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(entry, serverSource(root, 'v1')), + writeFile(join(root, 'agent-bundle.config.ts'), [ + "import { defineConfig } from 'agent-bundle';", + '', + 'export default defineConfig({', + " mcp: { servers: { fixture: { entry: './src/server.ts' } } },", + " plugin: { name: 'host-proxy-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '});', + '', + ].join('\n')), + ]); + return entry; +}; + +const openProxy = async (root: string, url?: string) => { + const stderr: string[] = []; + const transport = new StdioClientTransport({ + args: [ + cliEntry, + 'dev', + 'proxy', + '--root', + root, + '--server', + 'fixture', + ...(url === undefined ? [] : ['--url', url]), + ], + command: process.execPath, + stderr: 'pipe', + }); + transport.stderr?.on('data', (chunk: Buffer | string) => stderr.push(chunk.toString())); + const client = new Client({ name: 'host-proxy-test', version: '1.0.0' }); + await client.connect(transport).catch(async (error: unknown) => { + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 20); }); + throw new Error(`Proxy initialize failed. Proxy stderr: ${stderr.join('')}`, { cause: error }); + }); + return { client, stderr, transport }; +}; + +const resultText = (result: Awaited>): string => { + const first = result.content[0]; + if (first?.type !== 'text') throw new Error('Expected a text tool result.'); + return first.text; +}; + +it('preserves generated-server state across calls within one host epoch', async () => { + const project = await createProjectFixture(); + let client: Client | undefined; + let server: Awaited> | undefined; + try { + await writeProxyProject(project.root); + server = await startDevServer({ open: false, port: 0, root: project.root }); + ({ client } = await openProxy(project.root, server.url)); + + expect(resultText(await client.callTool({ arguments: {}, name: 'count' }))).toBe('1'); + expect(resultText(await client.callTool({ arguments: {}, name: 'count' }))).toBe('2'); + } finally { + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 60_000); + +it('keeps one real proxy connection across rebuilds while calls stay bound to their starting epoch', async () => { + const project = await createProjectFixture(); + let client: Client | undefined; + let stderr: string[] = []; + let server: Awaited> | undefined; + try { + const entry = await writeProxyProject(project.root); + server = await startDevServer({ open: false, port: 0, root: project.root }); + expect(await discoverDevServerUrl({ projectRoot: project.root })).toBe(server.url); + ({ client, stderr } = await openProxy(project.root, server.url)); + + const listed = await client.listTools().catch((error: unknown) => { + throw new Error(`Initial tools/list failed. Proxy stderr: ${stderr.join('')}`, { cause: error }); + }); + expect(listed.tools.map((tool) => tool.name)).toEqual(['count', 'version', 'slow-version']); + expect(resultText(await client.callTool({ arguments: {}, name: 'version' }))).toBe('v1'); + + const changed = Promise.withResolvers(); + client.setNotificationHandler('notifications/tools/list_changed', async () => { + changed.resolve(); + }); + const inFlight = client.callTool({ arguments: {}, name: 'slow-version' }); + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 50); }); + await replaceWatchedSource(project.root, entry, serverSource(project.root, 'v2')); + await within(changed.promise); + + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual(['count', 'version', 'slow-version', 'new-tool']); + expect(resultText(await client.callTool({ arguments: {}, name: 'version' }))).toBe('v2'); + await writeFile(join(project.root, 'release-slow-call'), ''); + expect(resultText(await inFlight)).toBe('v1'); + expect(resultText(await client.callTool({ arguments: {}, name: 'new-tool' }))).toBe('new'); + } finally { + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 90_000); + +it('fails the connected host session closed with AB8024 when its active epoch is physically removed', async () => { + const project = await createProjectFixture(); + let client: Client | undefined; + let server: Awaited> | undefined; + try { + await writeProxyProject(project.root); + server = await startDevServer({ open: false, port: 0, root: project.root }); + ({ client } = await openProxy(project.root, server.url)); + await client.listTools(); + const artifact = server.status().artifact; + if (artifact.state !== 'active') throw new Error('Expected an active epoch.'); + const epochId = artifact.activeEpoch.id; + await rm(join(project.root, '.agent-bundle', 'epochs', epochId), { force: true, recursive: true }); + await rm(join(project.root, '.agent-bundle', 'epochs', '.metadata', `${epochId}.json`), { force: true }); + + await expect(client.listTools()).rejects.toMatchObject({ + data: { code: 'AB8024', epochId }, + }); + await expect(client.listTools()).rejects.toBeDefined(); + } finally { + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 60_000); + +it('reports AB8025 and an MCP error when the discovered dev server goes away', async () => { + const project = await createProjectFixture(); + let client: Client | undefined; + let server: Awaited> | undefined; + let stderr: string[] = []; + try { + await writeProxyProject(project.root); + server = await startDevServer({ open: false, port: 0, root: project.root }); + ({ client, stderr } = await openProxy(project.root, server.url)); + await client.listTools(); + await server.close(); + server = undefined; + + await expect(client.listTools()).rejects.toBeDefined(); + await within((async () => { + while (!stderr.join('').includes('AB8025')) { + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); }); + } + })()); + expect(stderr.join('')).toContain('Development MCP server is unavailable'); + await expect(access(join(project.root, '.agent-bundle', 'dev.lock'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await client?.close().catch(() => undefined); + await server?.close().catch(() => undefined); + await removeProjectFixture(project.root); + } +}, 60_000); + +it('exits fail-closed when no development server is running', async () => { + const project = await createProjectFixture(); + try { + const child = spawn(process.execPath, [ + cliEntry, + 'dev', + 'proxy', + '--root', + project.root, + '--server', + 'fixture', + ], { stdio: ['pipe', 'pipe', 'pipe'] }); + const stdout: string[] = []; + const stderr: string[] = []; + child.stdout.on('data', (chunk: Buffer | string) => stdout.push(chunk.toString())); + child.stderr.on('data', (chunk: Buffer | string) => stderr.push(chunk.toString())); + child.stdin.write(`${JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: { name: 'host-proxy-test', version: '1.0.0' }, + protocolVersion: '2025-06-18', + }, + })}\n`); + + const exitCode = await within(new Promise((resolvePromise) => { + child.once('close', resolvePromise); + })); + expect(exitCode).toBe(1); + expect(stderr.join('')).toContain('[AB8025] Development MCP server is unavailable.'); + const response = JSON.parse(stdout.join('').trim()) as { + readonly error?: { readonly data?: { readonly code?: string } }; + }; + expect(response.error?.data?.code).toBe('AB8025'); + } finally { + await removeProjectFixture(project.root); + } +}, 30_000); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index e14705b5c..c29d4c87a 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -36,6 +36,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-adapters.native.test.ts', 'packages/agent-bundle/tests/host-adapters.test.ts', 'packages/agent-bundle/tests/host-discovery-dev-server.test.ts', + 'packages/agent-bundle/tests/host-mcp-proxy.test.ts', 'packages/agent-bundle/tests/host-install-proof.test.ts', 'packages/agent-bundle/tests/host-install-session.test.ts', 'packages/agent-bundle/tests/installer-entry.test.ts',