diff --git a/.changeset/mcp-session-stale-epoch-fail-closed.md b/.changeset/mcp-session-stale-epoch-fail-closed.md new file mode 100644 index 000000000..28187d86c --- /dev/null +++ b/.changeset/mcp-session-stale-epoch-fail-closed.md @@ -0,0 +1,18 @@ +--- +"agent-bundle": patch +--- + +Fail MCP playground tool calls closed when the session's pinned artifact +epoch is removed underneath it. A long-lived `agent-bundle dev` server whose +project changed substantially — edits plus `agent-bundle build` runs from +another process, whose epoch retention cannot observe this process's epoch +leases — could lose the epoch a live MCP session was bound to. Tool calls +then kept executing against a vanished artifact or pended without any +indication that the project had changed. `tools/call` now probes the epoch +store before dispatch and on failure: a vanished epoch raises a typed +`McpSessionStaleEpochError`, cancels every in-flight tool call with the same +typed failure, and closes the session, mirroring the stderr-overflow +fail-closed contract. The MCP session routes surface it as a fail-closed +`AB8018` (409) diagnostic — like the artifact routes' epoch mapping — so the +Workbench playground renders the failure in its existing invocation-error +state instead of hanging silently. diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts index 1b526961d..f54422df7 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-routes.ts @@ -14,6 +14,7 @@ import { responseJson, type RequestDiagnostic, } from '../http.ts'; +import { McpSessionStaleEpochError } from './mcp-session-service.ts'; import type { McpSessionBinding, McpSessionConnectionState, @@ -209,6 +210,12 @@ const traceCursorError = (error: unknown): RequestDiagnostic | undefined => const closedSessionError = (error: unknown): boolean => error instanceof Error && error.message === 'MCP session is closed.'; +/** Epoch-bound sessions fail closed when their pinned epoch vanished, like the artifact routes. */ +const staleEpochDiagnostic = (error: unknown): RequestDiagnostic | undefined => + error instanceof McpSessionStaleEpochError + ? diagnostic('AB8018', 'MCP session epoch is no longer available; the project changed underneath the session.', 409) + : undefined; + /** * HTTP boundary for the deliberately small browser MCP operation contract. * It never turns browser input into a launcher, environment, source path, or @@ -247,6 +254,8 @@ export class McpSessionRoutes { if (isRequestDiagnostic(error)) throw error; const cursor = traceCursorError(error); if (cursor !== undefined) throw requestError(cursor); + const staleEpoch = staleEpochDiagnostic(error); + if (staleEpoch !== undefined) throw requestError(staleEpoch); if (closedSessionError(error)) throw this.#unavailable(); if (parsed.kind === 'create') { throw requestError(diagnostic('AB8019', 'MCP session could not be opened.', 400)); diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index 92fad50bc..6ada681a2 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -42,6 +42,7 @@ import { import { McpSessionServiceCloseError, + McpSessionStaleEpochError, type McpClient, type McpSessionServiceCloseFailure, type McpSessionServiceOptions, @@ -51,7 +52,7 @@ import { type StdioTransport, } from './mcp-session-types.ts'; -export { McpSessionServiceCloseError }; +export { McpSessionServiceCloseError, McpSessionStaleEpochError }; export { mcpAppClientCapabilities }; export { McpSession } from './mcp-session.ts'; export type { @@ -295,6 +296,10 @@ export class McpSessionService { pluginData = await mkdtemp(resolve(tmpdir(), 'agent-bundle-mcp-')); const sessionId = randomUUID(); session = new McpSession({ + assertEpochAvailable: async () => { + const probe = await this.#epochStore.acquireEpochReference(options.epochId); + await probe.close(); + }, binding: { epochId: options.epochId, serverName: options.serverName, target }, createClient: this.#createClient, createStdioTransport: this.#createStdioTransport, diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index 57c837fd3..8820299d8 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -135,6 +135,25 @@ export interface McpSessionServiceCloseFailure { readonly sessionId?: McpSessionId; } +/** + * The session's pinned artifact epoch is no longer available: the project + * changed underneath the session (typically another process's build + * retention, which cannot observe this process's epoch leases). Tool calls + * fail closed with this error instead of hanging against a vanished artifact. + */ +export class McpSessionStaleEpochError extends Error { + readonly epochId: string; + + constructor(epochId: string, options?: Readonly<{ readonly cause?: unknown }>) { + super( + `MCP session epoch ${JSON.stringify(epochId)} is no longer available; the project changed underneath the session.`, + options?.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = 'McpSessionStaleEpochError'; + this.epochId = epochId; + } +} + /** Reports every session-service lifecycle failure after all tracked work settles. */ export class McpSessionServiceCloseError extends Error { readonly failures: readonly McpSessionServiceCloseFailure[]; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts index 144f69725..5d44d59f7 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts @@ -33,20 +33,21 @@ import { } from './mcp-session-launch.ts'; import { McpSessionTraceLog, type McpSessionTraceSink } from './mcp-session-trace.ts'; import { RecordingTransport } from './mcp-recording-transport.ts'; -import type { - McpClient, - McpRequestOptions as RequestOptions, - McpSessionConnectionState, - McpSessionEvent, - McpSessionFrame, - McpSessionPromptOptions, - McpSessionReplay, - McpSessionRequestOptions, - McpSessionResourceOptions, - McpSessionToolCallOptions, - RemoteTransportOptions, - StdioOptions, - StdioTransport, +import { + McpSessionStaleEpochError, + type McpClient, + type McpRequestOptions as RequestOptions, + type McpSessionConnectionState, + type McpSessionEvent, + type McpSessionFrame, + type McpSessionPromptOptions, + type McpSessionReplay, + type McpSessionRequestOptions, + type McpSessionResourceOptions, + type McpSessionToolCallOptions, + type RemoteTransportOptions, + type StdioOptions, + type StdioTransport, } from './mcp-session-types.ts'; // A session request can legitimately sit behind an rsbuild compile or Chrome @@ -125,6 +126,7 @@ const captureStderr = ( * artifact epoch are fixed when the session opens. */ export class McpSession { + readonly #assertEpochAvailable: (() => Promise) | undefined; readonly #binding: McpSessionBinding; readonly #createClient: () => McpClient; readonly #createStdioTransport: (options: StdioOptions) => StdioTransport; @@ -144,6 +146,7 @@ export class McpSession { readonly #requests = new Map(); #capture: StderrCapture | undefined; #client: McpClient | undefined; + #staleEpochFailure: McpSessionStaleEpochError | undefined; #closePromise: Promise | undefined; #closed = false; #connection: McpSessionConnectionState | undefined; @@ -154,6 +157,8 @@ export class McpSession { #stderrOverflow = false; constructor(options: { + /** Fail-closed probe that the session's pinned epoch still exists in its store. */ + readonly assertEpochAvailable?: () => Promise; readonly binding: McpSessionBinding; readonly createClient: () => McpClient; readonly createStdioTransport: (options: StdioOptions) => StdioTransport; @@ -168,6 +173,7 @@ export class McpSession { readonly traceSink?: McpSessionTraceSink; readonly workspaceRoot: string; }) { + this.#assertEpochAvailable = options.assertEpochAvailable; this.#binding = Object.freeze({ ...options.binding }); this.#createClient = options.createClient; this.#createStdioTransport = options.createStdioTransport; @@ -300,6 +306,7 @@ export class McpSession { throw options.signal.reason ?? new Error('MCP session tool call was aborted.'); } return this.#operation('callTool', async () => { + await this.#assertEpochCurrent(); const requestId = options.requestId ?? randomUUID(); if (requestId.trim().length === 0) throw new Error('MCP session requestId must be nonempty.'); if (this.#requests.has(requestId)) throw new Error(`MCP session request ${JSON.stringify(requestId)} is already active.`); @@ -314,6 +321,17 @@ export class McpSession { }); this.#throwIfStderrExceeded(); return result; + } catch (error) { + // A call that failed while the epoch vanished mid-flight reports the + // stale epoch, not the incidental abort or timeout it produced. + if (this.#staleEpochFailure === undefined && !this.#closed && this.#assertEpochAvailable !== undefined) { + try { + await this.#assertEpochAvailable(); + } catch (cause) { + this.#failStaleEpoch(cause); + } + } + throw this.#staleEpochFailure ?? error; } finally { options.signal?.removeEventListener('abort', onAbort); this.#requests.delete(requestId); @@ -381,6 +399,31 @@ export class McpSession { if (this.#closed) throw new Error('MCP session is closed.'); } + /** + * Fails a tool call closed when the pinned epoch no longer exists — the + * project changed underneath the session (often another process's build + * retention, which cannot observe this process's epoch leases). Discovery + * cancels every in-flight request with the same typed failure and closes + * the session, mirroring the stderr-overflow contract. + */ + async #assertEpochCurrent(): Promise { + if (this.#staleEpochFailure !== undefined) throw this.#staleEpochFailure; + if (this.#assertEpochAvailable === undefined) return; + try { + await this.#assertEpochAvailable(); + } catch (cause) { + throw this.#failStaleEpoch(cause); + } + } + + #failStaleEpoch(cause: unknown): McpSessionStaleEpochError { + this.#staleEpochFailure ??= new McpSessionStaleEpochError(this.#binding.epochId, { cause }); + const failure = this.#staleEpochFailure; + this.#cancelAll(failure.message); + void this.close().catch(() => undefined); + return failure; + } + async #operation(operation: McpSessionOperation, run: () => Promise): Promise { this.#recordOperation(operation, 'started'); try { diff --git a/packages/agent-bundle/tests/mcp-session-routes.test.ts b/packages/agent-bundle/tests/mcp-session-routes.test.ts index 2213fdcdd..03730a16a 100644 --- a/packages/agent-bundle/tests/mcp-session-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-session-routes.test.ts @@ -7,6 +7,7 @@ import { type McpSessionRouteService, type McpSessionRouteSession, } from '../src/dev/mcp-session/mcp-session-routes.ts'; +import { McpSessionStaleEpochError } from '../src/dev/mcp-session/mcp-session-service.ts'; import type { McpSessionConnectionState, McpSessionInspectorConfig, @@ -37,8 +38,11 @@ class RecordingSession implements McpSessionRouteSession { #traceOverflow: McpSessionReplayOverflow | undefined; #sequence = 0; + callToolError: Error | undefined; + callTool(options: { readonly arguments: Record; readonly name: string; readonly requestId?: string }): Promise { this.calls.push({ kind: 'callTool', options }); + if (this.callToolError !== undefined) return Promise.reject(this.callToolError); return Promise.resolve({ content: [{ text: 'forecast', type: 'text' }], structuredContent: { temperature: 20 } }); } @@ -186,6 +190,29 @@ it('admits one positive session timeout with the immutable session snapshot', as } }); +it('maps a stale-epoch tool call failure to its fail-closed diagnostic', async () => { + const service = new RecordingService(); + service.session.callToolError = new McpSessionStaleEpochError('epoch-a'); + const started = await startRoutes(service); + + try { + const response = await fetch(`${started.url}/api/mcp/sessions/session-a/operations`, { + body: JSON.stringify({ arguments: {}, name: 'forecast', operation: 'tools/call' }), + headers: { ...headers(), 'content-type': 'application/json' }, + method: 'POST', + }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8018', + message: 'MCP session epoch is no longer available; the project changed underneath the session.', + }, + }); + } finally { + await started.close(); + } +}); + it('rejects invalid and smuggled session timeout request shapes', async () => { const service = new RecordingService(); const started = await startRoutes(service); diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index a4e066a34..e65b30728 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -435,6 +435,54 @@ it('pins the selected epoch until the persistent session closes', async () => { } }, 30_000); +it('fails tool calls closed with a typed stale-epoch error when the pinned epoch is removed underneath the session', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-stale-epoch-')); + try { + const epochStore = await publishFixtureEpoch(root, 'epoch-1'); + const service = new McpSessionService({ epochStore, projectRoot: root }); + // A session timeout beyond this test's own timeout proves the stale-epoch + // failure below is fail-fast rather than the SDK request timeout. + const session = await service.open({ epochId: 'epoch-1', serverName: 'fixture', target: 'portable', timeoutMs: 120_000 }); + + // The serving project moves on; the pinned session must keep working. + await publishEpochCopy( + root, + epochStore, + join(root, '.agent-bundle', 'epochs', 'epoch-1'), + 'epoch-2', + '2026-08-14T12:00:02.000Z', + ); + await session.callTool({ arguments: {}, name: 'inspect' }); + + const inFlight = session.callTool({ arguments: {}, name: 'hang', requestId: 'stale-epoch-in-flight' }); + const inFlightFailure = inFlight.then( + () => { throw new Error('Expected the in-flight tool call to fail.'); }, + (error: unknown) => error, + ); + + // Another process's build retention cannot observe this process's epoch + // leases: it removes the pinned epoch directory and metadata underneath + // the live session while `active-epoch.json` already names epoch-2. + await rm(join(root, '.agent-bundle', 'epochs', 'epoch-1'), { force: true, recursive: true }); + await rm(join(root, '.agent-bundle', 'epochs', '.metadata', 'epoch-1.json'), { force: true }); + + await expect(session.callTool({ arguments: {}, name: 'inspect' })).rejects.toMatchObject({ + epochId: 'epoch-1', + message: 'MCP session epoch "epoch-1" is no longer available; the project changed underneath the session.', + name: 'McpSessionStaleEpochError', + }); + await expect(inFlightFailure).resolves.toMatchObject({ + epochId: 'epoch-1', + name: 'McpSessionStaleEpochError', + }); + expect(service.get(session.id)).toBeUndefined(); + await session.close(); + await service.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('executes only the acquired epoch reference root when service and store roots differ', async () => { const serviceRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-service-root-')); const storeRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-store-root-'));