Skip to content

Commit 08a6a3d

Browse files
refactor(dev): type MCP session lifecycle failures and scope the tool-call abort controller (#512)
* refactor(dev): type MCP session lifecycle failures and scope the tool-call abort controller Expected MCP session failures (closed session or service, uninitialized client, invalid or duplicate requestId, empty server name) now ride the Effect error channel as McpSessionError (a CodedError) instead of bare Error values; messages are unchanged so the routes' message match and every Promise caller see the same text. The tool-call request slot is a scoped resource: Effect.acquireRelease admits the requestId (fails closed on a duplicate) and owns the request's AbortController; release aborts it and frees the slot, so an interrupted fiber cannot leak a live SDK request. The host signal composes in with AbortSignal.any instead of a hand-rolled listener. * chore(changeset): record the coded MCP session errors (#512)
1 parent e647336 commit 08a6a3d

5 files changed

Lines changed: 201 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
Reject closed, uninitialized, or misused `agent-bundle dev` MCP sessions with a coded `McpSessionError` (`code` one of `session-closed`, `not-initialized`, `invalid-request-id`, `duplicate-request-id`, `invalid-server-name`, `service-closed`) instead of a bare `Error`; every message is unchanged, so existing message matches keep working. A tool call's request slot is now released — and its in-flight SDK request aborted — whenever the call is interrupted, not only when it settles. (#512)

‎packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from './mcp-session-apps.ts';
4545

4646
import {
47+
McpSessionError,
4748
McpSessionServiceCloseError,
4849
McpSessionStaleEpochError,
4950
type McpClient,
@@ -55,7 +56,8 @@ import {
5556
type StdioTransport,
5657
} from './mcp-session-types.ts';
5758

58-
export { McpSessionServiceCloseError, McpSessionStaleEpochError };
59+
export { McpSessionError, McpSessionServiceCloseError, McpSessionStaleEpochError };
60+
export type { McpSessionErrorCode } from './mcp-session-types.ts';
5961
export { mcpAppClientCapabilities };
6062
export { McpSession } from './mcp-session.ts';
6163
export type {
@@ -251,7 +253,7 @@ export class McpSessionService {
251253
}
252254

253255
async open(options: OpenMcpSessionOptions): Promise<McpSession> {
254-
if (this.#closed) throw new Error('MCP session service is closed.');
256+
if (this.#closed) throw McpSessionError.serviceClosed();
255257
const timeoutMs = requestOptions(options).timeout;
256258
const opening = openingSession();
257259
this.#openingSessions.add(opening);
@@ -303,7 +305,7 @@ export class McpSessionService {
303305
const target = options.target;
304306
const runtime = yield* liftTry(() => this.#runtime(target));
305307
if (options.serverName.trim().length === 0) {
306-
return yield* Effect.fail(new Error('MCP server name must be nonempty.'));
308+
return yield* Effect.fail(McpSessionError.invalidServerName());
307309
}
308310
const epochReference = yield* Effect.acquireRelease(
309311
liftPromise(() => this.#epochStore.acquireEpochReference(options.epochId)),
@@ -345,7 +347,7 @@ export class McpSessionService {
345347
}));
346348
constructed = session;
347349
yield* liftPromise(() => session.initialize({ signal: options.signal }));
348-
if (this.#closed) return yield* Effect.fail(new Error('MCP session service is closed.'));
350+
if (this.#closed) return yield* Effect.fail(McpSessionError.serviceClosed());
349351
this.#sessions.set(sessionId, {
350352
appLeaseCount: 0,
351353
closeWatchers: new Set(),

‎packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
McpSessionReplayOverflow,
2020
} from './mcp-session-protocol.ts';
2121
import type { McpSessionTraceSink } from './mcp-session-trace.ts';
22+
import { CodedError } from '../../core/errors.ts';
2223
import { deepFreeze } from '../../core/freeze.ts';
2324

2425

@@ -146,6 +147,55 @@ export interface McpSessionServiceCloseFailure {
146147
readonly sessionId?: McpSessionId;
147148
}
148149

150+
export type McpSessionErrorCode =
151+
| 'duplicate-request-id'
152+
| 'invalid-request-id'
153+
| 'invalid-server-name'
154+
| 'not-initialized'
155+
| 'service-closed'
156+
| 'session-closed';
157+
158+
/**
159+
* Expected session-lifecycle failures on the Effect error channel: the
160+
* session or its service is closed, a protocol call ran before `initialize`,
161+
* or a request was admitted with an invalid or already-active `requestId`.
162+
* These ride the fail channel as a `CodedError` (never `Effect.die`) and
163+
* rethrow unchanged at `src/effect/boundary.ts`, so Promise callers keep the
164+
* exact messages they saw before the class existed.
165+
*/
166+
export class McpSessionError extends CodedError<McpSessionErrorCode> {
167+
constructor(code: McpSessionErrorCode, message: string) {
168+
super('McpSessionError', code, message);
169+
}
170+
171+
static closed(): McpSessionError {
172+
return new McpSessionError('session-closed', 'MCP session is closed.');
173+
}
174+
175+
static duplicateRequestId(requestId: string): McpSessionError {
176+
return new McpSessionError(
177+
'duplicate-request-id',
178+
`MCP session request ${JSON.stringify(requestId)} is already active.`,
179+
);
180+
}
181+
182+
static invalidRequestId(): McpSessionError {
183+
return new McpSessionError('invalid-request-id', 'MCP session requestId must be nonempty.');
184+
}
185+
186+
static invalidServerName(): McpSessionError {
187+
return new McpSessionError('invalid-server-name', 'MCP server name must be nonempty.');
188+
}
189+
190+
static notInitialized(): McpSessionError {
191+
return new McpSessionError('not-initialized', 'MCP session must initialize before protocol operations.');
192+
}
193+
194+
static serviceClosed(): McpSessionError {
195+
return new McpSessionError('service-closed', 'MCP session service is closed.');
196+
}
197+
}
198+
149199
/**
150200
* The session's pinned artifact epoch is no longer available: the project
151201
* changed underneath the session (typically another process's build

‎packages/agent-bundle/src/dev/mcp-session/mcp-session.ts‎

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
Tool,
88
Transport,
99
} from '@modelcontextprotocol/client';
10-
import { Effect, Semaphore } from 'effect';
10+
import { Effect, type Scope, Semaphore } from 'effect';
1111
import { randomUUID } from 'node:crypto';
1212
import { rm } from 'node:fs/promises';
1313
import type { Stream } from 'node:stream';
@@ -36,6 +36,7 @@ import {
3636
import { McpSessionTraceLog, type McpSessionTraceSink } from './mcp-session-trace.ts';
3737
import { RecordingTransport } from './mcp-recording-transport.ts';
3838
import {
39+
McpSessionError,
3940
McpSessionStaleEpochError,
4041
type McpClient,
4142
type McpRequestOptions as RequestOptions,
@@ -145,6 +146,11 @@ export class McpSession {
145146
readonly #workspaceRoot: string;
146147
readonly #frames: McpSessionFrame[] = [];
147148
readonly #events: McpSessionEvent[] = [];
149+
/**
150+
* In-flight tool calls by `requestId`. Each controller is owned by its
151+
* request's scope (see {@link #admitRequest}); `cancel()` and `#cancelAll`
152+
* abort it with their reason and the SDK decides how the call rejects.
153+
*/
148154
readonly #requests = new Map<string, AbortController>();
149155
#capture: StderrCapture | undefined;
150156
#client: McpClient | undefined;
@@ -313,46 +319,70 @@ export class McpSession {
313319
return this.#operation('callTool', () => runPromise(this.#callToolEffect(options)));
314320
}
315321

322+
/**
323+
* One tool call as a scoped Effect. The request slot is the scoped
324+
* resource: `acquireRelease` admits the `requestId` (failing closed on a
325+
* duplicate) and owns its `AbortController`; release aborts the controller
326+
* and frees the slot, so an interrupted fiber can no longer leak a live SDK
327+
* request. `cancel()` and `#cancelAll` abort the same controller with their
328+
* reason, and the host signal is composed in with `AbortSignal.any`, so the
329+
* SDK still decides how an aborted request rejects — exactly as before.
330+
*
331+
* `scopedAbortSignal` (`Effect.abortSignal`) is the same
332+
* `acquireRelease(new AbortController, abort)` shape, but it exposes only
333+
* the signal and aborts without a reason; this contract needs both.
334+
*/
316335
#callToolEffect(options: McpSessionToolCallOptions): Effect.Effect<CallToolResult, unknown> {
317336
return this.#assertEpochCurrentEffect().pipe(Effect.andThen(Effect.suspend(() => {
318337
if (options.signal?.aborted) {
319338
return Effect.fail(options.signal.reason ?? new Error('MCP session tool call was aborted.'));
320339
}
321340
const requestId = options.requestId ?? randomUUID();
322341
if (requestId.trim().length === 0) {
323-
return Effect.fail(new Error('MCP session requestId must be nonempty.'));
324-
}
325-
if (this.#requests.has(requestId)) {
326-
return Effect.fail(new Error(`MCP session request ${JSON.stringify(requestId)} is already active.`));
342+
return Effect.fail(McpSessionError.invalidRequestId());
327343
}
328-
const controller = new AbortController();
329-
const onAbort = () => controller.abort(options.signal?.reason);
330-
options.signal?.addEventListener('abort', onAbort, { once: true });
331-
this.#requests.set(requestId, controller);
332-
return Effect.gen({ self: this }, function* (this: McpSession) {
344+
const call = Effect.gen({ self: this }, function* (this: McpSession) {
345+
const controller = yield* this.#admitRequest(requestId);
333346
const client = yield* liftTry(() => this.#clientFor());
334347
const result = yield* liftPromise(() => client.callTool({
335348
...(options._meta === undefined ? {} : { _meta: options._meta }),
336349
arguments: options.arguments,
337350
name: options.name,
338351
}, {
339-
signal: controller.signal,
352+
signal: options.signal === undefined ? controller.signal : AbortSignal.any([controller.signal, options.signal]),
340353
timeout: requestOptions(options, this.#timeoutMs).timeout,
341354
}));
342355
yield* liftTry(() => {
343356
this.#throwIfStderrExceeded();
344357
});
345358
return result;
346-
}).pipe(
359+
});
360+
return Effect.scoped(call).pipe(
347361
Effect.catch((error) => this.#substituteStaleEpochFailure(error)),
348-
Effect.ensuring(Effect.sync(() => {
349-
options.signal?.removeEventListener('abort', onAbort);
350-
this.#requests.delete(requestId);
351-
})),
352362
);
353363
})));
354364
}
355365

366+
/**
367+
* Admits one in-flight request as a scoped resource. Acquire fails closed
368+
* when the `requestId` is already active; release aborts the request's
369+
* controller (a no-op once the SDK call settled) and frees the slot.
370+
*/
371+
#admitRequest(requestId: string): Effect.Effect<AbortController, McpSessionError, Scope.Scope> {
372+
return Effect.acquireRelease(
373+
Effect.suspend(() => {
374+
if (this.#requests.has(requestId)) return Effect.fail(McpSessionError.duplicateRequestId(requestId));
375+
const controller = new AbortController();
376+
this.#requests.set(requestId, controller);
377+
return Effect.succeed(controller);
378+
}),
379+
(controller) => Effect.sync(() => {
380+
this.#requests.delete(requestId);
381+
controller.abort();
382+
}),
383+
);
384+
}
385+
356386
/**
357387
* A call that failed while the epoch vanished mid-flight reports the
358388
* stale epoch, not the incidental abort or timeout it produced.
@@ -444,12 +474,12 @@ export class McpSession {
444474
}
445475

446476
#assertOpen(): void {
447-
if (this.#closed) throw new Error('MCP session is closed.');
477+
if (this.#closed) throw McpSessionError.closed();
448478
}
449479

450-
#assertOpenEffect(): Effect.Effect<void, Error> {
480+
#assertOpenEffect(): Effect.Effect<void, McpSessionError> {
451481
return Effect.suspend(() => this.#closed
452-
? Effect.fail(new Error('MCP session is closed.'))
482+
? Effect.fail(McpSessionError.closed())
453483
: Effect.void);
454484
}
455485

@@ -495,9 +525,7 @@ export class McpSession {
495525

496526
#clientFor(): McpClient {
497527
this.#assertOpen();
498-
if (this.#connection === undefined) {
499-
throw new Error('MCP session must initialize before protocol operations.');
500-
}
528+
if (this.#connection === undefined) throw McpSessionError.notInitialized();
501529
this.#throwIfStderrExceeded();
502530
return this.#client!;
503531
}

‎packages/agent-bundle/tests/mcp-session-service.test.ts‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import { normalizeProject } from '../src/config/normalize.ts';
1313

1414
import { EpochStore } from '../src/dev/epoch-store.ts';
1515
import { McpAppBindingService, type McpAppSessionAuthority } from '../src/dev/mcp-apps/mcp-app-binding-service.ts';
16-
import { mcpAppClientCapabilities, McpSession, McpSessionService } from '../src/dev/mcp-session/mcp-session-service.ts';
16+
import {
17+
mcpAppClientCapabilities,
18+
McpSession,
19+
McpSessionError,
20+
McpSessionService,
21+
} from '../src/dev/mcp-session/mcp-session-service.ts';
1722
import type { ArtifactEpoch } from '../src/dev/types.ts';
1823
import { pathTokens, type NormalizationTargetRegistry } from '../src/core/types.ts';
1924
import { agentBundleNodeModules } from './helpers/workspace-paths.ts';
@@ -1078,6 +1083,90 @@ it('fails and closes the session as soon as stderr exceeds its output bound', as
10781083
}
10791084
}, 30_000);
10801085

1086+
it('fails admission, lifecycle, and service misuse closed with coded McpSessionError values', async () => {
1087+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-typed-errors-'));
1088+
try {
1089+
const epochStore = await publishFixtureEpoch(root, 'epoch-1');
1090+
const releases: Array<() => void> = [];
1091+
const signals: AbortSignal[] = [];
1092+
const service = new McpSessionService({
1093+
createClient: () => ({
1094+
callTool: async (_params: unknown, options?: { readonly signal?: AbortSignal }) => {
1095+
if (options?.signal !== undefined) signals.push(options.signal);
1096+
await new Promise<void>((resolvePromise) => {
1097+
releases.push(resolvePromise);
1098+
});
1099+
return { content: [] };
1100+
},
1101+
close: async () => undefined,
1102+
connect: async () => undefined,
1103+
...mcpCatalogStub(),
1104+
}),
1105+
createStdioTransport: () => stdioTransportStub() as never,
1106+
epochStore,
1107+
projectRoot: root,
1108+
});
1109+
1110+
const expectSessionError = async (
1111+
rejection: Promise<unknown>,
1112+
code: string,
1113+
message: string,
1114+
): Promise<void> => {
1115+
const error = await rejection.then(
1116+
() => { throw new Error(`Expected ${code} to reject.`); },
1117+
(failure: unknown) => failure,
1118+
);
1119+
expect(error).toBeInstanceOf(McpSessionError);
1120+
expect(error).toEqual(expect.objectContaining({ code, message, name: 'McpSessionError' }));
1121+
};
1122+
1123+
await expectSessionError(
1124+
service.open({ epochId: 'epoch-1', serverName: ' ', target: 'portable' }),
1125+
'invalid-server-name',
1126+
'MCP server name must be nonempty.',
1127+
);
1128+
1129+
const session = await service.open({ epochId: 'epoch-1', serverName: 'fixture', target: 'portable' });
1130+
await expectSessionError(
1131+
session.callTool({ arguments: {}, name: 'fixture', requestId: ' ' }),
1132+
'invalid-request-id',
1133+
'MCP session requestId must be nonempty.',
1134+
);
1135+
1136+
const first = session.callTool({ arguments: {}, name: 'fixture', requestId: 'shared' });
1137+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
1138+
expect(signals).toHaveLength(1);
1139+
await expectSessionError(
1140+
session.callTool({ arguments: {}, name: 'fixture', requestId: 'shared' }),
1141+
'duplicate-request-id',
1142+
'MCP session request "shared" is already active.',
1143+
);
1144+
expect(signals[0]?.aborted).toBe(false);
1145+
releases.shift()?.();
1146+
await expect(first).resolves.toEqual({ content: [] });
1147+
// Releasing the request slot aborts its controller and frees the id.
1148+
expect(signals[0]?.aborted).toBe(true);
1149+
const reused = session.callTool({ arguments: {}, name: 'fixture', requestId: 'shared' });
1150+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
1151+
expect(signals).toHaveLength(2);
1152+
releases.shift()?.();
1153+
await expect(reused).resolves.toEqual({ content: [] });
1154+
1155+
await session.close();
1156+
await expectSessionError(session.restart(), 'session-closed', 'MCP session is closed.');
1157+
await expectSessionError(session.listTools(), 'session-closed', 'MCP session is closed.');
1158+
1159+
await service.close();
1160+
await expectSessionError(
1161+
service.open({ epochId: 'epoch-1', serverName: 'fixture', target: 'portable' }),
1162+
'service-closed',
1163+
'MCP session service is closed.',
1164+
);
1165+
} finally {
1166+
await rm(root, { force: true, recursive: true });
1167+
}
1168+
}, 30_000);
1169+
10811170
it('opens a generated streamable HTTP server through its modern transport', async () => {
10821171
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-remote-'));
10831172
try {

0 commit comments

Comments
 (0)