@@ -87,15 +87,14 @@ export interface SdkMcpAdapterOptions {
8787/**
8888 * Translate an SDK/transport error into the CLI error contract.
8989 *
90- * - `BailianError` (already ours, produced by the fetch interceptor) passes through.
91- * - `McpError`: SDK-local failures only — the protocol timer (`Request timed
92- * out` / `Maximum total timeout exceeded`, carrying its payload in `data`) →
93- * TIMEOUT, and the transport-close rejection (`Connection closed`) → NETWORK.
94- * A server-side JSON-RPC error reusing those codes (-32000 / -32001) carries
95- * the server's own message, so it falls through to GENERAL with the
96- * `MCP error (code): message` shape (message passed through verbatim per
97- * AGENTS.md §3) — the exit code must not mislead callers into retrying a
98- * business error as a network/timeout failure.
90+ * - `BailianError` (already ours, produced by local timeout or the fetch
91+ * interceptor) passes through.
92+ * - `McpError`: treated as a server-side JSON-RPC error by default and mapped to
93+ * `GENERAL` with the `MCP error (<code>): <message>` shape (message passed
94+ * through verbatim per AGENTS.md §3). Callers that can distinguish local
95+ * transport-close events pass `localCloseFired: true` so that
96+ * `ConnectionClosed` errors arising after the transport has closed are mapped
97+ * to `NETWORK` instead.
9998 * - `StreamableHTTPError` (HTTP status in `.code`) → GENERAL with the
10099 * `MCP request failed: <status>` shape so the fallback detectors still match.
101100 * - `SseError` (classic SSE): when the fetch interceptor captured the failing
@@ -107,15 +106,16 @@ export interface SdkMcpAdapterOptions {
107106 * - Anything else (e.g. a `TypeError` from `fetch`) is returned unchanged so the
108107 * runtime keeps its errno-specific handling.
109108 */
110- export function mapSdkError ( error : unknown , sseGetFailure ?: SseGetFailure ) : unknown {
109+ export function mapSdkError (
110+ error : unknown ,
111+ sseGetFailure ?: SseGetFailure ,
112+ options ?: { localCloseFired ?: boolean } ,
113+ ) : unknown {
111114 if ( error instanceof BailianError ) return error ;
112115
113116 if ( error instanceof McpError ) {
114117 const raw = rawMcpMessage ( error ) ;
115- if ( error . code === ErrorCode . RequestTimeout && isLocalTimeoutSignature ( error , raw ) ) {
116- return new BailianError ( raw || "MCP request timed out." , ExitCode . TIMEOUT ) ;
117- }
118- if ( error . code === ErrorCode . ConnectionClosed && raw === LOCAL_CLOSE_MESSAGE ) {
118+ if ( error . code === ErrorCode . ConnectionClosed && options ?. localCloseFired ) {
119119 return new BailianError ( raw || "MCP connection closed." , ExitCode . NETWORK ) ;
120120 }
121121 return new BailianError ( `MCP error (${ error . code } ): ${ raw } ` , ExitCode . GENERAL ) ;
@@ -154,24 +154,6 @@ function rawMcpMessage(error: McpError): string {
154154 return error . message . startsWith ( prefix ) ? error . message . slice ( prefix . length ) : error . message ;
155155}
156156
157- /** Fixed message of the SDK's transport-close rejection of pending requests. */
158- const LOCAL_CLOSE_MESSAGE = "Connection closed" ;
159-
160- /**
161- * True when a `RequestTimeout` McpError came from the SDK's own request timer,
162- * not from a server JSON-RPC error response reusing code -32001. The protocol
163- * timer attaches its payload as `data` (`{ timeout }` / `{ maxTotalTimeout, … }`);
164- * the fixed timer messages are matched as a fallback so the contract survives
165- * SDK builds that drop the payload.
166- */
167- function isLocalTimeoutSignature ( error : McpError , raw : string ) : boolean {
168- const data = error . data as Record < string , unknown > | undefined ;
169- if ( data && ( typeof data . timeout === "number" || typeof data . maxTotalTimeout === "number" ) ) {
170- return true ;
171- }
172- return raw === "Request timed out" || raw === "Maximum total timeout exceeded" ;
173- }
174-
175157/**
176158 * Server-side failure of a classic SSE stream GET, captured by the fetch
177159 * interceptor before `eventsource` discards the body (it only reports
@@ -281,6 +263,12 @@ export class SdkMcpAdapter implements McpConnectedClient {
281263 private connecting : Promise < void > | undefined ;
282264 /** Last failed classic SSE stream GET, for `SseError` contract reconstruction. */
283265 private sseGetFailure : SseGetFailure | undefined ;
266+ /**
267+ * Set when the transport's `onclose` callback fires. Used to distinguish
268+ * SDK-generated `ConnectionClosed` errors (local network events) from
269+ * server-authored JSON-RPC errors that reuse code -32000.
270+ */
271+ private transportClosed = false ;
284272
285273 constructor ( options : SdkMcpAdapterOptions ) {
286274 this . deps = options . deps ;
@@ -308,7 +296,9 @@ export class SdkMcpAdapter implements McpConnectedClient {
308296 async listTools ( ) : Promise < McpTool [ ] > {
309297 const client = await this . requireClient ( ) ;
310298 try {
311- const result = await client . listTools ( undefined , this . requestOptions ( ) ) ;
299+ const result = await this . withLocalTimeout (
300+ client . listTools ( undefined , this . requestOptions ( ) ) ,
301+ ) ;
312302 // Returned verbatim (the SDK-validated Tool objects): callers such as
313303 // `bl mcp tools --output json` keep every standard field the server
314304 // declares (outputSchema / annotations / title / `_meta`, …), matching
@@ -322,10 +312,8 @@ export class SdkMcpAdapter implements McpConnectedClient {
322312 async callTool ( name : string , args : Record < string , unknown > ) : Promise < McpToolResult > {
323313 const client = await this . requireClient ( ) ;
324314 try {
325- const result = ( await client . callTool (
326- { name, arguments : args } ,
327- undefined ,
328- this . requestOptions ( ) ,
315+ const result = ( await this . withLocalTimeout (
316+ client . callTool ( { name, arguments : args } , undefined , this . requestOptions ( ) ) ,
329317 ) ) as McpToolResult ;
330318 // The SDK already validates the envelope against CallToolResultSchema;
331319 // only a missing content array is an unexpected shape for our callers.
@@ -359,9 +347,9 @@ export class SdkMcpAdapter implements McpConnectedClient {
359347
360348 // ---- Internal ----
361349
362- /** `mapSdkError` with this adapter's captured classic SSE GET failure . */
350+ /** `mapSdkError` with this adapter's local transport-close state . */
363351 private mapTransportError ( error : unknown ) : unknown {
364- return mapSdkError ( error , this . sseGetFailure ) ;
352+ return mapSdkError ( error , this . sseGetFailure , { localCloseFired : this . transportClosed } ) ;
365353 }
366354
367355 private async connect ( ) : Promise < void > {
@@ -375,6 +363,8 @@ export class SdkMcpAdapter implements McpConnectedClient {
375363 this . sseGetFailure = failure ;
376364 } ) ;
377365 const endpoint = new URL ( this . url ) ;
366+ this . transportClosed = false ;
367+
378368 const transport =
379369 this . kind === "sse"
380370 ? new SSEClientTransport ( endpoint , { requestInit : { headers } , fetch : mcpFetch } )
@@ -383,6 +373,14 @@ export class SdkMcpAdapter implements McpConnectedClient {
383373 fetch : mcpFetch ,
384374 } ) ;
385375
376+ // Track local transport close so SDK-generated `ConnectionClosed` errors
377+ // can be reliably distinguished from server-authored -32000 JSON-RPC errors.
378+ // Protocol.connect wraps any pre-existing onclose, so this fires before the
379+ // SDK rejects in-flight requests.
380+ transport . onclose = ( ) => {
381+ this . transportClosed = true ;
382+ } ;
383+
386384 const client = new Client (
387385 { name : this . deps . identity . clientName , version : this . deps . identity . version } ,
388386 { capabilities : { } } ,
@@ -452,6 +450,28 @@ export class SdkMcpAdapter implements McpConnectedClient {
452450 }
453451
454452 private requestOptions ( ) : { timeout : number } {
455- return { timeout : this . deps . settings . timeout * 1000 } ;
453+ // Disable the SDK's own request timer; the adapter enforces the timeout
454+ // locally via `withLocalTimeout` so that timeout failures carry an
455+ // unambiguous local source and are not confused with server JSON-RPC
456+ // errors that may reuse code -32001 or carry `data.timeout`.
457+ // Use Node's maximum setTimeout delay so the SDK timer never wins the race.
458+ return { timeout : 2_147_483_647 } ;
459+ }
460+
461+ private async withLocalTimeout < T > ( promise : Promise < T > ) : Promise < T > {
462+ const timeoutSeconds = this . deps . settings . timeout ;
463+ let timer : ReturnType < typeof setTimeout > | undefined ;
464+ const timeoutPromise = new Promise < never > ( ( _ , reject ) => {
465+ timer = setTimeout ( ( ) => {
466+ reject (
467+ new BailianError ( `MCP request timed out after ${ timeoutSeconds } s.` , ExitCode . TIMEOUT ) ,
468+ ) ;
469+ } , timeoutSeconds * 1000 ) ;
470+ } ) ;
471+ try {
472+ return await Promise . race ( [ promise , timeoutPromise ] ) ;
473+ } finally {
474+ if ( timer ) clearTimeout ( timer ) ;
475+ }
456476 }
457477}
0 commit comments