Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/mcp-session-stale-epoch-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
responseJson,
type RequestDiagnostic,
} from '../http.ts';
import { McpSessionStaleEpochError } from './mcp-session-service.ts';
import type {
McpSessionBinding,
McpSessionConnectionState,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {

import {
McpSessionServiceCloseError,
McpSessionStaleEpochError,
type McpClient,
type McpSessionServiceCloseFailure,
type McpSessionServiceOptions,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
71 changes: 57 additions & 14 deletions packages/agent-bundle/src/dev/mcp-session/mcp-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,6 +126,7 @@ const captureStderr = (
* artifact epoch are fixed when the session opens.
*/
export class McpSession {
readonly #assertEpochAvailable: (() => Promise<void>) | undefined;
readonly #binding: McpSessionBinding;
readonly #createClient: () => McpClient;
readonly #createStdioTransport: (options: StdioOptions) => StdioTransport;
Expand All @@ -144,6 +146,7 @@ export class McpSession {
readonly #requests = new Map<string, AbortController>();
#capture: StderrCapture | undefined;
#client: McpClient | undefined;
#staleEpochFailure: McpSessionStaleEpochError | undefined;
#closePromise: Promise<void> | undefined;
#closed = false;
#connection: McpSessionConnectionState | undefined;
Expand All @@ -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<void>;
readonly binding: McpSessionBinding;
readonly createClient: () => McpClient;
readonly createStdioTransport: (options: StdioOptions) => StdioTransport;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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.`);
Expand All @@ -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);
Expand Down Expand Up @@ -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<void> {
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<Result>(operation: McpSessionOperation, run: () => Promise<Result>): Promise<Result> {
this.#recordOperation(operation, 'started');
try {
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-bundle/tests/mcp-session-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,8 +38,11 @@ class RecordingSession implements McpSessionRouteSession {
#traceOverflow: McpSessionReplayOverflow | undefined;
#sequence = 0;

callToolError: Error | undefined;

callTool(options: { readonly arguments: Record<string, unknown>; readonly name: string; readonly requestId?: string }): Promise<unknown> {
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 } });
}

Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions packages/agent-bundle/tests/mcp-session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down
Loading