From 55e25b7cfeeeb31eaf72bab32b7df263592666f6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 19:57:19 -0400 Subject: [PATCH 01/58] fix(core): register peer-request handlers before connect (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roots/sampling/elicitation capabilities are advertised on the SDK `Client` at construction, so from the moment `connect()` sends `notifications/initialized` the server may issue any of those requests. The handlers, however, were registered after the handshake resolved and after `fetchServerInfo()` — leaving a window in which the SDK answered `-32601 Method not found`. `server-filesystem` asks for `roots/list` the instant it is initialized (that is how it learns its allowed directories) and lost the race, so it silently fell back to its CLI-argument directories instead of the roots configured in the Inspector. `server-everything` asks later and did not. Extract the four peer-request handler blocks into `registerPeerRequestHandlers()` and call it before `client.connect()`. None of them depend on server capabilities — only on constructor-set state. The notification handlers that do gate on `this.capabilities` stay in `connect()`, after `initialize`. Regression test drives the race deterministically with a fake transport that delivers `roots/list` synchronously from inside the `send()` of `notifications/initialized`; it fails on the pre-fix ordering. Closes #1797 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 98 +++++ core/mcp/inspectorClient.ts | 395 +++++++++--------- 2 files changed, 304 insertions(+), 189 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts new file mode 100644 index 000000000..0950c0ce0 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +/** + * Regression coverage for #1797: a server may issue a server→client request the + * instant it is initialized, and the Inspector must already be able to answer. + * + * The client advertises `roots` (and sampling/elicitation) on the SDK `Client` + * at construction, so from the moment `connect()` sends + * `notifications/initialized` the server is entitled to call `roots/list`. + * `server-filesystem` does exactly that — it learns its allowed directories that + * way — and used to get `-32601 Method not found` because the handlers were + * registered after the handshake had already resolved. + * + * Driving this over a real HTTP server would make the assertion a race (the + * outcome depends on whether the server's request lands before or after the + * post-connect awaits). The fake transport below removes the timing entirely: it + * delivers `roots/list` synchronously from inside the `send()` of + * `notifications/initialized`, i.e. at the earliest instant any server could. + */ +class InitializedRacingTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + sessionId?: string; + setProtocolVersion?: (version: string) => void; + + /** The client's reply to the injected `roots/list`, once it sends one. */ + rootsReply?: JSONRPCMessage; + /** Id used for the injected server→client request. */ + private static readonly ROOTS_REQUEST_ID = 9001; + + async start(): Promise {} + async close(): Promise {} + + async send(message: JSONRPCMessage): Promise { + if ( + "method" in message && + message.method === "initialize" && + "id" in message + ) { + const params = message.params as { protocolVersion: string }; + this.deliver({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: {}, + serverInfo: { name: "racing-server", version: "1.0.0" }, + }, + }); + return; + } + if ("method" in message && message.method === "notifications/initialized") { + // The moment the server learns we are initialized, it asks for roots. + this.deliver({ + jsonrpc: "2.0", + id: InitializedRacingTransport.ROOTS_REQUEST_ID, + method: "roots/list", + }); + return; + } + if ( + "id" in message && + message.id === InitializedRacingTransport.ROOTS_REQUEST_ID + ) { + this.rootsReply = message; + } + } + + private deliver(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +describe("InspectorClient peer-request handler timing (#1797)", () => { + it("answers a roots/list that arrives with notifications/initialized", async () => { + const roots = [{ uri: "file:///work", name: "Work" }]; + const transport = new InitializedRacingTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + roots, + }, + ); + + await client.connect(); + + const reply = transport.rootsReply; + expect(reply).toBeDefined(); + expect(reply).not.toHaveProperty("error"); + expect(reply).toMatchObject({ result: { roots } }); + + await client.disconnect(); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8a4d6705c..feeec52cf 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1048,6 +1048,208 @@ export class InspectorClient extends InspectorClientEventTarget { this.transportHasAuthProvider = false; } + /** + * Register the handlers for requests the *server* makes of *us* — + * `roots/list`, `sampling/createMessage`, `elicitation/create`, and the + * receiver-side `tasks/*` polls. + * + * MUST be called before `client.connect()`. The matching capabilities are + * advertised on the `Client` at construction time, so from the moment + * `connect()` sends `notifications/initialized` the server is entitled to + * issue any of these requests. Registering afterwards leaves a window in + * which the SDK `Client` has no handler and answers `-32601 Method not + * found` — which is exactly what a server that asks for roots the instant it + * is initialized (e.g. `server-filesystem`, which learns its allowed + * directories that way) hits, while a server that asks later does not (#1797). + * + * Nothing here depends on the server's capabilities — only on constructor-set + * state — so there is nothing to wait for. The *notification* handlers that + * do gate on `this.capabilities` stay in `connect()`, after the handshake. + */ + private registerPeerRequestHandlers(): void { + // Set up sampling request handler if sampling capability is enabled + if (this.sample && this.client) { + const samplingHandler = ( + request: CreateMessageRequest, + ): Promise => { + const paramsTask = (request.params as { task?: { ttl?: number } }) + ?.task; + if (this.receiverTasks && paramsTask != null) { + const record = this.createReceiverTask({ + ttl: paramsTask.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + void (async () => { + const samplingRequest = new SamplingCreateMessage( + request, + (result) => { + record.resolvePayload(result); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: now, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (error) => { + record.rejectPayload(error); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: now, + statusMessage: + error instanceof Error ? error.message : String(error), + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (id) => this.removePendingSample(id), + ); + this.addPendingSample(samplingRequest); + })(); + // Task-augmented (2025-11-25) response: the server sent a + // task-augmented `sampling/createMessage`, so we reply with a + // `CreateTaskResult` (`{ task }`) rather than a `CreateMessageResult`. + // The v2 Client validates a spec handler's result and would reject + // `{ task }` with -32602; `installReceiverTaskResponseBypass` below + // routes this task-augmented branch around that validation so the + // legacy `{ task }` response reaches the wire. `taskResult` is typed + // as `CreateTaskResult` so its shape IS checked; the unavoidable + // `as unknown as CreateMessageResult` bridges the SDK gap — the 2-arg + // `setRequestHandler` overload types a sampling handler's return as + // `CreateMessageResult` only and doesn't model the (deprecated but + // wire-valid) task-augmented `CreateTaskResult`. A handler-result + // union `CreateMessageResult | CreateTaskResult` on the SDK side + // would remove this cast. + const taskResult: CreateTaskResult = { task: record.task }; + return Promise.resolve(taskResult as unknown as CreateMessageResult); + } + return this.enqueuePendingSample(request, "server-request"); + }; + this.client.setRequestHandler("sampling/createMessage", samplingHandler); + if (this.receiverTasks) { + this.installReceiverTaskResponseBypass( + "sampling/createMessage", + samplingHandler, + ); + } + } + + // Set up elicitation request handler if elicitation capability is enabled + if (this.elicit && this.client) { + const elicitHandler = (request: ElicitRequest): Promise => { + const paramsTask = (request.params as { task?: { ttl?: number } }) + ?.task; + if (this.receiverTasks && paramsTask != null) { + const record = this.createReceiverTask({ + ttl: paramsTask.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + void (async () => { + const elicitationRequest = new ElicitationCreateMessage( + request, + (result) => { + record.resolvePayload(result); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: now, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (id) => this.removePendingElicitation(id), + (error) => { + record.rejectPayload(error); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: now, + statusMessage: error.message, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + ); + this.addPendingElicitation(elicitationRequest); + })(); + // Task-augmented (2025-11-25) response — see the sampling handler + // above. Reply with a `CreateTaskResult` (`{ task }`), routed around + // the v2 Client's result validation by + // `installReceiverTaskResponseBypass` below. `taskResult` is typed so + // its shape is checked; the `as unknown as ElicitResult` bridges the + // same SDK gap as the sampling handler — the 2-arg `setRequestHandler` + // overload types an elicitation handler's return as `ElicitResult` + // only and doesn't model the task-augmented `CreateTaskResult`. + const taskResult: CreateTaskResult = { task: record.task }; + return Promise.resolve(taskResult as unknown as ElicitResult); + } + return this.enqueuePendingElicitation(request, "server-request"); + }; + this.client.setRequestHandler("elicitation/create", elicitHandler); + if (this.receiverTasks) { + this.installReceiverTaskResponseBypass( + "elicitation/create", + elicitHandler, + ); + } + } + + // Set up roots/list request handler if roots capability is enabled + if (this.roots !== undefined && this.client) { + this.client.setRequestHandler("roots/list", async () => { + return { roots: this.roots ?? [] }; + }); + } + + // Set up receiver-task request handlers (server polls us for tasks/list, + // tasks/get, tasks/result, tasks/cancel). SDK v2 removed tasks from the + // spec-method set, so these register through the 3-arg custom form with an + // explicit params schema (from the deprecated-but-importable task request + // schemas). The `result` schema is intentionally omitted so the SDK does + // not validate our responder return — matching v1, where only the + // requester validated (our receiver `Task` may omit fields a strict result + // schema would require). + if (this.receiverTasks && this.client) { + this.client.setRequestHandler( + "tasks/list", + { params: ListTasksRequestSchema.shape.params }, + async () => ({ tasks: this.listReceiverTasks() }), + ); + this.client.setRequestHandler( + "tasks/get", + { params: GetTaskRequestSchema.shape.params }, + async (params) => { + const record = this.getReceiverTask(params.taskId); + if (!record) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Unknown taskId: ${params.taskId}`, + ); + } + return record.task; + }, + ); + this.client.setRequestHandler( + "tasks/result", + { params: GetTaskPayloadRequestSchema.shape.params }, + async (params) => this.getReceiverTaskPayload(params.taskId), + ); + this.client.setRequestHandler( + "tasks/cancel", + { params: CancelTaskRequestSchema.shape.params }, + async (params) => this.cancelReceiverTask(params.taskId), + ); + } + } + /** * Connect to the MCP server */ @@ -1165,6 +1367,10 @@ export class InspectorClient extends InspectorClientEventTarget { this.status = "connecting"; this.dispatchTypedEvent("statusChange", this.status); + // Register the handlers for server→client requests before the handshake — + // see `registerPeerRequestHandlers` for why the ordering is load-bearing. + this.registerPeerRequestHandlers(); + // Optional connect-time timeout from per-server settings. The MCP SDK // has no connect-time timeout option, so we wrap the handshake in a // Promise.race. On timeout, tear the transport down so the next @@ -1224,195 +1430,6 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - // Set up sampling request handler if sampling capability is enabled - if (this.sample && this.client) { - const samplingHandler = ( - request: CreateMessageRequest, - ): Promise => { - const paramsTask = (request.params as { task?: { ttl?: number } }) - ?.task; - if (this.receiverTasks && paramsTask != null) { - const record = this.createReceiverTask({ - ttl: paramsTask.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - void (async () => { - const samplingRequest = new SamplingCreateMessage( - request, - (result) => { - record.resolvePayload(result); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: now, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (error) => { - record.rejectPayload(error); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: now, - statusMessage: - error instanceof Error ? error.message : String(error), - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (id) => this.removePendingSample(id), - ); - this.addPendingSample(samplingRequest); - })(); - // Task-augmented (2025-11-25) response: the server sent a - // task-augmented `sampling/createMessage`, so we reply with a - // `CreateTaskResult` (`{ task }`) rather than a `CreateMessageResult`. - // The v2 Client validates a spec handler's result and would reject - // `{ task }` with -32602; `installReceiverTaskResponseBypass` below - // routes this task-augmented branch around that validation so the - // legacy `{ task }` response reaches the wire. `taskResult` is typed - // as `CreateTaskResult` so its shape IS checked; the unavoidable - // `as unknown as CreateMessageResult` bridges the SDK gap — the 2-arg - // `setRequestHandler` overload types a sampling handler's return as - // `CreateMessageResult` only and doesn't model the (deprecated but - // wire-valid) task-augmented `CreateTaskResult`. A handler-result - // union `CreateMessageResult | CreateTaskResult` on the SDK side - // would remove this cast. - const taskResult: CreateTaskResult = { task: record.task }; - return Promise.resolve( - taskResult as unknown as CreateMessageResult, - ); - } - return this.enqueuePendingSample(request, "server-request"); - }; - this.client.setRequestHandler( - "sampling/createMessage", - samplingHandler, - ); - if (this.receiverTasks) { - this.installReceiverTaskResponseBypass( - "sampling/createMessage", - samplingHandler, - ); - } - } - - // Set up elicitation request handler if elicitation capability is enabled - if (this.elicit && this.client) { - const elicitHandler = ( - request: ElicitRequest, - ): Promise => { - const paramsTask = (request.params as { task?: { ttl?: number } }) - ?.task; - if (this.receiverTasks && paramsTask != null) { - const record = this.createReceiverTask({ - ttl: paramsTask.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - void (async () => { - const elicitationRequest = new ElicitationCreateMessage( - request, - (result) => { - record.resolvePayload(result); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: now, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (id) => this.removePendingElicitation(id), - (error) => { - record.rejectPayload(error); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: now, - statusMessage: error.message, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - ); - this.addPendingElicitation(elicitationRequest); - })(); - // Task-augmented (2025-11-25) response — see the sampling handler - // above. Reply with a `CreateTaskResult` (`{ task }`), routed around - // the v2 Client's result validation by - // `installReceiverTaskResponseBypass` below. `taskResult` is typed so - // its shape is checked; the `as unknown as ElicitResult` bridges the - // same SDK gap as the sampling handler — the 2-arg `setRequestHandler` - // overload types an elicitation handler's return as `ElicitResult` - // only and doesn't model the task-augmented `CreateTaskResult`. - const taskResult: CreateTaskResult = { task: record.task }; - return Promise.resolve(taskResult as unknown as ElicitResult); - } - return this.enqueuePendingElicitation(request, "server-request"); - }; - this.client.setRequestHandler("elicitation/create", elicitHandler); - if (this.receiverTasks) { - this.installReceiverTaskResponseBypass( - "elicitation/create", - elicitHandler, - ); - } - } - - // Set up roots/list request handler if roots capability is enabled - if (this.roots !== undefined && this.client) { - this.client.setRequestHandler("roots/list", async () => { - return { roots: this.roots ?? [] }; - }); - } - - // Set up receiver-task request handlers (server polls us for tasks/list, - // tasks/get, tasks/result, tasks/cancel). SDK v2 removed tasks from the - // spec-method set, so these register through the 3-arg custom form with an - // explicit params schema (from the deprecated-but-importable task request - // schemas). The `result` schema is intentionally omitted so the SDK does - // not validate our responder return — matching v1, where only the - // requester validated (our receiver `Task` may omit fields a strict result - // schema would require). - if (this.receiverTasks && this.client) { - this.client.setRequestHandler( - "tasks/list", - { params: ListTasksRequestSchema.shape.params }, - async () => ({ tasks: this.listReceiverTasks() }), - ); - this.client.setRequestHandler( - "tasks/get", - { params: GetTaskRequestSchema.shape.params }, - async (params) => { - const record = this.getReceiverTask(params.taskId); - if (!record) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Unknown taskId: ${params.taskId}`, - ); - } - return record.task; - }, - ); - this.client.setRequestHandler( - "tasks/result", - { params: GetTaskPayloadRequestSchema.shape.params }, - async (params) => this.getReceiverTaskPayload(params.taskId), - ); - this.client.setRequestHandler( - "tasks/cancel", - { params: CancelTaskRequestSchema.shape.params }, - async (params) => this.cancelReceiverTask(params.taskId), - ); - } - // Set up notification handler for roots/list_changed from server if (this.client) { this.client.setNotificationHandler( From 1d2d44b419befb62b346ae60de21f83681f09a47 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:15:11 -0400 Subject: [PATCH 02/58] =?UTF-8?q?review:=20round-1=20fixes=20=E2=80=94=20p?= =?UTF-8?q?re-connect=20roots/list=5Fchanged,=20wider=20timing=20test=20(#?= =?UTF-8?q?1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: `notifications/roots/list_changed` was the one handler in the post-handshake block that gates on nothing but constructor state, so it sat in the same race — a server emitting it right after `initialized` had it silently dropped (no wire error; the symptom is a missed UI refresh). Moved to a new `registerPeerNotificationHandlers()` called next to `registerPeerRequestHandlers()` before `connect()`. Finding 2: the timing test asserted one of the four moved handler blocks. It now injects `tasks/list` and the `roots/list_changed` notification in the same burst as `roots/list`, so moving any of the block back after `connect()` fails the test. Sampling/elicitation stay out — they park a pending request awaiting user input and have no reply to assert on. Dropped the unused `sessionId`/`setProtocolVersion` transport fields. Verified the added assertions are load-bearing: the notification assertion fails against the previous commit, the `roots/list` one against `origin/v2/main`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 89 +++++++++++++------ core/mcp/inspectorClient.ts | 52 +++++++---- 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 0950c0ce0..dcf8d55de 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -3,11 +3,11 @@ import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; /** - * Regression coverage for #1797: a server may issue a server→client request the - * instant it is initialized, and the Inspector must already be able to answer. + * Regression coverage for #1797: a server may talk to us the instant it is + * initialized, and the Inspector must already be able to answer. * - * The client advertises `roots` (and sampling/elicitation) on the SDK `Client` - * at construction, so from the moment `connect()` sends + * The client advertises `roots` (and sampling/elicitation/tasks) on the SDK + * `Client` at construction, so from the moment `connect()` sends * `notifications/initialized` the server is entitled to call `roots/list`. * `server-filesystem` does exactly that — it learns its allowed directories that * way — and used to get `-32601 Method not found` because the handlers were @@ -16,20 +16,27 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * Driving this over a real HTTP server would make the assertion a race (the * outcome depends on whether the server's request lands before or after the * post-connect awaits). The fake transport below removes the timing entirely: it - * delivers `roots/list` synchronously from inside the `send()` of + * delivers the server→client traffic synchronously from inside the `send()` of * `notifications/initialized`, i.e. at the earliest instant any server could. + * + * `tasks/list` rides along with `roots/list` so the assertion pins the whole + * pre-handshake registration block rather than one member of it — moving any of + * it back after `connect()` fails here. (`sampling/createMessage` and + * `elicitation/create` are deliberately left out: they park a pending request + * awaiting user input, so they have no reply to assert on.) */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; onclose?: () => void; onerror?: (error: Error) => void; - sessionId?: string; - setProtocolVersion?: (version: string) => void; - /** The client's reply to the injected `roots/list`, once it sends one. */ - rootsReply?: JSONRPCMessage; - /** Id used for the injected server→client request. */ - private static readonly ROOTS_REQUEST_ID = 9001; + /** Ids for the requests injected at `initialized`, by method. */ + static readonly REQUEST_IDS = { "roots/list": 9001, "tasks/list": 9002 }; + + /** The client's replies to the injected requests, keyed by request id. */ + readonly replies = new Map(); + /** Whether the client accepted the `roots/list_changed` notification. */ + notificationRejected = false; async start(): Promise {} async close(): Promise {} @@ -53,45 +60,75 @@ class InitializedRacingTransport implements Transport { return; } if ("method" in message && message.method === "notifications/initialized") { - // The moment the server learns we are initialized, it asks for roots. + // The moment the server learns we are initialized, it talks to us. + for (const [method, id] of Object.entries( + InitializedRacingTransport.REQUEST_IDS, + )) { + this.deliver({ jsonrpc: "2.0", id, method }); + } this.deliver({ jsonrpc: "2.0", - id: InitializedRacingTransport.ROOTS_REQUEST_ID, - method: "roots/list", + method: "notifications/roots/list_changed", }); return; } - if ( - "id" in message && - message.id === InitializedRacingTransport.ROOTS_REQUEST_ID - ) { - this.rootsReply = message; + if ("id" in message && typeof message.id === "number") { + this.replies.set(message.id, message); } } private deliver(message: JSONRPCMessage): void { - this.onmessage?.(message); + try { + this.onmessage?.(message); + } catch { + // An unhandled notification would surface here rather than on the wire. + this.notificationRejected = true; + } } } -describe("InspectorClient peer-request handler timing (#1797)", () => { - it("answers a roots/list that arrives with notifications/initialized", async () => { +describe("InspectorClient peer-handler timing (#1797)", () => { + it("serves server→client traffic that arrives with notifications/initialized", async () => { const roots = [{ uri: "file:///work", name: "Work" }]; const transport = new InitializedRacingTransport(); + const rootsChanges: unknown[] = []; const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, { environment: { transport: () => ({ transport }) }, roots, + receiverTasks: true, }, ); + client.addEventListener("rootsChange", (event) => { + rootsChanges.push((event as CustomEvent).detail); + }); await client.connect(); - const reply = transport.rootsReply; - expect(reply).toBeDefined(); - expect(reply).not.toHaveProperty("error"); - expect(reply).toMatchObject({ result: { roots } }); + // roots/list — the request that regressed (#1797). + const rootsReply = transport.replies.get( + InitializedRacingTransport.REQUEST_IDS["roots/list"], + ); + expect(rootsReply).toBeDefined(); + // Asserted separately from the `toMatchObject` below because this is the + // assertion that names the pre-fix failure: the reply was an error object + // carrying -32601 Method not found. + expect(rootsReply).not.toHaveProperty("error"); + expect(rootsReply).toMatchObject({ result: { roots } }); + + // tasks/list — pins the rest of the block registered at the same point. + const tasksReply = transport.replies.get( + InitializedRacingTransport.REQUEST_IDS["tasks/list"], + ); + expect(tasksReply).toBeDefined(); + expect(tasksReply).not.toHaveProperty("error"); + expect(tasksReply).toMatchObject({ result: { tasks: [] } }); + + // notifications/roots/list_changed — dropped silently when unregistered, + // so assert on the effect (the rootsChange event) rather than a reply. + expect(transport.notificationRejected).toBe(false); + expect(rootsChanges).toEqual([roots]); await client.disconnect(); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index feeec52cf..a7e414ce8 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1063,8 +1063,10 @@ export class InspectorClient extends InspectorClientEventTarget { * directories that way) hits, while a server that asks later does not (#1797). * * Nothing here depends on the server's capabilities — only on constructor-set - * state — so there is nothing to wait for. The *notification* handlers that - * do gate on `this.capabilities` stay in `connect()`, after the handshake. + * state — so there is nothing to wait for. Its sibling + * {@link registerPeerNotificationHandlers} does the same for the one + * notification handler in that position; the notification handlers that *do* + * gate on `this.capabilities` stay in `connect()`, after the handshake. */ private registerPeerRequestHandlers(): void { // Set up sampling request handler if sampling capability is enabled @@ -1250,6 +1252,32 @@ export class InspectorClient extends InspectorClientEventTarget { } } + /** + * Register the server→client *notification* handlers that depend on nothing + * but constructor-set state, and so belong before the handshake for the same + * reason as {@link registerPeerRequestHandlers} (#1797). + * + * `notifications/roots/list_changed` is the only one: a server may emit it as + * soon as it is initialized, and a notification with no registered handler is + * silently dropped by the SDK (no error goes back on the wire, so the symptom + * is a missed UI refresh rather than a visible failure). The rest of the + * listChanged handlers gate on `this.capabilities`, which is not populated + * until `fetchServerInfo()` runs, so they stay in `connect()`. + */ + private registerPeerNotificationHandlers(): void { + if (!this.client) return; + this.client.setNotificationHandler( + "notifications/roots/list_changed", + async () => { + // Dispatch event to notify UI that server's roots may have changed + // Note: rootsChange is a CustomEvent with Root[] payload, not a signal event + // We'll reload roots when the UI requests them, so we don't need to pass data here + // For now, we'll just dispatch an empty array as a signal to reload + this.dispatchTypedEvent("rootsChange", this.roots || []); + }, + ); + } + /** * Connect to the MCP server */ @@ -1367,9 +1395,11 @@ export class InspectorClient extends InspectorClientEventTarget { this.status = "connecting"; this.dispatchTypedEvent("statusChange", this.status); - // Register the handlers for server→client requests before the handshake — - // see `registerPeerRequestHandlers` for why the ordering is load-bearing. + // Register the handlers for server→client requests and the + // capability-independent notifications before the handshake — see + // `registerPeerRequestHandlers` for why the ordering is load-bearing. this.registerPeerRequestHandlers(); + this.registerPeerNotificationHandlers(); // Optional connect-time timeout from per-server settings. The MCP SDK // has no connect-time timeout option, so we wrap the handshake in a @@ -1430,20 +1460,6 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - // Set up notification handler for roots/list_changed from server - if (this.client) { - this.client.setNotificationHandler( - "notifications/roots/list_changed", - async () => { - // Dispatch event to notify UI that server's roots may have changed - // Note: rootsChange is a CustomEvent with Root[] payload, not a signal event - // We'll reload roots when the UI requests them, so we don't need to pass data here - // For now, we'll just dispatch an empty array as a signal to reload - this.dispatchTypedEvent("rootsChange", this.roots || []); - }, - ); - } - // Set up listChanged notification handlers based on config if (this.client) { // Tools listChanged handler From 801685762d026a8d9da2186ece7e99924233f59b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:27:12 -0400 Subject: [PATCH 03/58] =?UTF-8?q?review:=20round-2=20fixes=20=E2=80=94=20c?= =?UTF-8?q?orrect=20the=20roots/list=5Fchanged=20premise=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-1 doc comment claimed a server may emit `notifications/roots/list_changed` at initialize. It can't, normally: `RootsListChangedNotificationSchema` is a member of `ClientNotificationSchema` and absent from `ServerNotificationSchema` — the client sends it (setRoots does, at :4251). The inbound handler is defensive coverage for a non-conformant server, and dispatches `rootsChange` with our own already-known roots. Reworded the method doc and the test's header so neither states the false premise; the move itself stands (it gates on no server capability, so it belongs with the request handlers). Also from review: - Dropped the vacuous `notificationRejected` flag and its inverted comment. The SDK does not throw for an unregistered notification, so the try/catch could never observe one — confirmed by bisect, where the pre-fix failure was always `rootsChanges`, never the flag. Removing the catch also stops it swallowing a genuine throw from the injected requests. - Narrowed `replies` to responses (`"result" in message || "error" in message`) so it can't also collect client-originated requests. - Annotated the unreachable `if (!this.client)` guard with a justified `v8 ignore` per the AGENTS.md dead-guard policy. Re-verified both regression directions: the `roots/list` assertion fails against `origin/v2/main`, the `rootsChange` one against `55e25b7c`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 27 ++++++++++--------- core/mcp/inspectorClient.ts | 24 +++++++++++------ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index dcf8d55de..0a3987581 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -24,6 +24,12 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * it back after `connect()` fails here. (`sampling/createMessage` and * `elicitation/create` are deliberately left out: they park a pending request * awaiting user input, so they have no reply to assert on.) + * + * The `roots/list_changed` notification is injected too, covering the sibling + * `registerPeerNotificationHandlers()`. Note the spec sends that notification + * the *other* way (client→server), so the inbound handler is defensive rather + * than something a conformant server exercises — this pins where it is + * registered, not a behaviour real servers depend on. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -35,8 +41,6 @@ class InitializedRacingTransport implements Transport { /** The client's replies to the injected requests, keyed by request id. */ readonly replies = new Map(); - /** Whether the client accepted the `roots/list_changed` notification. */ - notificationRejected = false; async start(): Promise {} async close(): Promise {} @@ -72,18 +76,17 @@ class InitializedRacingTransport implements Transport { }); return; } - if ("id" in message && typeof message.id === "number") { + if ( + "id" in message && + typeof message.id === "number" && + ("result" in message || "error" in message) + ) { this.replies.set(message.id, message); } } private deliver(message: JSONRPCMessage): void { - try { - this.onmessage?.(message); - } catch { - // An unhandled notification would surface here rather than on the wire. - this.notificationRejected = true; - } + this.onmessage?.(message); } } @@ -125,9 +128,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(tasksReply).not.toHaveProperty("error"); expect(tasksReply).toMatchObject({ result: { tasks: [] } }); - // notifications/roots/list_changed — dropped silently when unregistered, - // so assert on the effect (the rootsChange event) rather than a reply. - expect(transport.notificationRejected).toBe(false); + // notifications/roots/list_changed — a notification has no reply, and an + // unhandled one is dropped silently (no wire error), so the effect is the + // only observable: the handler dispatches `rootsChange`. expect(rootsChanges).toEqual([roots]); await client.disconnect(); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a7e414ce8..82c7bcfc7 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1253,18 +1253,26 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Register the server→client *notification* handlers that depend on nothing - * but constructor-set state, and so belong before the handshake for the same + * Register the inbound *notification* handlers that depend on nothing but + * constructor-set state, and so belong before the handshake for the same * reason as {@link registerPeerRequestHandlers} (#1797). * - * `notifications/roots/list_changed` is the only one: a server may emit it as - * soon as it is initialized, and a notification with no registered handler is - * silently dropped by the SDK (no error goes back on the wire, so the symptom - * is a missed UI refresh rather than a visible failure). The rest of the - * listChanged handlers gate on `this.capabilities`, which is not populated - * until `fetchServerInfo()` runs, so they stay in `connect()`. + * `notifications/roots/list_changed` is the only one — and note it is a + * **client**→server notification in the spec (`ClientNotification`): we send + * it from {@link setRoots}, servers do not normally send it to us. This + * inbound handler is defensive coverage for a non-conformant or experimental + * server, and its body dispatches `rootsChange` with our own already-known + * roots, i.e. a refresh signal carrying no new data. It sits here for + * consistency with the request handlers — it gates on no server capability, + * so there is nothing to wait for, and an unhandled notification is dropped + * silently by the SDK (no wire error) rather than answered `-32601`. + * + * The remaining listChanged handlers gate on `this.capabilities`, which is + * not populated until `fetchServerInfo()` runs, so they stay in `connect()`. */ private registerPeerNotificationHandlers(): void { + /* v8 ignore next -- unreachable: the sole caller is connect(), past its + `if (!this.client) throw`; the guard exists only to narrow the type. */ if (!this.client) return; this.client.setNotificationHandler( "notifications/roots/list_changed", From dd2628922850b7ccb2892ffa2befcfb9a6b73715 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:35:06 -0400 Subject: [PATCH 04/58] =?UTF-8?q?review:=20round-3=20fix=20=E2=80=94=20dro?= =?UTF-8?q?p=20stale=20comments=20inside=20the=20roots=20handler=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four inline comments moved verbatim with the handler contradicted the method doc reworded one round earlier: they said "server's roots" (they are ours — it is a ClientNotification) and "we'll just dispatch an empty array", which is not what `this.roots || []` does when roots are configured. The new timing test asserts exactly that non-empty dispatch. Replaced with one accurate line. Comment-only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- core/mcp/inspectorClient.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 82c7bcfc7..2f33661d6 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1277,10 +1277,8 @@ export class InspectorClient extends InspectorClientEventTarget { this.client.setNotificationHandler( "notifications/roots/list_changed", async () => { - // Dispatch event to notify UI that server's roots may have changed - // Note: rootsChange is a CustomEvent with Root[] payload, not a signal event - // We'll reload roots when the UI requests them, so we don't need to pass data here - // For now, we'll just dispatch an empty array as a signal to reload + // Re-dispatch our already-known roots as a refresh signal for the UI — + // the payload carries no new data (see the note on ownership above). this.dispatchTypedEvent("rootsChange", this.roots || []); }, ); From 0a38fe67279869632099833c94fc299a8efff432 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 20:52:01 -0400 Subject: [PATCH 05/58] fix(cli): advertise roots so roots/set is actually serviceable (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review found the same -32601 symptom on a second path: the CLI constructed its InspectorClient with no `roots` option, so `capabilities.roots` was never advertised and no `roots/list` handler was registered — yet `--method roots/set` calls `setRoots()`, which announces `notifications/roots/list_changed` to the server. A server taking up that invitation got "Method not found", after the CLI reported success. The review's suggested fix — register `roots/list` unconditionally — is not implementable: the SDK asserts the client capability inside `setRequestHandler` ("Client does not support roots capability"), so it throws during connect for any client built without the option, and `registerCapabilities` refuses to run after connect. A client that omits `roots` therefore cannot legally serve `roots/list` at all. Fixed at the layer that can fix it: `cli.ts` seeds `roots: []`, matching web (`App.tsx` always passes it). The core registration stays gated on the constructor value, now with the SDK constraint documented so the next reader doesn't retry the unconditional version. `setRoots()`'s doc claimed it "will enable roots capability if it wasn't already enabled" — it cannot, for the reasons above. Corrected, and dropped the dead `if (this.roots === undefined) this.roots = []` the very next line overwrote. Tests: a CLI test drives `list_roots` over an HTTP test server (verified load-bearing — without the seed it fails on the -32601 error text), and the core timing test gains a case pinning that roots set after connect are served with the current values, not the constructor's. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/cli.test.ts | 37 +++++++++++++ clients/cli/src/cli.ts | 5 ++ ...nspectorClient-peer-handler-timing.test.ts | 55 +++++++++++++++++++ core/mcp/inspectorClient.ts | 25 ++++++--- 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index ca3931008..64497513b 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -20,6 +20,7 @@ import { getTestMcpServerCommand, createTestServerHttp, createEchoTool, + createListRootsTool, createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js"; @@ -325,6 +326,42 @@ describe("CLI Tests", () => { }); }); + describe("Roots capability (#1797)", () => { + it("answers a server's roots/list instead of -32601", async () => { + // The CLI used to omit `roots` when constructing its InspectorClient, so + // the capability was never advertised and no `roots/list` handler was + // registered — a server that asked got -32601 Method not found, and + // `--method roots/set` announced `roots/list_changed` to a server it + // would then refuse to answer. `cli.ts` now seeds `roots: []`. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createListRootsTool()], + }); + try { + await server.start(); + + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/call", + "--tool-name", + "list_roots", + ]); + + expectCliSuccess(result); + const json = expectValidJson(result); + // The tool asks the client for roots and renders what it got. An + // unregistered handler surfaces as an isError result quoting -32601. + expect(json.isError).toBeFalsy(); + expect(JSON.stringify(json)).toContain("Roots:"); + expect(JSON.stringify(json)).not.toContain("-32601"); + } finally { + await server.stop(); + } + }); + }); + describe("Config-file settings lifting (#1482)", () => { it("applies a config file's custom header on a tools/call over HTTP", async () => { const server = createTestServerHttp({ diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index eddae3ab4..7a534b670 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -148,6 +148,11 @@ async function callMethod( progress: false, sample: false, elicit: false, + // Advertise roots (empty until `--method roots/set` fills them in). The + // capability is negotiated at `initialize` and the `roots/list` handler is + // registered from this option, so omitting it left `roots/set` announcing + // `roots/list_changed` to a server that then got -32601 back (#1797). + roots: [], serverSettings, // Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation. // Absent era defaults to legacy in the InspectorClient constructor (#1626). diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 0a3987581..c25d0652b 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -42,6 +42,32 @@ class InitializedRacingTransport implements Transport { /** The client's replies to the injected requests, keyed by request id. */ readonly replies = new Map(); + /** + * Whether to fire the server→client burst at `initialized`. Off for the + * `setRoots()` test, which injects by hand once the client is connected. + */ + private readonly burstOnInitialized: boolean; + + constructor(burstOnInitialized = true) { + this.burstOnInitialized = burstOnInitialized; + } + + /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ + private readonly waiters = new Map void>(); + + /** + * Deliver a server→client request outside the `initialized` burst, resolving + * with the client's reply. The handler is async, so the reply lands some + * microtasks later — awaiting it here beats guessing how many. + */ + injectRequest(method: string, id: number): Promise { + const reply = new Promise((resolve) => + this.waiters.set(id, resolve), + ); + this.deliver({ jsonrpc: "2.0", id, method }); + return reply; + } + async start(): Promise {} async close(): Promise {} @@ -64,6 +90,7 @@ class InitializedRacingTransport implements Transport { return; } if ("method" in message && message.method === "notifications/initialized") { + if (!this.burstOnInitialized) return; // The moment the server learns we are initialized, it talks to us. for (const [method, id] of Object.entries( InitializedRacingTransport.REQUEST_IDS, @@ -82,6 +109,7 @@ class InitializedRacingTransport implements Transport { ("result" in message || "error" in message) ) { this.replies.set(message.id, message); + this.waiters.get(message.id)?.(message); } } @@ -135,4 +163,31 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + + it("serves roots set after connect, given roots were advertised at construction", async () => { + // `setRoots()` announces `notifications/roots/list_changed`, inviting the + // server to re-read — so the handler must answer with the *current* roots, + // not the ones passed at construction. It reads `this.roots` live, so it + // does; this pins that, and the `roots: []` seed the CLI now passes + // (`clients/cli/src/cli.ts`) is what makes the handler exist at all. A + // client built with no `roots` option cannot serve this: the SDK asserts + // the capability in `setRequestHandler`, and the capability itself is + // fixed at `initialize`. + const transport = new InitializedRacingTransport(false); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) }, roots: [] }, + ); + + await client.connect(); + await client.setRoots([{ uri: "file:///late", name: "Late" }]); + + const reply = await transport.injectRequest("roots/list", 9101); + expect(reply).not.toHaveProperty("error"); + expect(reply).toMatchObject({ + result: { roots: [{ uri: "file:///late", name: "Late" }] }, + }); + + await client.disconnect(); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 2f33661d6..7e4852bb2 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1204,7 +1204,14 @@ export class InspectorClient extends InspectorClientEventTarget { } } - // Set up roots/list request handler if roots capability is enabled + // Gated on the *constructor* value, and it has to be: the SDK asserts the + // matching client capability inside `setRequestHandler`, so registering + // this on a client built without `roots` throws "Client does not support + // roots capability". Since `capabilities.roots` is negotiated at + // `initialize` (:588) and `registerCapabilities` refuses to run after + // connect, a client that omits the option can never serve `roots/list` — + // which is why every client that may call `setRoots()` later must pass + // `roots` up front (web does; the CLI now does too — #1797). if (this.roots !== undefined && this.client) { this.client.setRequestHandler("roots/list", async () => { return { roots: this.roots ?? [] }; @@ -4237,18 +4244,22 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Set roots and notify server if it supports roots/listChanged - * Note: This will enable roots capability if it wasn't already enabled + * Set roots and announce the change to the server. + * + * Note this does **not** enable the roots capability on a client that was + * built without the constructor's `roots` option, despite what this comment + * used to claim. `capabilities.roots` is negotiated at `initialize` and the + * SDK refuses `registerCapabilities` after connect, so such a client has no + * `roots/list` handler (see {@link registerPeerRequestHandlers}) and would + * answer `-32601` to a server taking up the `roots/list_changed` invitation + * below. Pass `roots` at construction — `[]` is enough — in any client that + * may call this (#1797). */ async setRoots(roots: Root[]): Promise { if (!this.client) { throw new Error("Client is not connected"); } - // Enable roots capability if not already enabled - if (this.roots === undefined) { - this.roots = []; - } this.roots = [...roots]; this.dispatchTypedEvent("rootsChange", this.roots); From b952142cb1779620ebe5985da959beeacb29b714 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:03:27 -0400 Subject: [PATCH 06/58] fix(cli,tui): advertise the roots configured in mcp.json (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review: seeding `roots: []` fixed the -32601 but not the outcome. `server-filesystem` discards an empty roots answer (`validatedRootDirs .length > 0`) and keeps its CLI-argument directories — which is the symptom this PR set out to fix. The user's roots were already in hand two lines away, on `serverSettings.roots` (lifted from mcp.json by `mcpConfigToServerEntries`). Now passed through `cleanRoots`, the same shared helper web uses, so both clients answer `roots/list` byte-identically for the same config. This matters most on the CLI because it is one-shot: `--method roots/set` sets roots on a connection torn down a few statements later, so the config file is the only durable way to give a run its roots. Same gap in the TUI (`App.tsx` built its options with no `roots` key at all), fixed the same way. The review suggested a follow-up issue for it; folding it in here instead, since it is the identical line and splitting it would leave a known-wrong client in tree. Also from review: hoisted `waiters` up with the other fields, and `injectRequest` now rejects after 1s naming the method and id rather than hanging to the vitest timeout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/cli.test.ts | 45 +++++++++++++++++++ clients/cli/src/cli.ts | 13 +++--- clients/tui/src/App.tsx | 6 +++ ...nspectorClient-peer-handler-timing.test.ts | 21 ++++++--- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index 64497513b..4130a8515 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -360,6 +360,51 @@ describe("CLI Tests", () => { await server.stop(); } }); + + it("answers with the roots configured for the server in the config file", async () => { + // Answering `roots/list` is only half the fix: the roots a user + // configured in mcp.json have to be the answer. They ride in on + // `serverSettings.roots` (lifted by `mcpConfigToServerEntries`), which is + // what web passes too — a CLI that seeded `[]` would let a server fall + // back to its own defaults, the outcome #1797 is about. + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createListRootsTool()], + }); + let configPath: string | undefined; + try { + await server.start(); + configPath = createTestConfig({ + mcpServers: { + web: { + type: "streamable-http", + url: server.url, + roots: [{ uri: "file:///configured", name: "Configured" }], + } as unknown as MCPServerConfig, + }, + }); + + const result = await runCli([ + "--config", + configPath, + "--server", + "web", + "--cli", + "--method", + "tools/call", + "--tool-name", + "list_roots", + ]); + + expectCliSuccess(result); + const json = expectValidJson(result); + expect(json.isError).toBeFalsy(); + expect(JSON.stringify(json)).toContain("file:///configured"); + } finally { + await server.stop(); + if (configPath) deleteConfigFile(configPath); + } + }); }); describe("Config-file settings lifting (#1482)", () => { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 7a534b670..98cb06884 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -15,6 +15,7 @@ import { listServerEntries, showServerEntry } from "./handlers/servers-list.js"; import { writeFormattedResult } from "./handlers/format-output.js"; import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { createTransportNode, loadServerEntries, @@ -148,11 +149,13 @@ async function callMethod( progress: false, sample: false, elicit: false, - // Advertise roots (empty until `--method roots/set` fills them in). The - // capability is negotiated at `initialize` and the `roots/list` handler is - // registered from this option, so omitting it left `roots/set` announcing - // `roots/list_changed` to a server that then got -32601 back (#1797). - roots: [], + // Advertise the roots configured for this server in mcp.json, exactly as + // web does (`App.tsx`) so both answer `roots/list` with the same content. + // Passing the option (even empty) is what negotiates `capabilities.roots` + // at `initialize` and registers the `roots/list` handler — omitting it left + // `--method roots/set` announcing `roots/list_changed` to a server that + // then got -32601 back (#1797). + roots: cleanRoots(serverSettings?.roots ?? []), serverSettings, // Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation. // Absent era defaults to legacy in the InspectorClient constructor (#1626). diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index f4adc1eab..62c9e7432 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -20,6 +20,7 @@ import type { GetPromptResult, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { cleanRoots } from "@inspector/core/mcp/serverList.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { ManagedToolsState, @@ -304,6 +305,11 @@ function App({ defaultMetadata, }), ...(savedSettings && { serverSettings: savedSettings }), + // Advertise the roots configured for this server in mcp.json, as web + // and the CLI do. The TUI has no roots editor, but a user who set + // roots in the web UI expects them to apply to the same server here + // (#1797). + roots: cleanRoots(savedSettings?.roots ?? []), // Per-server protocol era (SEP §7.8) from mcp.json → SDK // versionNegotiation; absent era defaults to legacy (#1626). ...(savedSettings?.protocolEra && { diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index c25d0652b..64e993d39 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -42,6 +42,9 @@ class InitializedRacingTransport implements Transport { /** The client's replies to the injected requests, keyed by request id. */ readonly replies = new Map(); + /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ + private readonly waiters = new Map void>(); + /** * Whether to fire the server→client burst at `initialized`. Off for the * `setRoots()` test, which injects by hand once the client is connected. @@ -52,18 +55,21 @@ class InitializedRacingTransport implements Transport { this.burstOnInitialized = burstOnInitialized; } - /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ - private readonly waiters = new Map void>(); - /** * Deliver a server→client request outside the `initialized` burst, resolving * with the client's reply. The handler is async, so the reply lands some - * microtasks later — awaiting it here beats guessing how many. + * microtasks later — awaiting it here beats guessing how many. Rejects rather + * than hanging to the vitest timeout if the client never answers, so a + * regression fails where it happened. */ injectRequest(method: string, id: number): Promise { - const reply = new Promise((resolve) => - this.waiters.set(id, resolve), - ); + const reply = new Promise((resolve, reject) => { + this.waiters.set(id, resolve); + setTimeout(() => { + this.waiters.delete(id); + reject(new Error(`No reply to injected ${method} (id ${id})`)); + }, 1000).unref?.(); + }); this.deliver({ jsonrpc: "2.0", id, method }); return reply; } @@ -110,6 +116,7 @@ class InitializedRacingTransport implements Transport { ) { this.replies.set(message.id, message); this.waiters.get(message.id)?.(message); + this.waiters.delete(message.id); } } From b8e02e1d46070045d5bb580a8d98e6376d21fbe5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:18:15 -0400 Subject: [PATCH 07/58] =?UTF-8?q?review:=20round-6=20fixes=20=E2=80=94=20d?= =?UTF-8?q?ocument=20roots=20in=20the=20CLI=20README,=20harden=20cleanRoot?= =?UTF-8?q?s=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clients/cli/README.md enumerated the settings lifted from a catalog/config file without roots. Roots are the least discoverable member of that list — there is no flag, and `--method roots/set` dies with the connection — so the file is the only durable way to supply them. Said so. - cleanRoots() trusted its input's shape. `Root[]` is a compile-time type over hand-editable mcp.json, and as of this PR all three clients feed it straight from disk, so `"roots": [{"name":"Work"}]` or `"roots": "file:///work"` threw at connect. Non-array bails to []; an entry without a string uri is dropped with a warning. Hardened in the one shared place rather than three call sites. Review suggested a follow-up issue; folded in for the same reason as the TUI fix last round. - Test comment said the CLI passes a `roots: []` seed — true one commit ago. - injectRequest clears its timeout on reply (an armed timer outliving a test is the #1760 teardown class), and setRoots' doc drops the "despite what this comment used to claim" archaeology. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/README.md | 4 +- ...nspectorClient-peer-handler-timing.test.ts | 16 +++++--- .../web/src/test/core/mcp/serverList.test.ts | 39 ++++++++++++++++++- core/mcp/inspectorClient.ts | 4 +- core/mcp/serverList.ts | 19 ++++++++- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 6abb958fe..09612d594 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -78,7 +78,9 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --tr npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key" ``` -When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, and OAuth) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place. +When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, OAuth, and `roots`) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place. + +The file is the only durable way to give a run its roots: there is no roots flag, and `--method roots/set` applies only to that one short-lived connection. Roots configured for a server (the same field the web UI's Server Settings writes) are advertised at connect, so a server that asks for `roots/list` — `@modelcontextprotocol/server-filesystem` does, to learn its allowed directories — gets them. **Environment-variable semantics.** `MCP_CATALOG_PATH` is honored only when no ad-hoc target is given (positional command, `--server-url`, or `--transport`) — so a shell that exports it can still run one-off ad-hoc invocations without hitting the catalog/ad-hoc conflict. `MCP_STORAGE_DIR` sets the storage directory used by the OAuth persist backend (`/oauth.json`); the per-file `MCP_INSPECTOR_OAUTH_STATE_PATH` override still takes precedence over it. diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 64e993d39..9b20c5c0f 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -64,11 +64,16 @@ class InitializedRacingTransport implements Transport { */ injectRequest(method: string, id: number): Promise { const reply = new Promise((resolve, reject) => { - this.waiters.set(id, resolve); - setTimeout(() => { + const timer = setTimeout(() => { this.waiters.delete(id); reject(new Error(`No reply to injected ${method} (id ${id})`)); - }, 1000).unref?.(); + }, 1000); + // Cleared on reply — an armed timer outliving the test is the #1760 + // teardown-crash class, even though this callback touches no `window`. + this.waiters.set(id, (m) => { + clearTimeout(timer); + resolve(m); + }); }); this.deliver({ jsonrpc: "2.0", id, method }); return reply; @@ -175,8 +180,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // `setRoots()` announces `notifications/roots/list_changed`, inviting the // server to re-read — so the handler must answer with the *current* roots, // not the ones passed at construction. It reads `this.roots` live, so it - // does; this pins that, and the `roots: []` seed the CLI now passes - // (`clients/cli/src/cli.ts`) is what makes the handler exist at all. A + // does; this pins that. Passing the `roots` option at all — which the CLI + // now always does, empty when nothing is configured + // (`clients/cli/src/cli.ts`) — is what makes the handler exist. A // client built with no `roots` option cannot serve this: the SDK asserts // the capability in `setRequestHandler`, and the capability itself is // fixed at `initialize`. diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 22e9639b0..13108b37f 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { cleanRoots, DEFAULT_SEED_CONFIG, @@ -23,6 +23,7 @@ import type { ServerEntry, StoredMCPServer, } from "@inspector/core/mcp/types.js"; +import type { Root } from "@modelcontextprotocol/client"; describe("normalizeServerType", () => { it("defaults missing type to stdio", () => { @@ -102,6 +103,42 @@ describe("cleanRoots", () => { { uri: "file:///b", _meta: { k: 2 } }, ]); }); + + // Every client now feeds this straight from hand-editable mcp.json (#1797), + // where `Root[]` is only a compile-time promise. Malformed input must be + // reported and skipped, not thrown at connect. + describe("malformed input from disk", () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + it("drops an entry with no uri instead of throwing", () => { + // A hand-edited `"roots": [{ "name": "Work" }]`. + const malformed = [{ name: "Work" }, { uri: "file:///ok" }]; + expect(cleanRoots(malformed as unknown as Root[])).toEqual([ + { uri: "file:///ok" }, + ]); + expect(warn).toHaveBeenCalled(); + }); + + it("drops an entry whose uri is not a string", () => { + const malformed = [{ uri: 42 }, { uri: "file:///ok" }]; + expect(cleanRoots(malformed as unknown as Root[])).toEqual([ + { uri: "file:///ok" }, + ]); + expect(warn).toHaveBeenCalled(); + }); + + it("bails to an empty list when roots is not an array", () => { + // A hand-edited `"roots": "file:///work"`. + expect(cleanRoots("file:///work" as unknown as Root[])).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); + }); }); describe("mcpConfigToServerEntries", () => { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 7e4852bb2..590c71643 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4247,8 +4247,8 @@ export class InspectorClient extends InspectorClientEventTarget { * Set roots and announce the change to the server. * * Note this does **not** enable the roots capability on a client that was - * built without the constructor's `roots` option, despite what this comment - * used to claim. `capabilities.roots` is negotiated at `initialize` and the + * built without the constructor's `roots` option. `capabilities.roots` is + * negotiated at `initialize` and the * SDK refuses `registerCapabilities` after connect, so such a client has no * `roots/list` handler (see {@link registerPeerRequestHandlers}) and would * answer `-32601` to a server taking up the `roots/list_changed` invitation diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index d46a31e67..78a852cf7 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -93,12 +93,27 @@ export function normalizeServerType( * new row empty mid-edit) and drop a blank/whitespace `name`. Any other fields * a root carries (e.g. `_meta` from a hand-edited `mcp.json`) are preserved — * only `uri`/`name` are normalized. Shared by the settings → disk converter - * (`inspectorSettingsToStoredFields`) and the web client's connect-time + + * (`inspectorSettingsToStoredFields`) and all three clients' connect-time + * `setRoots` wiring so the roots told to the server match what hits disk. + * + * `Root[]` is a compile-time type over hand-editable `mcp.json`, and every + * client now feeds this straight from disk (#1797), so the shape is validated + * at runtime too: a non-array bails to `[]` and an entry without a string + * `uri` is dropped with a warning, rather than throwing at connect. */ export function cleanRoots(roots: Root[]): Root[] { + if (!Array.isArray(roots)) { + console.warn("Ignoring `roots`: expected an array, got", typeof roots); + return []; + } return roots - .filter((r) => r.uri.trim() !== "") + .filter((r) => { + if (typeof r?.uri !== "string") { + console.warn("Dropping root without a string `uri`:", r); + return false; + } + return r.uri.trim() !== ""; + }) .map((r) => { const trimmedName = r.name?.trim(); // Strip `name` off the carried-through rest so a cleared optional name From 866c2b5686fa78bb9315c5c59f7fe2185d20f8ec Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:27:33 -0400 Subject: [PATCH 08/58] =?UTF-8?q?review:=20round-7=20fix=20=E2=80=94=20cle?= =?UTF-8?q?anRoots=20also=20throws=20on=20a=20non-string=20name=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-6 hardening guarded `uri` and left `name` throwing one line later: `r.name?.trim()` guards null/undefined, not "is a string", so `"roots": [{"uri":"file:///a","name":42}]` still died at connect — the same crash class, one field over, reachable from all three clients and the web settings-save path. Reproduced against the shipped function body before fixing. A bad `name` now costs the name, not the root (it is optional, and the uri is still usable), with a warning. The doc no longer overstates what is validated. Also from review: the CLI test comment said "`cli.ts` now seeds `roots: []`" — true only of that ad-hoc test's path; and the registration comment's `(:588)` line reference is now a durable name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/cli.test.ts | 3 ++- clients/web/src/test/core/mcp/serverList.test.ts | 11 +++++++++++ core/mcp/inspectorClient.ts | 8 ++++---- core/mcp/serverList.ts | 14 +++++++++++--- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index 4130a8515..5757741f3 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -332,7 +332,8 @@ describe("CLI Tests", () => { // the capability was never advertised and no `roots/list` handler was // registered — a server that asked got -32601 Method not found, and // `--method roots/set` announced `roots/list_changed` to a server it - // would then refuse to answer. `cli.ts` now seeds `roots: []`. + // would then refuse to answer. `cli.ts` now always passes the `roots` + // option — empty on this ad-hoc path, since there is no config file. const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createListRootsTool()], diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 13108b37f..d6118fbd4 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -133,6 +133,17 @@ describe("cleanRoots", () => { expect(warn).toHaveBeenCalled(); }); + it("keeps a root whose name is not a string, dropping just the name", () => { + // `name` is optional, so a bad one costs the name, not the root. + expect( + cleanRoots([ + { uri: "file:///a", name: 42 }, + { uri: "file:///b", name: { x: 1 } }, + ] as unknown as Root[]), + ).toEqual([{ uri: "file:///a" }, { uri: "file:///b" }]); + expect(warn).toHaveBeenCalledTimes(2); + }); + it("bails to an empty list when roots is not an array", () => { // A hand-edited `"roots": "file:///work"`. expect(cleanRoots("file:///work" as unknown as Root[])).toEqual([]); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 590c71643..5d27041a5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1208,10 +1208,10 @@ export class InspectorClient extends InspectorClientEventTarget { // matching client capability inside `setRequestHandler`, so registering // this on a client built without `roots` throws "Client does not support // roots capability". Since `capabilities.roots` is negotiated at - // `initialize` (:588) and `registerCapabilities` refuses to run after - // connect, a client that omits the option can never serve `roots/list` — - // which is why every client that may call `setRoots()` later must pass - // `roots` up front (web does; the CLI now does too — #1797). + // `initialize` (set in the constructor) and `registerCapabilities` refuses + // to run after connect, a client that omits the option can never serve + // `roots/list` — which is why every client that may call `setRoots()` later + // must pass `roots` up front (web does; the CLI and TUI now do too — #1797). if (this.roots !== undefined && this.client) { this.client.setRequestHandler("roots/list", async () => { return { roots: this.roots ?? [] }; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 78a852cf7..f5049c528 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -98,8 +98,9 @@ export function normalizeServerType( * * `Root[]` is a compile-time type over hand-editable `mcp.json`, and every * client now feeds this straight from disk (#1797), so the shape is validated - * at runtime too: a non-array bails to `[]` and an entry without a string - * `uri` is dropped with a warning, rather than throwing at connect. + * at runtime too, rather than throwing at connect: a non-array bails to `[]`, + * an entry without a string `uri` is dropped, and a non-string `name` is + * dropped from an otherwise-usable entry. Each case warns. */ export function cleanRoots(roots: Root[]): Root[] { if (!Array.isArray(roots)) { @@ -115,7 +116,14 @@ export function cleanRoots(roots: Root[]): Root[] { return r.uri.trim() !== ""; }) .map((r) => { - const trimmedName = r.name?.trim(); + // `?.` would guard null/undefined but not a non-string `name` from disk, + // which `.trim()` throws on — the `uri` case above, one field over. + const rawName = r.name; + if (rawName !== undefined && typeof rawName !== "string") { + console.warn("Dropping non-string `name` on root:", r); + } + const trimmedName = + typeof rawName === "string" ? rawName.trim() : undefined; // Strip `name` off the carried-through rest so a cleared optional name // doesn't persist as `name: ""`; re-add it only when non-empty. const { name: _name, ...rest } = r; From 344198b78e7905753c2d1d69dbd2dd0ab385f8b4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:48:36 -0400 Subject: [PATCH 09/58] fix(core): normalize roots in setRoots() too (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 review: this PR made cleanRoots the single normalizer for roots at connect and at save, but setRoots — the third way roots enter the client — did a bare `this.roots = [...roots]`. Web cleans at its call site; the CLI does not, so `--method roots/set --roots-json '[{"name":"Work"}]'` stored a root with no uri, advertised it in the roots/list reply, and echoed it back as success. cleanRoots is idempotent, so calling it inside setRoots covers the CLI without disturbing web's pre-clean, and all three entry points now agree. Review filed this as an optional follow-up; folding it in for the same reason as the TUI and cleanRoots items — it is smaller than the issue describing it. Test asserts the malformed entry is dropped and the valid one survives; verified load-bearing against the bare-spread version. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/run-method.test.ts | 16 ++++++++++++++++ core/mcp/inspectorClient.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts index 242e0cd03..f2ff4cbfb 100644 --- a/clients/cli/__tests__/run-method.test.ts +++ b/clients/cli/__tests__/run-method.test.ts @@ -92,6 +92,22 @@ describe("runMethod", () => { } }); + it("roots/set drops a malformed root rather than advertising it", async () => { + // `--roots-json` only checks that the payload parses to an array, so a + // root with no `uri` used to be stored and then handed to the server in + // the `roots/list` reply — an invalid Root on the wire, echoed back to the + // user as success. `setRoots` normalizes through `cleanRoots` now (#1797). + const c = await connectStdio(); + const set = await runMethod(c, { + method: "roots/set", + rootsJson: JSON.stringify([{ name: "no uri" }, { uri: "file:///keep" }]), + }); + expect(set.kind).toBe("result"); + if (set.kind === "result") { + expect(set.result.roots).toEqual([{ uri: "file:///keep" }]); + } + }); + it("consumeMethodOutcome writes result json", async () => { let stdout = ""; const original = process.stdout.write; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 5d27041a5..fbbc853c8 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -35,6 +35,7 @@ import { INACTIVE_SUBSCRIPTION_STREAM_STATE, isTerminalStatus, } from "./types.js"; +import { cleanRoots } from "./serverList.js"; // Fallback client identity, used ONLY when a caller doesn't pass // `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI) // read the single-source version from the root package.json via @@ -4254,13 +4255,19 @@ export class InspectorClient extends InspectorClientEventTarget { * answer `-32601` to a server taking up the `roots/list_changed` invitation * below. Pass `roots` at construction — `[]` is enough — in any client that * may call this (#1797). + * + * The argument runs through `cleanRoots`, the same normalizer the + * connect-time and settings-save paths use, so all three ways roots enter the + * client agree and no caller can advertise a `Root` with no `uri` (the CLI's + * `--roots-json` only checks that the JSON is an array). It is idempotent, so + * a caller that already cleaned loses nothing. */ async setRoots(roots: Root[]): Promise { if (!this.client) { throw new Error("Client is not connected"); } - this.roots = [...roots]; + this.roots = cleanRoots(roots); this.dispatchTypedEvent("rootsChange", this.roots); // Send notification to server - clients can send this notification to any server From 5758de8d168e4edea7ef6add2c1ff05d009225a1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 21:59:34 -0400 Subject: [PATCH 10/58] =?UTF-8?q?review:=20round-9=20fixes=20=E2=80=94=20n?= =?UTF-8?q?ormalize=20roots=20in=20the=20constructor=20too=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-8 commit claimed "all three entry points now agree"; review pointed out the constructor is a fourth, and the only one still raw. All three clients clean at their call site, so nothing was broken — but the invariant was enforced by convention rather than by the class, at the very entry point this whole thread started from. `this.roots` now runs through cleanRoots, with a ternary so the `undefined`-vs-`[]` distinction that gates capabilities.roots survives. Also from review: - The CLI roots/set test provoked a cleanRoots warning without suppressing it (AGENTS.md). Suppressed, and the spy now asserts the drop was reported rather than silent. - `enables roots when previously undefined …` in coverage-backfill was named after behaviour round 5 established does not exist. Renamed, with a comment on what that client actually can and cannot do. - setRoots dispatches a copy to rootsChange, as getRoots() already did, so a listener can't push into the list we advertise. - run-method.test.ts's connectStdio now passes `roots: []` like cli.ts, so the helper matches the client shape production builds. Verified load-bearing: the constructor test fails against the raw assignment. validate / coverage / build-gate / smoke / storybook all green (run as separate stages — full `npm run ci` kept being killed mid-coverage by the environment). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/run-method.test.ts | 30 ++++++++++++++----- ...nspectorClient-peer-handler-timing.test.ts | 28 +++++++++++++++-- .../inspectorClient-coverage-backfill.test.ts | 7 +++-- core/mcp/inspectorClient.ts | 14 +++++++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts index f2ff4cbfb..783ba60b2 100644 --- a/clients/cli/__tests__/run-method.test.ts +++ b/clients/cli/__tests__/run-method.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/index.js"; @@ -25,6 +25,9 @@ describe("runMethod", () => { progress: false, sample: false, elicit: false, + // Mirrors `cli.ts`, which always passes the option so the capability is + // negotiated and the `roots/list` handler registered (#1797). + roots: [], }, ); await client.connect(); @@ -98,13 +101,24 @@ describe("runMethod", () => { // the `roots/list` reply — an invalid Root on the wire, echoed back to the // user as success. `setRoots` normalizes through `cleanRoots` now (#1797). const c = await connectStdio(); - const set = await runMethod(c, { - method: "roots/set", - rootsJson: JSON.stringify([{ name: "no uri" }, { uri: "file:///keep" }]), - }); - expect(set.kind).toBe("result"); - if (set.kind === "result") { - expect(set.result.roots).toEqual([{ uri: "file:///keep" }]); + // cleanRoots reports the drop; suppress it here and assert it happened, so + // the test also covers that a dropped root is not dropped silently. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const set = await runMethod(c, { + method: "roots/set", + rootsJson: JSON.stringify([ + { name: "no uri" }, + { uri: "file:///keep" }, + ]), + }); + expect(set.kind).toBe("result"); + if (set.kind === "result") { + expect(set.result.roots).toEqual([{ uri: "file:///keep" }]); + } + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); } }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 9b20c5c0f..4651143f5 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from "vitest"; -import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { describe, it, expect, vi } from "vitest"; +import type { + JSONRPCMessage, + Root, + Transport, +} from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; /** @@ -176,6 +180,26 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("normalizes a malformed roots option at construction", async () => { + // Core owns the invariant rather than trusting each client to clean at its + // call site — the constructor is the fourth way roots enter the client, and + // the option can come straight off hand-edited mcp.json. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({}) as never }, + roots: [{ name: "no uri" }, { uri: "file:///keep" }] as Root[], + }, + ); + expect(client.getRoots()).toEqual([{ uri: "file:///keep" }]); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it("serves roots set after connect, given roots were advertised at construction", async () => { // `setRoots()` announces `notifications/roots/list_changed`, inviting the // server to re-read — so the handler must answer with the *current* roots, diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index 291a1d89f..edef1d63a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -200,8 +200,11 @@ describe("InspectorClient coverage backfill", () => { }); describe("setRoots", () => { - it("enables roots when previously undefined and dispatches rootsChange", async () => { - // No roots option → this.roots is undefined initially. + it("stores roots and dispatches rootsChange when none were configured", async () => { + // No roots option → this.roots is undefined initially. Note setRoots does + // *not* enable the capability: this client still has no `roots/list` + // handler and no `capabilities.roots`, so only `getRoots()` reflects the + // change — see the note on setRoots (#1797). client = stdioClient(); await client.connect(); const rootsChange = waitForEvent(client, "rootsChange", { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index fbbc853c8..8f5e9e037 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -494,8 +494,14 @@ export class InspectorClient extends InspectorClientEventTarget { // the SDK guidance that a debugging tool must not auto-probe (#1626). this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" }; this.directAuthRecovery = options.directAuthRecovery ?? false; - // Only set roots if explicitly provided (even if empty array) - this enables roots capability - this.roots = options.roots; + // Only set roots if explicitly provided (even if empty array) - this enables + // roots capability. Normalized here as well as in `setRoots`, so core owns + // the invariant rather than trusting each client to clean at its call site + // (they do, and `cleanRoots` is idempotent, so this costs nothing). The + // ternary preserves the `undefined`-vs-`[]` distinction the capability + // advertisement below gates on. + this.roots = + options.roots !== undefined ? cleanRoots(options.roots) : undefined; // Initialize listChangedNotifications config (default: all enabled) this.listChangedNotifications = { tools: options.listChangedNotifications?.tools ?? true, @@ -4268,7 +4274,9 @@ export class InspectorClient extends InspectorClientEventTarget { } this.roots = cleanRoots(roots); - this.dispatchTypedEvent("rootsChange", this.roots); + // Copy, as `getRoots()` does — a listener must not be able to push into the + // list we advertise. + this.dispatchTypedEvent("rootsChange", [...this.roots]); // Send notification to server - clients can send this notification to any server // The server doesn't need to advertise support for it From 758b89752b7ee2f84f086974251a0b99df7bbfa7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 22:13:09 -0400 Subject: [PATCH 11/58] =?UTF-8?q?review:=20round-10=20fixes=20=E2=80=94=20?= =?UTF-8?q?copy=20at=20both=20rootsChange=20dispatches=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 claimed setRoots' copy closed the "a listener can't push into the list we advertise" hole. It closed one of two sites: my reply conflated the notification handler's *dispatch* with the roots/list *request* handler — the latter is serialized to the wire immediately and is fine, the former hands listeners the internal array. Both copy now. Also from review: dropped the `({}) as never` in the new constructor test for the real factory the sibling tests use (this test never connects, so it is never invoked), and the needless `async` on it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- .../core/mcp/inspectorClient-peer-handler-timing.test.ts | 6 ++++-- core/mcp/inspectorClient.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4651143f5..cdd3f5220 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -180,7 +180,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - it("normalizes a malformed roots option at construction", async () => { + it("normalizes a malformed roots option at construction", () => { // Core owns the invariant rather than trusting each client to clean at its // call site — the constructor is the fourth way roots enter the client, and // the option can come straight off hand-edited mcp.json. @@ -189,7 +189,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, { - environment: { transport: () => ({}) as never }, + environment: { + transport: () => ({ transport: new InitializedRacingTransport() }), + }, roots: [{ name: "no uri" }, { uri: "file:///keep" }] as Root[], }, ); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8f5e9e037..6c8abbc40 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1293,7 +1293,9 @@ export class InspectorClient extends InspectorClientEventTarget { async () => { // Re-dispatch our already-known roots as a refresh signal for the UI — // the payload carries no new data (see the note on ownership above). - this.dispatchTypedEvent("rootsChange", this.roots || []); + // Copied, as `setRoots` and `getRoots()` do: a listener must not be + // able to push into the list we advertise. + this.dispatchTypedEvent("rootsChange", [...(this.roots ?? [])]); }, ); } From afccdbc4e07c2ec7b920db2a578b31ce99fb228b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 22:28:31 -0400 Subject: [PATCH 12/58] review: round-11 doc fixes; merge v2/main (#1797) - cleanRoots' opening sentence still framed it as the settings-form normalizer, contradicting the paragraph below it that says every client feeds it from disk. It has four callers now; says so. - Marked the two runtime guards `Keep:` with the reason. The `Root[]` parameter narrows both to `never`, so "the compiler says this can't happen" is the argument someone will use to delete the checks rounds 6-7 installed. The reason now lives where the deletion would happen. - Noted the constructor-normalization case in the timing test's header, so it is discoverable from a file otherwise about the initialized race. Also merged origin/v2/main (#1796, loopback binding), which had advanced by one commit and touched the smoke scripts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 4 ++++ core/mcp/serverList.ts | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index cdd3f5220..4c4790ab2 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -34,6 +34,10 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * the *other* way (client→server), so the inbound handler is defensive rather * than something a conformant server exercises — this pins where it is * registered, not a behaviour real servers depend on. + * + * The last case is the odd one out: it never connects, and covers the + * constructor's `cleanRoots` normalization — same #1797 thread, since answering + * `roots/list` promptly is only useful if what we answer with is well-formed. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index f5049c528..56aaae5e6 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -88,13 +88,16 @@ export function normalizeServerType( } /** - * Normalize the form's controlled root rows into the shape the Inspector - * advertises and persists: drop rows whose `uri` is blank (the form leaves a - * new row empty mid-edit) and drop a blank/whitespace `name`. Any other fields - * a root carries (e.g. `_meta` from a hand-edited `mcp.json`) are preserved — - * only `uri`/`name` are normalized. Shared by the settings → disk converter - * (`inspectorSettingsToStoredFields`) and all three clients' connect-time + - * `setRoots` wiring so the roots told to the server match what hits disk. + * The shared roots normalizer — for a list from the web settings form, from + * `mcp.json`, or from `setRoots()`. Puts them in the shape the Inspector + * advertises and persists: drop entries whose `uri` is blank (the settings form + * leaves a new row empty mid-edit) and drop a blank/whitespace `name`. Any other + * fields a root carries (e.g. `_meta` from a hand-edited `mcp.json`) are + * preserved — only `uri`/`name` are normalized. Every path roots take runs + * through here — the settings → disk converter + * (`inspectorSettingsToStoredFields`), the `InspectorClient` constructor and + * `setRoots`, and all three clients' connect-time wiring — so what the server is + * told matches what hits disk. * * `Root[]` is a compile-time type over hand-editable `mcp.json`, and every * client now feeds this straight from disk (#1797), so the shape is validated @@ -103,12 +106,15 @@ export function normalizeServerType( * dropped from an otherwise-usable entry. Each case warns. */ export function cleanRoots(roots: Root[]): Root[] { + // Keep: the `Root[]` parameter narrows this branch to `never`, but the type is + // a promise hand-edited `mcp.json` does not keep. Not dead code (#1797). if (!Array.isArray(roots)) { console.warn("Ignoring `roots`: expected an array, got", typeof roots); return []; } return roots .filter((r) => { + // Keep: unreachable per the parameter type, reachable from disk. if (typeof r?.uri !== "string") { console.warn("Dropping root without a string `uri`:", r); return false; From 7481695ba5ad4a449e6f3cb417dddffe352b0a86 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 22:43:01 -0400 Subject: [PATCH 13/58] fix(core): clear queued peer requests when connect() fails (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-12 review found a second-order consequence of the handler move. Before it, registration happened after every await in connect(), so the moment a peer request could be queued the connect could no longer fail — queue and failure path never overlapped. Registering before the handshake puts both the handshake and the post-connect `setLoggingLevel` inside that window. The catch sets status to error and drops the transport but never cleared pendingSamples/pendingElicitations; only disconnect() does, and connect() reaches it solely on the connect-timeout path (not the default). So a server that elicits at `initialized` and then fails the connect left a live pending-request modal on a dead connection — web derives it from the queue lengths with no status gate — and the elicitation's cancel() never fired, hanging any awaitUrlElicitation waiter. Extracted disconnect()'s clear-and-cancel into clearPendingPeerRequests() and called it from the catch, dispatching the change events so the UI drops the modal. CLI/TUI are unaffected either way (sample/elicit off). Test note: the first version of this test passed with and without the fix — its `requestedSchema` omitted `properties`, so the SDK rejected the inbound params and nothing was ever queued. Fixed the fixture; it now fails without the change with a queued ElicitationCreateMessage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 77 +++++++++++++++++++ core/mcp/inspectorClient.ts | 43 +++++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4c4790ab2..4833e334d 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -138,6 +138,62 @@ class InitializedRacingTransport implements Transport { } } +/** + * Delivers an `elicitation/create` at `initialized` — the earliest a server + * could — and then fails the `logging/setLevel` that `connect()` issues after + * the handshake, so the connect attempt dies with a peer request already queued. + */ +class ElicitThenFailTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + async start(): Promise {} + async close(): Promise {} + + async send(message: JSONRPCMessage): Promise { + if (!("method" in message)) return; + if (message.method === "initialize" && "id" in message) { + const params = message.params as { protocolVersion: string }; + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: params.protocolVersion, + // Advertise logging so connect() issues the setLevel we fail below. + capabilities: { logging: {} }, + serverInfo: { name: "failing-server", version: "1.0.0" }, + }, + }); + return; + } + if (message.method === "notifications/initialized") { + this.onmessage?.({ + jsonrpc: "2.0", + id: 9201, + method: "elicitation/create", + // `properties` is required — the SDK validates inbound params, and a + // schema without it is rejected before our handler ever enqueues. + params: { + message: "Your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }); + return; + } + if (message.method === "logging/setLevel" && "id" in message) { + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32603, message: "no logging for you" }, + }); + } + } +} + describe("InspectorClient peer-handler timing (#1797)", () => { it("serves server→client traffic that arrives with notifications/initialized", async () => { const roots = [{ uri: "file:///work", name: "Work" }]; @@ -233,4 +289,25 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("drops a peer request queued during a connect that then fails", async () => { + // Registering the handlers before the handshake (#1797) widened the window + // in which a server can queue a request to include the part of connect() + // that can still fail. The failure path must not leave the queue behind: + // web derives its pending-request modal from these lengths with no status + // gate, so a stranded entry means a live modal on a dead connection, and a + // URL elicitation's waiter would never settle. + const transport = new ElicitThenFailTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + initialLoggingLevel: "debug", + }, + ); + + await expect(client.connect()).rejects.toThrow(); + + expect(client.getPendingElicitations()).toEqual([]); + expect(client.getPendingSamples()).toEqual([]); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 6c8abbc40..32e8dc685 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1300,6 +1300,23 @@ export class InspectorClient extends InspectorClientEventTarget { ); } + /** + * Settle and drop the queued peer requests (sampling / elicitation). + * + * Each elicitation is cancelled before being dropped, so an error-path + * `awaitUrlElicitation` — which blocks `callTool` — doesn't hang forever when + * the queue goes away. Callers dispatch the change events themselves: + * `disconnect()` batches them with its other teardown events, while the + * `connect()` failure path emits them on its own. + */ + private clearPendingPeerRequests(): void { + this.pendingSamples = []; + for (const elicitation of this.pendingElicitations) { + elicitation.cancel(); + } + this.pendingElicitations = []; + } + /** * Connect to the MCP server */ @@ -1607,6 +1624,23 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } + // The peer handlers are registered before the handshake (#1797), so a + // server can queue a sampling/elicitation request during it — and this is + // where that connect attempt dies. Drop the queue: otherwise the UI keeps + // a live pending-request modal for a connection that never came up, and + // answering it would route to a torn-down transport. `disconnect()` does + // the same, but `connect()` only reaches it on the connect-timeout path. + if ( + this.pendingSamples.length > 0 || + this.pendingElicitations.length > 0 + ) { + this.clearPendingPeerRequests(); + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); + } // Deliberately do NOT dispatch the `error` event here: this is the // awaited `connect()` path, so re-throwing hands the reason straight to // the caller. The `error` event is reserved for non-awaited transitions @@ -1679,14 +1713,7 @@ export class InspectorClient extends InspectorClientEventTarget { } // Clear server state on disconnect (list state is in state managers). - // Settle any outstanding elicitations as cancelled before dropping them, so - // an error-path `awaitUrlElicitation` (which blocks `callTool`) doesn't hang - // forever when the queue is cleared on teardown. - this.pendingSamples = []; - for (const elicitation of this.pendingElicitations) { - elicitation.cancel(); - } - this.pendingElicitations = []; + this.clearPendingPeerRequests(); // Clear resource subscriptions on disconnect. Tear down the modern listen // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). From 4a717b00813d64cfe2bccb98265668a0922d8a12 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 22:55:50 -0400 Subject: [PATCH 14/58] fix(core): clear the peer-request queue on a mid-session crash too (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-13 review, two items. The test asserted only `getPendingElicitations()`/`getPendingSamples()`, which read the arrays directly — but the arrays are not what removes the modal. `usePendingClientRequests` tracks its own state off the pendingSamplesChange/pendingElicitationsChange events, so a regression that cleared without dispatching would strand a live modal on a dead connection and stay green. Both tests now assert the events fired with an empty payload; verified by deleting just the dispatches, which fails them. The connect-failure fix left the other way a connection ends uncovered: a mid-session crash goes through the transport's `onclose`, which sets status and fires `disconnect` but never touched the queues. Same two hazards, and arguably more reachable. `clearAndAnnouncePendingPeerRequests` wraps the clear plus its events, guarded on a non-empty queue so the paths that overlap (a connect failure whose dropCachedTransport also fires onclose) announce once; called from both. New test queues an elicitation, fires onclose, and asserts the queue and the event — fails without the change. Review flagged the crash path as a follow-up; folding it in, as with the earlier out-of-scope items — it is the same seam and three lines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 84 +++++++++++++++++++ core/mcp/inspectorClient.ts | 43 +++++++--- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4833e334d..5e8d0c720 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -194,6 +194,50 @@ class ElicitThenFailTransport implements Transport { } } +/** Connects cleanly, then elicits on demand so a test can kill the transport. */ +class ElicitAfterConnectTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + async start(): Promise {} + async close(): Promise {} + + elicit(): void { + this.onmessage?.({ + jsonrpc: "2.0", + id: 9301, + method: "elicitation/create", + params: { + message: "Your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }); + } + + async send(message: JSONRPCMessage): Promise { + if ( + "method" in message && + message.method === "initialize" && + "id" in message + ) { + const params = message.params as { protocolVersion: string }; + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: {}, + serverInfo: { name: "dying-server", version: "1.0.0" }, + }, + }); + } + } +} + describe("InspectorClient peer-handler timing (#1797)", () => { it("serves server→client traffic that arrives with notifications/initialized", async () => { const roots = [{ uri: "file:///work", name: "Work" }]; @@ -304,10 +348,50 @@ describe("InspectorClient peer-handler timing (#1797)", () => { initialLoggingLevel: "debug", }, ); + // The events, not the arrays, are what removes the modal: + // `usePendingClientRequests` tracks its own state off them, so clearing + // without dispatching would leave a live modal on a dead connection — + // invisible to a getter-only assertion. + const elicitationCounts: number[] = []; + client.addEventListener("pendingElicitationsChange", (event) => { + elicitationCounts.push((event as CustomEvent).detail.length); + }); + const sampleCounts: number[] = []; + client.addEventListener("pendingSamplesChange", (event) => { + sampleCounts.push((event as CustomEvent).detail.length); + }); await expect(client.connect()).rejects.toThrow(); expect(client.getPendingElicitations()).toEqual([]); expect(client.getPendingSamples()).toEqual([]); + expect(elicitationCounts.at(-1)).toBe(0); + expect(sampleCounts.at(-1)).toBe(0); + }); + + it("drops a peer request queued when the connection dies mid-session", async () => { + // The other way a connection ends without `disconnect()`: the server goes + // away. Same hazards — a modal for a dead connection, and a URL + // elicitation's waiter (which blocks `callTool`) never settling. + const transport = new ElicitAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + const elicitationCounts: number[] = []; + client.addEventListener("pendingElicitationsChange", (event) => { + elicitationCounts.push((event as CustomEvent).detail.length); + }); + + await client.connect(); + transport.elicit(); + await Promise.resolve(); + expect(client.getPendingElicitations()).toHaveLength(1); + + // The server process dies. + transport.onclose?.(); + + expect(client.getPendingElicitations()).toEqual([]); + expect(elicitationCounts.at(-1)).toBe(0); }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 32e8dc685..6f59c3fa3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -721,6 +721,11 @@ export class InspectorClient extends InspectorClientEventTarget { this.status = "disconnected"; this.dispatchTypedEvent("statusChange", this.status); } + // A mid-session crash ends the connection without going through + // `disconnect()`, so drop anything the server had queued with us — the + // same reasoning as the `connect()` failure path. Cleared before the + // `disconnect` event so a consumer reacting to it sees an empty queue. + this.clearAndAnnouncePendingPeerRequests(); this.dispatchTypedEvent("disconnect"); }; baseTransport.onerror = (error: Error) => { @@ -1317,6 +1322,32 @@ export class InspectorClient extends InspectorClientEventTarget { this.pendingElicitations = []; } + /** + * {@link clearPendingPeerRequests} plus the change events, for the paths that + * end a connection without going through `disconnect()` — a failed + * `connect()` and a mid-session transport close. + * + * The events are the load-bearing half: `usePendingClientRequests` tracks its + * own state off them, so clearing the arrays without dispatching leaves the + * web pending-request modal on screen for a connection that is gone. Guarded + * on a non-empty queue so the paths that overlap (a `connect()` failure whose + * `dropCachedTransport` also fires `onclose`) announce it once. + */ + private clearAndAnnouncePendingPeerRequests(): void { + if ( + this.pendingSamples.length === 0 && + this.pendingElicitations.length === 0 + ) { + return; + } + this.clearPendingPeerRequests(); + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); + } + /** * Connect to the MCP server */ @@ -1630,17 +1661,7 @@ export class InspectorClient extends InspectorClientEventTarget { // a live pending-request modal for a connection that never came up, and // answering it would route to a torn-down transport. `disconnect()` does // the same, but `connect()` only reaches it on the connect-timeout path. - if ( - this.pendingSamples.length > 0 || - this.pendingElicitations.length > 0 - ) { - this.clearPendingPeerRequests(); - this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); - this.dispatchTypedEvent( - "pendingElicitationsChange", - this.pendingElicitations, - ); - } + this.clearAndAnnouncePendingPeerRequests(); // Deliberately do NOT dispatch the `error` event here: this is the // awaited `connect()` path, so re-throwing hands the reason straight to // the caller. The `error` event is reserved for non-awaited transitions From ecd9c7ddd9b6528df36ed4b189ef830ced2744f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:08:07 -0400 Subject: [PATCH 15/58] =?UTF-8?q?review:=20round-14=20fixes=20=E2=80=94=20?= =?UTF-8?q?make=20both=20teardown=20paths=20agree=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The crash path's comment claimed "cleared before the `disconnect` event so a consumer sees an empty queue" while disconnect() cleared *after* its own dispatch — an invariant stated as a property and held at one of two sites, the same shape as the round-10 finding. Moved disconnect()'s clear above its status/disconnect block so both paths agree; its change events stay batched with the other teardown dispatches. Full gate green, so nothing depended on the old ordering. - clearPendingPeerRequests' doc still named the `connect()` failure path as a caller; that path now goes through clearAndAnnouncePendingPeerRequests. Names the two real callers. - The mid-session test waited on `await Promise.resolve()` — the microtask-counting pattern this file replaced with injectRequest two rounds ago, working today only because the enqueue happens exactly one tick deep. Waits on the client's own `newPendingElicitation` instead. - The file header still called the constructor case "the last case"; it is third of five, and the two teardown cases went undescribed. Rewritten. - Restored the missing blank line between two tests, and noted why the sampling-event assertion holds for a queue that never held anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 26 ++++++++++++++++--- core/mcp/inspectorClient.ts | 16 ++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 5e8d0c720..4f81add5e 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -35,9 +35,16 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * than something a conformant server exercises — this pins where it is * registered, not a behaviour real servers depend on. * - * The last case is the odd one out: it never connects, and covers the - * constructor's `cleanRoots` normalization — same #1797 thread, since answering - * `roots/list` promptly is only useful if what we answer with is well-formed. + * One case never connects at all: it covers the constructor's `cleanRoots` + * normalization — same #1797 thread, since answering `roots/list` promptly is + * only useful if what we answer with is well-formed. + * + * The last two cover the flip side of the same move. Registering the handlers + * before the handshake also widened the window in which a server can queue a + * request with us, so the two paths that end a connection without going through + * `disconnect()` — a failed `connect()` and a mid-session transport close — must + * clear that queue *and* announce it, or the web pending-request modal outlives + * the connection it belongs to. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -333,6 +340,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("drops a peer request queued during a connect that then fails", async () => { // Registering the handlers before the handshake (#1797) widened the window // in which a server can queue a request to include the part of connect() @@ -366,6 +374,8 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(client.getPendingElicitations()).toEqual([]); expect(client.getPendingSamples()).toEqual([]); expect(elicitationCounts.at(-1)).toBe(0); + // The helper announces both queues whenever either was non-empty, so the + // sampling event fires here too even though nothing sampled. expect(sampleCounts.at(-1)).toBe(0); }); @@ -384,8 +394,16 @@ describe("InspectorClient peer-handler timing (#1797)", () => { }); await client.connect(); + // Wait on the client's own signal rather than counting microtasks — the + // enqueue is one tick deep today with no margin, and an added await on the + // SDK's inbound path would flake this (see `injectRequest` above). + const queued = new Promise((resolve) => + client.addEventListener("newPendingElicitation", () => resolve(), { + once: true, + }), + ); transport.elicit(); - await Promise.resolve(); + await queued; expect(client.getPendingElicitations()).toHaveLength(1); // The server process dies. diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 6f59c3fa3..a62f18795 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -724,7 +724,8 @@ export class InspectorClient extends InspectorClientEventTarget { // A mid-session crash ends the connection without going through // `disconnect()`, so drop anything the server had queued with us — the // same reasoning as the `connect()` failure path. Cleared before the - // `disconnect` event so a consumer reacting to it sees an empty queue. + // `disconnect` event so a consumer reacting to it sees an empty queue, as + // `disconnect()` also does. this.clearAndAnnouncePendingPeerRequests(); this.dispatchTypedEvent("disconnect"); }; @@ -1311,8 +1312,9 @@ export class InspectorClient extends InspectorClientEventTarget { * Each elicitation is cancelled before being dropped, so an error-path * `awaitUrlElicitation` — which blocks `callTool` — doesn't hang forever when * the queue goes away. Callers dispatch the change events themselves: - * `disconnect()` batches them with its other teardown events, while the - * `connect()` failure path emits them on its own. + * `disconnect()` batches them with its other teardown dispatches, and + * {@link clearAndAnnouncePendingPeerRequests} emits them immediately for the + * paths that end a connection without going through `disconnect()`. */ private clearPendingPeerRequests(): void { this.pendingSamples = []; @@ -1724,6 +1726,11 @@ export class InspectorClient extends InspectorClientEventTarget { this.baseTransport = null; this.transport = null; this.transportHasAuthProvider = false; + // Drop anything the server had queued with us before announcing the + // teardown, so a `disconnect` consumer sees an empty queue here as it does + // on the crash path. The change events stay batched with the other teardown + // dispatches below. + this.clearPendingPeerRequests(); // Update status - any onclose fired during close() above deferred to us // (see `disconnecting`), so this is the single place the explicit-disconnect // path settles the status and emits `disconnect`. @@ -1733,8 +1740,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("disconnect"); } - // Clear server state on disconnect (list state is in state managers). - this.clearPendingPeerRequests(); + // Clear the rest of the server state (list state is in state managers). // Clear resource subscriptions on disconnect. Tear down the modern listen // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). From 7f83e9c57c91e76a035267cd14549091caa84ebe Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:18:35 -0400 Subject: [PATCH 16/58] =?UTF-8?q?review:=20round-15=20fixes=20=E2=80=94=20?= =?UTF-8?q?scope=20the=20parity=20claim=20to=20the=20arrays=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-14 made the two teardown paths agree on when the queue is cleared, and the comment then claimed parity flatly. It holds for the arrays but not the events: the crash path announces before `disconnect`, while disconnect() batches its change events with the rest of its teardown, so they land just after. Since the events are what drives the modal, the distinction matters to the next reader. Scoped the comment rather than un-batching — un-batching would trade a documented ordering for a real behaviour change with no gain. Same rule as round 14, one level down: check the other site, then check the other axis. Also gave the new `newPendingElicitation` wait the timeout that `injectRequest` documents, so a regression that stops the enqueue fails at the wait instead of hanging to the vitest timeout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 21 ++++++++++++++----- core/mcp/inspectorClient.ts | 6 ++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4f81add5e..ba0c98364 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -397,11 +397,22 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // Wait on the client's own signal rather than counting microtasks — the // enqueue is one tick deep today with no margin, and an added await on the // SDK's inbound path would flake this (see `injectRequest` above). - const queued = new Promise((resolve) => - client.addEventListener("newPendingElicitation", () => resolve(), { - once: true, - }), - ); + // Raced with a reject for the same reason `injectRequest` is: a regression + // that stops the enqueue should fail here, not hang to the vitest timeout. + const queued = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("No newPendingElicitation after elicit()")), + 1000, + ); + client.addEventListener( + "newPendingElicitation", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); transport.elicit(); await queued; expect(client.getPendingElicitations()).toHaveLength(1); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a62f18795..443b128f5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -724,8 +724,10 @@ export class InspectorClient extends InspectorClientEventTarget { // A mid-session crash ends the connection without going through // `disconnect()`, so drop anything the server had queued with us — the // same reasoning as the `connect()` failure path. Cleared before the - // `disconnect` event so a consumer reacting to it sees an empty queue, as - // `disconnect()` also does. + // `disconnect` event so a consumer reading the queue while handling it + // sees it empty. `disconnect()` clears at the same point, but batches its + // change events with the rest of its teardown dispatches — so on that + // path the *events* land just after its `disconnect`, not before. this.clearAndAnnouncePendingPeerRequests(); this.dispatchTypedEvent("disconnect"); }; From 166c64acc73142345e99508b69de0ac4dacd55c9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:28:47 -0400 Subject: [PATCH 17/58] test: pin disconnect()'s clear-before-announce ordering (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-14 moved clearPendingPeerRequests() above disconnect()'s status block so a `disconnect` consumer sees an empty queue on both teardown paths, and round 15 documented it — but nothing enforced it. The existing `disconnect` listeners only count events, and the two teardown tests cover the crash and connect-failure paths, neither of which goes through disconnect(). So the one behavioural change in the stack was untested in both directions, and the tidy that undoes it (regrouping the clear with the batched change events) would have stayed green. Test records the queue length from inside a `disconnect` listener; the fixture's close() never fires onclose, so it exercises disconnect()'s own ordering rather than the crash path. Verified against the pre-round-14 position: `expected 1 to be +0`. Folded the newPendingElicitation wait into a shared helper now that two tests use it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 73 ++++++++++++++----- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index ba0c98364..f48897a55 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -245,6 +245,30 @@ class ElicitAfterConnectTransport implements Transport { } } +/** + * Resolve when the client enqueues an elicitation. Waits on the client's own + * signal rather than counting microtasks — the enqueue is one tick deep today + * with no margin, and an added await on the SDK's inbound path would flake it. + * Raced with a reject for the same reason `injectRequest` is: a regression that + * stops the enqueue should fail here, not hang to the vitest timeout. + */ +function waitForNewPendingElicitation(client: InspectorClient): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("No newPendingElicitation after elicit()")), + 1000, + ); + client.addEventListener( + "newPendingElicitation", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + describe("InspectorClient peer-handler timing (#1797)", () => { it("serves server→client traffic that arrives with notifications/initialized", async () => { const roots = [{ uri: "file:///work", name: "Work" }]; @@ -394,25 +418,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { }); await client.connect(); - // Wait on the client's own signal rather than counting microtasks — the - // enqueue is one tick deep today with no margin, and an added await on the - // SDK's inbound path would flake this (see `injectRequest` above). - // Raced with a reject for the same reason `injectRequest` is: a regression - // that stops the enqueue should fail here, not hang to the vitest timeout. - const queued = new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("No newPendingElicitation after elicit()")), - 1000, - ); - client.addEventListener( - "newPendingElicitation", - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); - }); + const queued = waitForNewPendingElicitation(client); transport.elicit(); await queued; expect(client.getPendingElicitations()).toHaveLength(1); @@ -423,4 +429,33 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(client.getPendingElicitations()).toEqual([]); expect(elicitationCounts.at(-1)).toBe(0); }); + + it("has already cleared the queue by the time `disconnect` fires", async () => { + // `disconnect()` clears above its status block so a consumer handling the + // event sees an empty queue, matching the crash path. That ordering is the + // whole point of the clear's position, and moving it back below the block — + // which reads tidier, next to the batched change events — would silently + // restore the asymmetry. This is what notices. + const transport = new ElicitAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + + await client.connect(); + const queued = waitForNewPendingElicitation(client); + transport.elicit(); + await queued; + expect(client.getPendingElicitations()).toHaveLength(1); + + let queueDuringDisconnectEvent = -1; + client.addEventListener("disconnect", () => { + queueDuringDisconnectEvent = client.getPendingElicitations().length; + }); + // This fixture's close() never fires onclose, so the clear under test is + // `disconnect()`'s own, not the crash path's. + await client.disconnect(); + + expect(queueDuringDisconnectEvent).toBe(0); + }); }); From 6c5a16863591f21f2fdecd2ea095db4f2192a82e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:37:36 -0400 Subject: [PATCH 18/58] test: pin disconnect()'s announce as well as its clear (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-16 pinned the array half of disconnect()'s teardown and left the events unpinned — and nothing else in the suite covers them, so deleting disconnect()'s two change-event dispatches would have left a live modal after a user-initiated disconnect with the suite green. That is the path most likely to have a modal open when it runs. Asserted after the await rather than inside the disconnect listener, since disconnect() batches the events deliberately; verified by deleting the dispatches (`expected undefined to be +0`). Header rewritten by category rather than position: it has now gone stale twice in three rounds because it counted tests ("the last two") and enumerated paths that a new test falsified. It names the three teardown paths and what distinguishes them instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index f48897a55..4ed366b61 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -39,12 +39,15 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * normalization — same #1797 thread, since answering `roots/list` promptly is * only useful if what we answer with is well-formed. * - * The last two cover the flip side of the same move. Registering the handlers - * before the handshake also widened the window in which a server can queue a - * request with us, so the two paths that end a connection without going through - * `disconnect()` — a failed `connect()` and a mid-session transport close — must - * clear that queue *and* announce it, or the web pending-request modal outlives - * the connection it belongs to. + * The teardown cases cover the flip side of the same move. Registering the + * handlers before the handshake also widened the window in which a server can + * queue a request with us, so every path that ends a connection has to clear + * that queue and announce it — otherwise the web pending-request modal outlives + * the connection it belongs to. There are three: a failed `connect()`, a + * mid-session transport close, and an explicit `disconnect()`. All three clear + * before announcing the teardown; the first two emit the change events + * immediately, while `disconnect()` batches them with its other teardown + * dispatches. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -452,10 +455,19 @@ describe("InspectorClient peer-handler timing (#1797)", () => { client.addEventListener("disconnect", () => { queueDuringDisconnectEvent = client.getPendingElicitations().length; }); + // The array is what a `disconnect` handler reads; the change event is what + // drives the modal. `disconnect()` batches the latter after its `disconnect` + // dispatch, so this is asserted after the await rather than inside the + // listener — pinning that it happens, not where it interleaves. + const elicitationCounts: number[] = []; + client.addEventListener("pendingElicitationsChange", (event) => { + elicitationCounts.push((event as CustomEvent).detail.length); + }); // This fixture's close() never fires onclose, so the clear under test is // `disconnect()`'s own, not the crash path's. await client.disconnect(); expect(queueDuringDisconnectEvent).toBe(0); + expect(elicitationCounts.at(-1)).toBe(0); }); }); From b0792220455862303e4a76f7339c514a90d74b5b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:46:51 -0400 Subject: [PATCH 19/58] =?UTF-8?q?review:=20round-18=20doc=20fixes=20?= =?UTF-8?q?=E2=80=94=20scope=20the=20three-path=20claim,=20re-attribute=20?= =?UTF-8?q?cancel()=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header I rewrote last round claimed all three teardown paths clear before announcing. True only if "announcing" means the `disconnect` event: the connect-failure path emits none at all, and both it and the crash path clear after their `statusChange`. Scoped to what's true. Same shape as rounds 10/14/15 — a parity asserted in prose that holds on one of the axes it could mean — which is now the fourth time, in a sentence I wrote. ElicitationCreateMessage.cancel()'s doc still credited `disconnect()` teardown; this PR made clearPendingPeerRequests() its only caller, serving three paths. Re-attributed, and promoted the no-`onRemove` note to its own paragraph with the consequence spelled out — it quietly became a three-caller invariant during this PR, and "don't splice mid-iteration" reads like a style note until you know it would skip every other entry and leave those promises unsettled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- .../inspectorClient-peer-handler-timing.test.ts | 10 ++++++---- core/mcp/elicitationCreateMessage.ts | 16 +++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4ed366b61..2369ba6e9 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -44,10 +44,12 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * queue a request with us, so every path that ends a connection has to clear * that queue and announce it — otherwise the web pending-request modal outlives * the connection it belongs to. There are three: a failed `connect()`, a - * mid-session transport close, and an explicit `disconnect()`. All three clear - * before announcing the teardown; the first two emit the change events - * immediately, while `disconnect()` batches them with its other teardown - * dispatches. + * mid-session transport close, and an explicit `disconnect()`. The two that + * emit a `disconnect` event clear before it, so a handler reading the queue + * sees it empty; the connect-failure path emits no `disconnect` at all and + * clears after its `statusChange` to `"error"`. The two crash/failure paths + * emit the change events immediately, while `disconnect()` batches them with + * its other teardown dispatches. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; diff --git a/core/mcp/elicitationCreateMessage.ts b/core/mcp/elicitationCreateMessage.ts index 2c30a6b2b..4b76ade03 100644 --- a/core/mcp/elicitationCreateMessage.ts +++ b/core/mcp/elicitationCreateMessage.ts @@ -102,11 +102,17 @@ export class ElicitationCreateMessage { /** * Settle a still-pending elicitation as cancelled, without removing it from - * the queue. Used by `disconnect()` teardown so an awaiting caller — notably - * the error-path `awaitUrlElicitation` that blocks `callTool` — doesn't hang - * forever when the pending queue is dropped wholesale. No-op once already - * resolved; deliberately does not call `onRemove` (the caller clears the - * queue itself, so we must not splice it mid-iteration). + * the queue. Called from `InspectorClient`'s `clearPendingPeerRequests()`, + * which serves all three teardown paths — an explicit `disconnect()`, a + * failed `connect()`, and a mid-session transport close — so an awaiting + * caller (notably the error-path `awaitUrlElicitation` that blocks + * `callTool`) doesn't hang forever when the pending queue is dropped + * wholesale. No-op once already resolved. + * + * Deliberately does not call `onRemove`: that caller iterates the queue and + * clears it itself, so removing here would splice mid-iteration — skipping + * every other entry and leaving those promises unsettled, the exact hang this + * method exists to prevent. */ cancel(): void { if (this.resolvePromise) { From 77912b70ac4eea9d68e8232dd2b453031e8fef2a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:59:49 -0400 Subject: [PATCH 20/58] fix(core): gate roots/list on what was advertised, not on this.roots (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-19 review found a latent wedge, reproduced before fixing: a client constructed without `roots` that later calls setRoots() has this.roots defined, so registerPeerRequestHandlers tried to register the handler on every subsequent connect() — and the SDK throws "Client does not support roots capability" from setRequestHandler when the capability was never advertised. That throw lands before the handshake, so the client can never reconnect. Worse than the -32601 this PR started from: the connection is gone rather than answering one method badly. Not a regression (the same gate lived after the handshake before) and unreachable from the three shipped clients, which all pass roots. But core/ is a library surface, and the failure is unrecoverable. Gate now reads a construction-time `rootsCapabilityAdvertised` — the thing the SDK actually asserts on — so this.roots stays free to change. setRoots' doc says what does happen on such a client: the roots are stored and readable, but no server can ask for them. Also scoped the header's claim that the connect-failure path emits no `disconnect`: true of connect()'s own catch, but on a real transport dropCachedTransport's close() fires onclose first, which does dispatch one — making the catch's own call the auth-recovery backstop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 42 ++++++++++++++++--- core/mcp/inspectorClient.ts | 23 ++++++++-- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 2369ba6e9..7ef12f89a 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -44,12 +44,15 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * queue a request with us, so every path that ends a connection has to clear * that queue and announce it — otherwise the web pending-request modal outlives * the connection it belongs to. There are three: a failed `connect()`, a - * mid-session transport close, and an explicit `disconnect()`. The two that - * emit a `disconnect` event clear before it, so a handler reading the queue - * sees it empty; the connect-failure path emits no `disconnect` at all and - * clears after its `statusChange` to `"error"`. The two crash/failure paths - * emit the change events immediately, while `disconnect()` batches them with - * its other teardown dispatches. + * mid-session transport close, and an explicit `disconnect()`. `disconnect()` + * and the crash path clear before dispatching `disconnect`, so a handler + * reading the queue sees it empty. `connect()`'s own catch dispatches no + * `disconnect` — though on a real transport its `dropCachedTransport()` usually + * fires `onclose` first, which clears, announces and *does* dispatch one, so + * the catch's own call is really the backstop for the auth-recovery sub-case, + * where the transport is retained and no `onclose` fires. The crash and + * failure paths emit the change events immediately; `disconnect()` batches them + * with its other teardown dispatches. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -435,6 +438,33 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(elicitationCounts.at(-1)).toBe(0); }); + it("can reconnect after setRoots() on a client built without roots", async () => { + // `setRoots()` makes `this.roots` defined on a client that never advertised + // the capability. Gating the `roots/list` registration on that would throw + // "Client does not support roots capability" from `setRequestHandler` on + // every later connect() — before the handshake, so the client could never + // reconnect. The gate reads what was advertised at construction instead. + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + }, + ); + + await client.connect(); + await client.setRoots([{ uri: "file:///late" }]); + await client.disconnect(); + + await expect(client.connect()).resolves.toBeUndefined(); + // Stored and readable, but no server can ask for them — the capability was + // never advertised, so no handler is registered. + expect(client.getRoots()).toEqual([{ uri: "file:///late" }]); + + await client.disconnect(); + }); + it("has already cleared the queue by the time `disconnect` fires", async () => { // `disconnect()` clears above its status block so a consumer handling the // event sees an empty queue, matching the crash path. That ordering is the diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 443b128f5..72d08438c 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -364,6 +364,19 @@ export class InspectorClient extends InspectorClientEventTarget { private pendingElicitations: ElicitationCreateMessage[] = []; // Roots (undefined means roots capability not enabled, empty array means enabled but no roots) private roots: Root[] | undefined; + /** + * Whether `capabilities.roots` was advertised at `initialize` — i.e. whether + * the constructor was given a `roots` option. Fixed for the client's lifetime, + * because the capability is negotiated at construction and the SDK refuses + * `registerCapabilities` after connect. + * + * The `roots/list` registration gates on *this*, not on `this.roots`, which + * `setRoots()` can make defined later: the SDK throws "Client does not support + * roots capability" from `setRequestHandler` when the capability was never + * advertised, and that throw would land before the handshake on every + * subsequent `connect()` — wedging the client permanently (#1797). + */ + private readonly rootsCapabilityAdvertised: boolean; // Content cache // ListChanged notification configuration private listChangedNotifications: { @@ -502,6 +515,7 @@ export class InspectorClient extends InspectorClientEventTarget { // advertisement below gates on. this.roots = options.roots !== undefined ? cleanRoots(options.roots) : undefined; + this.rootsCapabilityAdvertised = options.roots !== undefined; // Initialize listChangedNotifications config (default: all enabled) this.listChangedNotifications = { tools: options.listChangedNotifications?.tools ?? true, @@ -1219,7 +1233,7 @@ export class InspectorClient extends InspectorClientEventTarget { } } - // Gated on the *constructor* value, and it has to be: the SDK asserts the + // Gated on what was advertised at construction, and it has to be: the SDK asserts the // matching client capability inside `setRequestHandler`, so registering // this on a client built without `roots` throws "Client does not support // roots capability". Since `capabilities.roots` is negotiated at @@ -1227,7 +1241,7 @@ export class InspectorClient extends InspectorClientEventTarget { // to run after connect, a client that omits the option can never serve // `roots/list` — which is why every client that may call `setRoots()` later // must pass `roots` up front (web does; the CLI and TUI now do too — #1797). - if (this.roots !== undefined && this.client) { + if (this.rootsCapabilityAdvertised && this.client) { this.client.setRequestHandler("roots/list", async () => { return { roots: this.roots ?? [] }; }); @@ -4317,8 +4331,9 @@ export class InspectorClient extends InspectorClientEventTarget { * SDK refuses `registerCapabilities` after connect, so such a client has no * `roots/list` handler (see {@link registerPeerRequestHandlers}) and would * answer `-32601` to a server taking up the `roots/list_changed` invitation - * below. Pass `roots` at construction — `[]` is enough — in any client that - * may call this (#1797). + * below — the roots set here are stored and readable via {@link getRoots}, + * but no server can ask for them. Pass `roots` at construction — `[]` is + * enough — in any client that may call this (#1797). * * The argument runs through `cleanRoots`, the same normalizer the * connect-time and settings-save paths use, so all three ways roots enter the From 402e4736c0af419750c7c59ad43b588f3a1c9a29 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:10:35 -0400 Subject: [PATCH 21/58] =?UTF-8?q?review:=20round-20=20=E2=80=94=20derive?= =?UTF-8?q?=20the=20roots=20gate=20from=20the=20advertisement=20itself=20(?= =?UTF-8?q?#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-19's fix restored the invariant but not the derivation: rootsCapabilityAdvertised was computed from `options.roots` alongside the capability block rather than from it, so the two encodings of "did we advertise roots" could drift. Adding any term to the advertisement condition would leave the flag true with `capabilities.roots` absent — the same unrecoverable wedge, reintroduced silently and in the throwing direction. Now read off the built capability object after it is assembled, so divergence is structurally impossible rather than merely absent. Verified the reconnect test still fails against the old `this.roots` gate. Also: named the capability-lifetime category in the test header (the new test was none of the three it listed), moved that test below the teardown group so the group stays contiguous, and rewrapped the over-long comment line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 60 ++++++++++--------- core/mcp/inspectorClient.ts | 23 ++++--- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 7ef12f89a..85bf88f96 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -53,6 +53,12 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * where the transport is retained and no `onclose` fires. The crash and * failure paths emit the change events immediately; `disconnect()` batches them * with its other teardown dispatches. + * + * One case covers the other side of the registration gate: `capabilities.roots` + * is fixed at construction, so the gate must key off what was advertised rather + * than the mutable `this.roots` — otherwise a later `setRoots()` makes every + * subsequent `connect()` try to register a handler the SDK refuses, and the + * client can never reconnect. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -438,33 +444,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(elicitationCounts.at(-1)).toBe(0); }); - it("can reconnect after setRoots() on a client built without roots", async () => { - // `setRoots()` makes `this.roots` defined on a client that never advertised - // the capability. Gating the `roots/list` registration on that would throw - // "Client does not support roots capability" from `setRequestHandler` on - // every later connect() — before the handshake, so the client could never - // reconnect. The gate reads what was advertised at construction instead. - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { - environment: { - transport: () => ({ transport: new ElicitAfterConnectTransport() }), - }, - }, - ); - - await client.connect(); - await client.setRoots([{ uri: "file:///late" }]); - await client.disconnect(); - - await expect(client.connect()).resolves.toBeUndefined(); - // Stored and readable, but no server can ask for them — the capability was - // never advertised, so no handler is registered. - expect(client.getRoots()).toEqual([{ uri: "file:///late" }]); - - await client.disconnect(); - }); - it("has already cleared the queue by the time `disconnect` fires", async () => { // `disconnect()` clears above its status block so a consumer handling the // event sees an empty queue, matching the crash path. That ordering is the @@ -502,4 +481,31 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(queueDuringDisconnectEvent).toBe(0); expect(elicitationCounts.at(-1)).toBe(0); }); + + it("can reconnect after setRoots() on a client built without roots", async () => { + // `setRoots()` makes `this.roots` defined on a client that never advertised + // the capability. Gating the `roots/list` registration on that would throw + // "Client does not support roots capability" from `setRequestHandler` on + // every later connect() — before the handshake, so the client could never + // reconnect. The gate reads what was advertised at construction instead. + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + }, + ); + + await client.connect(); + await client.setRoots([{ uri: "file:///late" }]); + await client.disconnect(); + + await expect(client.connect()).resolves.toBeUndefined(); + // Stored and readable, but no server can ask for them — the capability was + // never advertised, so no handler is registered. + expect(client.getRoots()).toEqual([{ uri: "file:///late" }]); + + await client.disconnect(); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 72d08438c..43ff9227b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -365,10 +365,11 @@ export class InspectorClient extends InspectorClientEventTarget { // Roots (undefined means roots capability not enabled, empty array means enabled but no roots) private roots: Root[] | undefined; /** - * Whether `capabilities.roots` was advertised at `initialize` — i.e. whether - * the constructor was given a `roots` option. Fixed for the client's lifetime, - * because the capability is negotiated at construction and the SDK refuses - * `registerCapabilities` after connect. + * Whether `capabilities.roots` was advertised at `initialize`, read off the + * capability object actually sent rather than re-derived from the constructor + * option. Fixed for the client's lifetime, because the capability is + * negotiated at construction and the SDK refuses `registerCapabilities` after + * connect. * * The `roots/list` registration gates on *this*, not on `this.roots`, which * `setRoots()` can make defined later: the SDK throws "Client does not support @@ -515,7 +516,6 @@ export class InspectorClient extends InspectorClientEventTarget { // advertisement below gates on. this.roots = options.roots !== undefined ? cleanRoots(options.roots) : undefined; - this.rootsCapabilityAdvertised = options.roots !== undefined; // Initialize listChangedNotifications config (default: all enabled) this.listChangedNotifications = { tools: options.listChangedNotifications?.tools ?? true, @@ -640,6 +640,11 @@ export class InspectorClient extends InspectorClientEventTarget { } clientOptions.capabilities = capabilities; this.clientCapabilities = capabilities; + // Read off the built capability object rather than re-deriving from + // `options.roots`: the gate and the advertisement must agree, and two + // independent derivations of the same fact can drift (a `readonly` field is + // assignable anywhere in the constructor). + this.rootsCapabilityAdvertised = capabilities.roots !== undefined; this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { @@ -1233,10 +1238,10 @@ export class InspectorClient extends InspectorClientEventTarget { } } - // Gated on what was advertised at construction, and it has to be: the SDK asserts the - // matching client capability inside `setRequestHandler`, so registering - // this on a client built without `roots` throws "Client does not support - // roots capability". Since `capabilities.roots` is negotiated at + // Gated on what was advertised at construction, and it has to be: the SDK + // asserts the matching client capability inside `setRequestHandler`, so + // registering this on a client built without `roots` throws "Client does + // not support roots capability". Since `capabilities.roots` is negotiated at // `initialize` (set in the constructor) and `registerCapabilities` refuses // to run after connect, a client that omits the option can never serve // `roots/list` — which is why every client that may call `setRoots()` later From 2115a7cc1dec9f62a22a363bf2e6ed811c8f5d7e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:20:51 -0400 Subject: [PATCH 22/58] fix(core): gate elicitation/create on what was advertised too (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-21 review asked round-20's question of the capability next door, and there the two derivations already disagreed — no future edit needed. `elicit` is typed `boolean | { form?, url? }`, so `{}` or `{ form: false, url: false }` is valid, enables no mode, and advertises no `capabilities.elicitation` — while the registration gated on `this.elicit` being truthy. Reproduced before fixing: CONNECT ERROR: Client does not support elicitation capability (required for elicitation/create) Since #1797 moved registration before the handshake, that throw means the client cannot connect at all. Same wedge as the roots one, one capability over, and reachable through a documented option value rather than a future edit. Gated on a new elicitationCapabilityAdvertised, derived from the built capability object exactly as the roots flag now is. Test constructs with `{ form: false, url: false }` and asserts connect() resolves; verified load-bearing. Also made the four construction-time capability inputs (sample, elicit, receiverTasks, advertisedExtensions) `readonly`. The round-19 bug was a mutator making a construction-time fact stale, so a future setter for any of these should be a compile error rather than a review catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 32 ++++++++++++++++--- core/mcp/inspectorClient.ts | 26 +++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 85bf88f96..1fd949194 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -54,11 +54,13 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * failure paths emit the change events immediately; `disconnect()` batches them * with its other teardown dispatches. * - * One case covers the other side of the registration gate: `capabilities.roots` - * is fixed at construction, so the gate must key off what was advertised rather - * than the mutable `this.roots` — otherwise a later `setRoots()` makes every - * subsequent `connect()` try to register a handler the SDK refuses, and the - * client can never reconnect. + * Two cases cover the other side of the registration gates. Client capabilities + * are fixed at construction, so each gate must key off what was actually + * advertised rather than the option it was derived from: a later `setRoots()` + * must not make a subsequent `connect()` register a roots handler that was + * never advertised, and an `elicit` option that enables no mode must not + * register an elicitation handler. Either mistake throws before the handshake, + * so the client cannot connect at all. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -482,6 +484,26 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(elicitationCounts.at(-1)).toBe(0); }); + it("connects when an elicit option enables no mode", async () => { + // `{ form: false, url: false }` is a valid option that advertises no + // elicitation capability. Registering `elicitation/create` on `this.elicit` + // being truthy would throw "Client does not support elicitation capability" + // before the handshake — so the client could not connect at all. + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + elicit: { form: false, url: false }, + }, + ); + + await expect(client.connect()).resolves.toBeUndefined(); + + await client.disconnect(); + }); + it("can reconnect after setRoots() on a client built without roots", async () => { // `setRoots()` makes `this.roots` defined on a client that never advertised // the capability. Gating the `roots/list` registration on that would throw diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 43ff9227b..f2b7b8f2b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -321,8 +321,8 @@ export class InspectorClient extends InspectorClientEventTarget { // request so server logs arrive on each request's stream; `undefined` means // "don't opt in" (logs stay silently absent). Only honored on the modern era. private modernLogLevel?: LoggingLevel; - private sample: boolean; - private elicit: boolean | { form?: boolean; url?: boolean }; + private readonly sample: boolean; + private readonly elicit: boolean | { form?: boolean; url?: boolean }; private progress: boolean; private resetTimeoutOnProgress: boolean; private requestTimeout: number | undefined; @@ -378,6 +378,16 @@ export class InspectorClient extends InspectorClientEventTarget { * subsequent `connect()` — wedging the client permanently (#1797). */ private readonly rootsCapabilityAdvertised: boolean; + /** + * Whether `capabilities.elicitation` was advertised, on the same terms as + * {@link rootsCapabilityAdvertised}. Not the same question as `this.elicit` + * being truthy: `{}` / `{ form: false, url: false }` are valid options that + * enable no mode, so nothing is advertised — and registering + * `elicitation/create` anyway throws "Client does not support elicitation + * capability" before the handshake, leaving the client unable to connect + * at all (#1797). + */ + private readonly elicitationCapabilityAdvertised: boolean; // Content cache // ListChanged notification configuration private listChangedNotifications: { @@ -445,10 +455,10 @@ export class InspectorClient extends InspectorClientEventTarget { // `cancelRequestorTask` instead, so they don't use this (#1458). private activeToolCallAbortController?: AbortController; // Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us) - private receiverTasks: boolean; + private readonly receiverTasks: boolean; // Per-extension advertise overrides (#1738); undefined key falls back to the // registry default in ADVERTISABLE_EXTENSIONS. - private advertisedExtensions?: Record; + private readonly advertisedExtensions?: Record; private receiverTaskTtlMs: number | (() => number); private receiverTaskRecords: Map = new Map(); // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") @@ -645,6 +655,8 @@ export class InspectorClient extends InspectorClientEventTarget { // independent derivations of the same fact can drift (a `readonly` field is // assignable anywhere in the constructor). this.rootsCapabilityAdvertised = capabilities.roots !== undefined; + this.elicitationCapabilityAdvertised = + capabilities.elicitation !== undefined; this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { @@ -1175,8 +1187,10 @@ export class InspectorClient extends InspectorClientEventTarget { } } - // Set up elicitation request handler if elicitation capability is enabled - if (this.elicit && this.client) { + // Gated on what was advertised, not on `this.elicit` — see the field's doc: + // an elicit option that enables no mode advertises nothing, and registering + // regardless throws before the handshake. + if (this.elicitationCapabilityAdvertised && this.client) { const elicitHandler = (request: ElicitRequest): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; From bdf65dce8c37f739f474bb1faa3eadba58f87b45 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:34:44 -0400 Subject: [PATCH 23/58] fix(core): derive tasks.requests and the remaining gates from the advertisement (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilities.tasks.requests declares which server→client requests we accept as tasks, but was built from `receiverTasks` alone — so `{ receiverTasks: true, elicit: false }` told the server it could send a task-augmented elicitation/create while advertising no elicitation capability and (since the last commit) registering no handler. A server taking the invitation gets -32601: the advertise-then-refuse shape this PR opened with, and the last place it survived. Now built from capabilities.sampling / capabilities.elicitation, both decided just above, with the key omitted entirely if neither applies. Also made the remaining three registration gates read the built capability object like the two from the last two rounds — sampling, tasks/*, and the URL-elicitation completion handler. None could diverge today, but a mixed convention means the next reader has to re-derive which gates are safe, and adding one condition to an advertisement block is exactly how elicitation broke. Five gates, one rule now. Test asserts a receiverTasks+elicit:false client advertises tasks.requests with sampling and without elicitation; verified load-bearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 23 ++++++++++ core/mcp/inspectorClient.ts | 42 +++++++++++++------ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 1fd949194..fd3a994dc 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -504,6 +504,29 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("advertises task requests only for capabilities it advertised", async () => { + // `capabilities.tasks.requests` tells the server which server→client + // requests we accept as tasks. Built from `receiverTasks` alone it would + // invite a task-augmented `elicitation/create` that no handler answers — + // advertise-then-refuse, the shape #1797 is about. + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + receiverTasks: true, + elicit: false, + }, + ); + + const capabilities = client.getClientCapabilities(); + expect(capabilities.tasks).toBeDefined(); + expect(capabilities.tasks?.requests?.elicitation).toBeUndefined(); + expect(capabilities.tasks?.requests?.sampling).toBeDefined(); + expect(capabilities.elicitation).toBeUndefined(); + }); + it("can reconnect after setRoots() on a client built without roots", async () => { // `setRoots()` makes `this.roots` defined on a client that never advertised // the capability. Gating the `roots/list` registration on that would throw diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index f2b7b8f2b..30cb4c83a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -388,6 +388,12 @@ export class InspectorClient extends InspectorClientEventTarget { * at all (#1797). */ private readonly elicitationCapabilityAdvertised: boolean; + /** As above, for `capabilities.sampling`. */ + private readonly samplingCapabilityAdvertised: boolean; + /** As above, for `capabilities.tasks` (the receiver-side `tasks/*` polls). */ + private readonly tasksCapabilityAdvertised: boolean; + /** As above, for `capabilities.elicitation.url` (the URL-mode completion). */ + private readonly urlElicitationCapabilityAdvertised: boolean; // Content cache // ListChanged notification configuration private listChangedNotifications: { @@ -621,13 +627,24 @@ export class InspectorClient extends InspectorClientEventTarget { } // Receiver tasks: advertise so server can send task-augmented createMessage/elicit and poll us if (this.receiverTasks) { + // `requests` declares which server→client requests we accept as tasks, so + // it must name only capabilities we actually advertised — both are decided + // above. Advertising a channel we then answer `-32601` on is the shape + // #1797 is about, and `{ receiverTasks: true, elicit: false }` would do + // exactly that. + const taskRequests: NonNullable< + NonNullable["requests"] + > = {}; + if (capabilities.sampling) { + taskRequests.sampling = { createMessage: {} }; + } + if (capabilities.elicitation) { + taskRequests.elicitation = { create: {} }; + } capabilities.tasks = { list: {}, cancel: {}, - requests: { - sampling: { createMessage: {} }, - elicitation: { create: {} }, - }, + ...(Object.keys(taskRequests).length > 0 && { requests: taskRequests }), }; } // Assemble the advertised-extensions map from one builder (the single @@ -657,6 +674,10 @@ export class InspectorClient extends InspectorClientEventTarget { this.rootsCapabilityAdvertised = capabilities.roots !== undefined; this.elicitationCapabilityAdvertised = capabilities.elicitation !== undefined; + this.samplingCapabilityAdvertised = capabilities.sampling !== undefined; + this.tasksCapabilityAdvertised = capabilities.tasks !== undefined; + this.urlElicitationCapabilityAdvertised = + capabilities.elicitation?.url !== undefined; this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { @@ -1115,8 +1136,9 @@ export class InspectorClient extends InspectorClientEventTarget { * gate on `this.capabilities` stay in `connect()`, after the handshake. */ private registerPeerRequestHandlers(): void { - // Set up sampling request handler if sampling capability is enabled - if (this.sample && this.client) { + // Gated on what was advertised, like the others — see + // `rootsCapabilityAdvertised`. + if (this.samplingCapabilityAdvertised && this.client) { const samplingHandler = ( request: CreateMessageRequest, ): Promise => { @@ -1274,7 +1296,7 @@ export class InspectorClient extends InspectorClientEventTarget { // not validate our responder return — matching v1, where only the // requester validated (our receiver `Task` may omit fields a strict result // schema would require). - if (this.receiverTasks && this.client) { + if (this.tasksCapabilityAdvertised && this.client) { this.client.setRequestHandler( "tasks/list", { params: ListTasksRequestSchema.shape.params }, @@ -1655,11 +1677,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Elicitation complete notification (URL mode only): server notifies when out-of-band // elicitation completes; we resolve the corresponding pending elicitation - const urlElicitEnabled = - this.elicit && - typeof this.elicit === "object" && - this.elicit.url === true; - if (urlElicitEnabled) { + if (this.urlElicitationCapabilityAdvertised) { this.client.setNotificationHandler( "notifications/elicitation/complete", async (notification) => { From a54a16e544f2e8a51b1349a3eff18215f7edf044 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:46:12 -0400 Subject: [PATCH 24/58] =?UTF-8?q?review:=20round-23=20=E2=80=94=20pin=20th?= =?UTF-8?q?e=20omitted-requests=20arm,=20drop=20the=20count=20again=20(#17?= =?UTF-8?q?97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `requests` key is omitted when neither sampling nor elicitation is advertised, but every `receiverTasks: true` construction in the tree left one of them enabled, so that arm was unexercised — and it would not have shown as a coverage failure, since one uncovered branch in a ~4900-line file sits well inside the per-file gate. Second client in the same test asserts `tasks` survives (list/cancel are still serviceable) while `requests` is absent rather than empty; verified against `requests: taskRequests`. The header regained a numeral four commits after round 17 rewrote it by category to stop exactly that, and it was already stale. Dropped, and named the third category — advertise-only-what-you-serve, which is about the capability object rather than a registration gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index fd3a994dc..0c0683408 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -54,13 +54,20 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * failure paths emit the change events immediately; `disconnect()` batches them * with its other teardown dispatches. * - * Two cases cover the other side of the registration gates. Client capabilities + * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually * advertised rather than the option it was derived from: a later `setRoots()` * must not make a subsequent `connect()` register a roots handler that was * never advertised, and an `elicit` option that enables no mode must not * register an elicitation handler. Either mistake throws before the handshake, * so the client cannot connect at all. + * + * The converse category is the advertised capability object itself: it must + * only invite requests we actually serve. `capabilities.tasks.requests` names + * the server→client requests we accept as tasks, so it is built from the + * sampling/elicitation capabilities rather than from `receiverTasks` alone — + * otherwise a server takes an invitation we answer `-32601` on, which is where + * this whole thread started. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -525,6 +532,23 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(capabilities.tasks?.requests?.elicitation).toBeUndefined(); expect(capabilities.tasks?.requests?.sampling).toBeDefined(); expect(capabilities.elicitation).toBeUndefined(); + + // Neither advertised: `requests` is omitted rather than sent empty, which + // would claim "I accept no task-augmented requests" instead of saying + // nothing. `tasks` itself stays — list/cancel are still serviceable. + const noRequests = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + receiverTasks: true, + sample: false, + elicit: false, + }, + ); + expect(noRequests.getClientCapabilities().tasks).toBeDefined(); + expect(noRequests.getClientCapabilities().tasks?.requests).toBeUndefined(); }); it("can reconnect after setRoots() on a client built without roots", async () => { From 8c2cdd919185675eabfd8c376c648de72115241b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:02:09 -0400 Subject: [PATCH 25/58] fix(core): settle queued samples on teardown instead of discarding them (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-24 review. Round 13 recorded the samples-vs-elicitations asymmetry as deliberate on two grounds: nothing internal awaits a sample, and the transport is gone by then. The first still holds; the second is false on one path, and it is a path this PR created. `connect()`'s catch drops the transport only when `!transportHasAuthProvider`, so on the HTTP+OAuth path — web's ordinary authenticated case — it is retained and the caller reconnects over it. A sampling request queued during the handshake was then discarded with the connection still live: no response frame ever written, and the server waits forever for a reply to a request we accepted. Accept-then-silence, which is worse for the peer than the -32601 this PR opened with. SamplingCreateMessage.cancel() mirrors the elicitation one — settles (rejects, what the UI's decline path already sends and the SDK turns into an error response) without calling onRemove, since the caller iterates the queue and clears it. clearPendingPeerRequests loops it like the elicitations. Also corrected two comments that assumed the catch always tore the transport down, and noted that the retention is gated on transportHasAuthProvider alone — independently of isConnectAuthRecoveryError, which gates only the status hold. Test asserts settlement rather than a wire frame, since on the disconnect() path the transport is closed before the settle; a later respond() throwing "already resolved or rejected" is the proof. Verified load-bearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 84 +++++++++++++++++++ core/mcp/inspectorClient.ts | 28 +++++-- core/mcp/samplingCreateMessage.ts | 28 +++++++ 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 0c0683408..9929717f1 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -292,6 +292,50 @@ function waitForNewPendingElicitation(client: InspectorClient): Promise { }); } +/** Connects cleanly, then samples on demand and records the client's reply. */ +class SampleAfterConnectTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + static readonly SAMPLE_ID = 9401; + + async start(): Promise {} + async close(): Promise {} + + sample(): void { + this.onmessage?.({ + jsonrpc: "2.0", + id: SampleAfterConnectTransport.SAMPLE_ID, + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "hi" } }], + maxTokens: 10, + }, + }); + } + + async send(message: JSONRPCMessage): Promise { + if ( + "method" in message && + message.method === "initialize" && + "id" in message + ) { + const params = message.params as { protocolVersion: string }; + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: {}, + serverInfo: { name: "sampling-server", version: "1.0.0" }, + }, + }); + return; + } + } +} + describe("InspectorClient peer-handler timing (#1797)", () => { it("serves server→client traffic that arrives with notifications/initialized", async () => { const roots = [{ uri: "file:///work", name: "Work" }]; @@ -551,6 +595,46 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(noRequests.getClientCapabilities().tasks?.requests).toBeUndefined(); }); + it("answers a queued sampling request when the connection is torn down", async () => { + // We accepted the server's `sampling/createMessage`, so dropping it without + // settling means no response frame is ever written and the server waits + // forever. Reachable because the transport can outlive a failed attempt — + // `connect()` keeps it when an auth provider holds it open. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + + await client.connect(); + const queued = new Promise((resolve) => + client.addEventListener("newPendingSample", () => resolve(), { + once: true, + }), + ); + transport.sample(); + await queued; + const [pending] = client.getPendingSamples(); + expect(pending).toBeDefined(); + + await client.disconnect(); + + expect(client.getPendingSamples()).toEqual([]); + // Settled, not merely discarded: `respond` refuses a request that already + // has an answer, so this throwing is the proof the promise was settled. + // (Whether the response frame reaches the wire depends on whether the + // transport outlived the teardown — on this path `disconnect()` closed it + // first, which is why the assertion is on the settle rather than on a + // recorded reply.) + await expect( + pending!.respond({ + role: "assistant", + content: { type: "text", text: "late" }, + model: "test", + }), + ).rejects.toThrow(/already resolved or rejected/); + }); + it("can reconnect after setRoots() on a client built without roots", async () => { // `setRoots()` makes `this.roots` defined on a client that never advertised // the capability. Gating the `roots/list` registration on that would throw diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 30cb4c83a..27729b3db 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1366,14 +1366,20 @@ export class InspectorClient extends InspectorClientEventTarget { /** * Settle and drop the queued peer requests (sampling / elicitation). * - * Each elicitation is cancelled before being dropped, so an error-path - * `awaitUrlElicitation` — which blocks `callTool` — doesn't hang forever when - * the queue goes away. Callers dispatch the change events themselves: + * Every entry is settled before being dropped rather than discarded: an + * elicitation so an error-path `awaitUrlElicitation` — which blocks + * `callTool` — doesn't hang forever, and a sample so the *server* gets a + * response frame for the request we accepted (the transport can outlive a + * failed attempt; see the `connect()` catch). Callers dispatch the change + * events themselves: * `disconnect()` batches them with its other teardown dispatches, and * {@link clearAndAnnouncePendingPeerRequests} emits them immediately for the * paths that end a connection without going through `disconnect()`. */ private clearPendingPeerRequests(): void { + for (const sample of this.pendingSamples) { + sample.cancel(); + } this.pendingSamples = []; for (const elicitation of this.pendingElicitations) { elicitation.cancel(); @@ -1383,8 +1389,11 @@ export class InspectorClient extends InspectorClientEventTarget { /** * {@link clearPendingPeerRequests} plus the change events, for the paths that - * end a connection without going through `disconnect()` — a failed - * `connect()` and a mid-session transport close. + * drop a queue without going through `disconnect()` — a failed `connect()` + * and a mid-session transport close. (The connect-failure case doesn't always + * end the connection: when an auth provider holds the transport open, the + * caller re-authenticates and retries over it, and what's dropped is the + * queue left by the attempt that failed.) * * The events are the load-bearing half: `usePendingClientRequests` tracks its * own state off them, so clearing the arrays without dispatching leaves the @@ -1714,8 +1723,13 @@ export class InspectorClient extends InspectorClientEventTarget { // server can queue a sampling/elicitation request during it — and this is // where that connect attempt dies. Drop the queue: otherwise the UI keeps // a live pending-request modal for a connection that never came up, and - // answering it would route to a torn-down transport. `disconnect()` does - // the same, but `connect()` only reaches it on the connect-timeout path. + // answering it would route to a transport that is either torn down or — + // when an auth provider holds it open, so the caller can re-authenticate + // and retry over it — carrying a queue from an attempt that already + // failed. Note the retention is gated on `transportHasAuthProvider` + // alone, independently of `isConnectAuthRecoveryError` above, which gates + // only the status hold. `disconnect()` does the same clearing, but + // `connect()` only reaches it on the connect-timeout path. this.clearAndAnnouncePendingPeerRequests(); // Deliberately do NOT dispatch the `error` event here: this is the // awaited `connect()` path, so re-throwing hands the reason straight to diff --git a/core/mcp/samplingCreateMessage.ts b/core/mcp/samplingCreateMessage.ts index 4f9539cbb..f92f1f665 100644 --- a/core/mcp/samplingCreateMessage.ts +++ b/core/mcp/samplingCreateMessage.ts @@ -86,6 +86,34 @@ export class SamplingCreateMessage { this.remove(); } + /** + * Settle a still-pending sample as cancelled, without removing it from the + * queue. Called from `InspectorClient`'s `clearPendingPeerRequests()`, which + * serves all three teardown paths — an explicit `disconnect()`, a failed + * `connect()`, and a mid-session transport close. + * + * Unlike an elicitation there is no internal awaiter to unblock, but the + * *server* is one: we accepted its `sampling/createMessage`, so dropping the + * request without settling means no response frame is ever written and it + * waits forever. That is reachable when the transport outlives the failed + * attempt — `connect()` keeps it when an auth provider holds it open — so + * settle rather than discard. Rejecting is the right settle: it is what the + * UI's decline path sends, and the SDK turns it into a JSON-RPC error + * response. No-op once already resolved. + * + * Deliberately does not call `onRemove`, for the same reason as + * `ElicitationCreateMessage.cancel()`: the caller iterates the queue and + * clears it itself, so removing here would splice mid-iteration — skipping + * every other entry and leaving those requests unanswered. + */ + cancel(): void { + if (this.rejectPromise) { + this.rejectPromise(new Error("Connection torn down")); + } + this.resolvePromise = undefined; + this.rejectPromise = undefined; + } + /** * Remove this pending sample from the list */ From 714f6fe004549564e959051ecde49cbb65d6f514 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:14:11 -0400 Subject: [PATCH 26/58] fix(core): mark receiver-task payload promises handled (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-25 review. clearPendingPeerRequests iterates every entry in pendingSamples, and two shapes live there: the plain server-request one, whose reject reaches the SDK handler, and the task-augmented one, whose reject reaches the receiver task's payloadPromise. That promise only gets a consumer when the server polls tasks/result, so a task sitting in input_required has no handler attached — and the settle added last commit surfaced as an unhandled rejection. Not hypothetical: the repo already worked around it test-side, with a helper that reaches into private state to attach no-op catches, whose comment describes this exact race. Fixed at the source instead — one `void payloadPromise.catch(() => {})` at creation, which also covers the pre-existing cancelReceiverTask path. Removed the now-redundant helper and its two call sites. Verified: dropping the source line makes that suite report two unhandled rejections. Corrected SamplingCreateMessage.cancel()'s doc — its "no response frame is ever written" rationale is true of the plain shape only; a task-augmented sample already answered the wire with a CreateTaskResult, so settling it is about not leaving the task in input_required limbo. Nits: generalized the enqueue wait helper to both events so the sampling wait gets the timeout the elicitation one has; moved the sampling teardown test back into the contiguous teardown group; named the settle property in the header; fixed the fixture doc that still claimed to record replies. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 107 +++++++++--------- .../inspectorClient-coverage-backfill.test.ts | 19 ---- core/mcp/inspectorClient.ts | 7 ++ core/mcp/samplingCreateMessage.ts | 23 ++-- 4 files changed, 76 insertions(+), 80 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 9929717f1..ef2501984 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -42,8 +42,9 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * The teardown cases cover the flip side of the same move. Registering the * handlers before the handshake also widened the window in which a server can * queue a request with us, so every path that ends a connection has to clear - * that queue and announce it — otherwise the web pending-request modal outlives - * the connection it belongs to. There are three: a failed `connect()`, a + * that queue, announce it, and settle each entry — otherwise the web + * pending-request modal outlives the connection it belongs to, and the server + * is left waiting on a request we accepted and never answered. There are three: a failed `connect()`, a * mid-session transport close, and an explicit `disconnect()`. `disconnect()` * and the crash path clear before dispatching `disconnect`, so a handler * reading the queue sees it empty. `connect()`'s own catch dispatches no @@ -269,20 +270,24 @@ class ElicitAfterConnectTransport implements Transport { } /** - * Resolve when the client enqueues an elicitation. Waits on the client's own - * signal rather than counting microtasks — the enqueue is one tick deep today - * with no margin, and an added await on the SDK's inbound path would flake it. - * Raced with a reject for the same reason `injectRequest` is: a regression that - * stops the enqueue should fail here, not hang to the vitest timeout. + * Resolve when the client enqueues a pending peer request. Waits on the + * client's own signal rather than counting microtasks — the enqueue is one tick + * deep today with no margin, and an added await on the SDK's inbound path would + * flake it. Raced with a reject for the same reason `injectRequest` is: a + * regression that stops the enqueue should fail here, not hang to the vitest + * timeout. */ -function waitForNewPendingElicitation(client: InspectorClient): Promise { +function waitForNewPendingRequest( + client: InspectorClient, + event: "newPendingElicitation" | "newPendingSample", +): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( - () => reject(new Error("No newPendingElicitation after elicit()")), + () => reject(new Error(`No ${event} after the request was delivered`)), 1000, ); client.addEventListener( - "newPendingElicitation", + event, () => { clearTimeout(timer); resolve(); @@ -292,7 +297,7 @@ function waitForNewPendingElicitation(client: InspectorClient): Promise { }); } -/** Connects cleanly, then samples on demand and records the client's reply. */ +/** Connects cleanly, then samples on demand so a test can tear the client down. */ class SampleAfterConnectTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; onclose?: () => void; @@ -485,7 +490,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { }); await client.connect(); - const queued = waitForNewPendingElicitation(client); + const queued = waitForNewPendingRequest(client, "newPendingElicitation"); transport.elicit(); await queued; expect(client.getPendingElicitations()).toHaveLength(1); @@ -497,6 +502,42 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(elicitationCounts.at(-1)).toBe(0); }); + it("answers a queued sampling request when the connection is torn down", async () => { + // We accepted the server's `sampling/createMessage`, so dropping it without + // settling means no response frame is ever written and the server waits + // forever. Reachable because the transport can outlive a failed attempt — + // `connect()` keeps it when an auth provider holds it open. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + + await client.connect(); + const queued = waitForNewPendingRequest(client, "newPendingSample"); + transport.sample(); + await queued; + const [pending] = client.getPendingSamples(); + expect(pending).toBeDefined(); + + await client.disconnect(); + + expect(client.getPendingSamples()).toEqual([]); + // Settled, not merely discarded: `respond` refuses a request that already + // has an answer, so this throwing is the proof the promise was settled. + // (Whether the response frame reaches the wire depends on whether the + // transport outlived the teardown — on this path `disconnect()` closed it + // first, which is why the assertion is on the settle rather than on a + // recorded reply.) + await expect( + pending!.respond({ + role: "assistant", + content: { type: "text", text: "late" }, + model: "test", + }), + ).rejects.toThrow(/already resolved or rejected/); + }); + it("has already cleared the queue by the time `disconnect` fires", async () => { // `disconnect()` clears above its status block so a consumer handling the // event sees an empty queue, matching the crash path. That ordering is the @@ -510,7 +551,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { ); await client.connect(); - const queued = waitForNewPendingElicitation(client); + const queued = waitForNewPendingRequest(client, "newPendingElicitation"); transport.elicit(); await queued; expect(client.getPendingElicitations()).toHaveLength(1); @@ -595,46 +636,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(noRequests.getClientCapabilities().tasks?.requests).toBeUndefined(); }); - it("answers a queued sampling request when the connection is torn down", async () => { - // We accepted the server's `sampling/createMessage`, so dropping it without - // settling means no response frame is ever written and the server waits - // forever. Reachable because the transport can outlive a failed attempt — - // `connect()` keeps it when an auth provider holds it open. - const transport = new SampleAfterConnectTransport(); - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, - ); - - await client.connect(); - const queued = new Promise((resolve) => - client.addEventListener("newPendingSample", () => resolve(), { - once: true, - }), - ); - transport.sample(); - await queued; - const [pending] = client.getPendingSamples(); - expect(pending).toBeDefined(); - - await client.disconnect(); - - expect(client.getPendingSamples()).toEqual([]); - // Settled, not merely discarded: `respond` refuses a request that already - // has an answer, so this throwing is the proof the promise was settled. - // (Whether the response frame reaches the wire depends on whether the - // transport outlived the teardown — on this path `disconnect()` closed it - // first, which is why the assertion is on the settle rather than on a - // recorded reply.) - await expect( - pending!.respond({ - role: "assistant", - content: { type: "text", text: "late" }, - model: "test", - }), - ).rejects.toThrow(/already resolved or rejected/); - }); - it("can reconnect after setRoots() on a client built without roots", async () => { // `setRoots()` makes `this.roots` defined on a client that never advertised // the capability. Gating the `roots/list` registration on that would throw diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index edef1d63a..53622610f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -57,23 +57,6 @@ async function getTool(client: InspectorClient, name: string): Promise { return tool; } -/** - * Attach a no-op catch to every outstanding receiver-task payload promise so a - * deliberate reject (failure-path test) doesn't bubble up as an unhandled - * rejection. The real consumer (the server polling tasks/result) handles the - * rejection, but it may not have a handler attached at the instant we reject. - */ -function suppressReceiverPayloadRejections(client: InspectorClient): void { - const records = ( - client as unknown as { - receiverTaskRecords: Map }>; - } - ).receiverTaskRecords; - for (const record of records.values()) { - record.payloadPromise.catch(() => {}); - } -} - describe("InspectorClient coverage backfill", () => { let client: InspectorClient | null = null; let server: TestServerHttp | null = null; @@ -328,7 +311,6 @@ describe("InspectorClient coverage backfill", () => { const sample = await samplingPromise; // Pre-attach a catch to the receiver task's payload promise so its // rejection (driven below) doesn't surface as an unhandled rejection. - suppressReceiverPayloadRejections(client); // Reject instead of respond — drives the receiver-task error callback, // which sets status "failed" and calls upsertReceiverTask. await sample.reject(new Error("user rejected sampling")); @@ -377,7 +359,6 @@ describe("InspectorClient coverage backfill", () => { .catch((e: unknown) => e); const elicitation = await elicitationPromise; - suppressReceiverPayloadRejections(client); await elicitation.reject(new Error("user declined elicitation")); const outcome = await callPromise; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 27729b3db..6aea41ad2 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1006,6 +1006,13 @@ export class InspectorClient extends InspectorClientEventTarget { resolvePayload = resolve; rejectPayload = reject; }); + // Mark it handled. The real consumer is the server polling `tasks/result` + // (`getReceiverTaskPayload` returns this same promise, so a real awaiter + // still sees the rejection), but nothing has attached a handler while the + // task sits in `input_required` — and it can be rejected from there, by an + // explicit `tasks/cancel` or by teardown settling a queued sample. Without + // this, that reject surfaces as an unhandled rejection. + void payloadPromise.catch(() => {}); const record: ReceiverTaskRecord = { task, payloadPromise, diff --git a/core/mcp/samplingCreateMessage.ts b/core/mcp/samplingCreateMessage.ts index f92f1f665..9bb8f84e7 100644 --- a/core/mcp/samplingCreateMessage.ts +++ b/core/mcp/samplingCreateMessage.ts @@ -92,14 +92,21 @@ export class SamplingCreateMessage { * serves all three teardown paths — an explicit `disconnect()`, a failed * `connect()`, and a mid-session transport close. * - * Unlike an elicitation there is no internal awaiter to unblock, but the - * *server* is one: we accepted its `sampling/createMessage`, so dropping the - * request without settling means no response frame is ever written and it - * waits forever. That is reachable when the transport outlives the failed - * attempt — `connect()` keeps it when an auth provider holds it open — so - * settle rather than discard. Rejecting is the right settle: it is what the - * UI's decline path sends, and the SDK turns it into a JSON-RPC error - * response. No-op once already resolved. + * Unlike an elicitation there is no internal awaiter to unblock, but on the + * plain server-request shape the *server* is one: we accepted its + * `sampling/createMessage`, so dropping the request without settling means no + * response frame is ever written and it waits forever. That is reachable when + * the transport outlives the failed attempt — `connect()` keeps it when an + * auth provider holds it open — so settle rather than discard. Rejecting is + * the right settle: it is what the UI's decline path sends, and the SDK turns + * it into a JSON-RPC error response. No-op once already resolved. + * + * A *task-augmented* sample sits in the same queue but already answered the + * wire request synchronously with a `CreateTaskResult`, so nothing is waiting + * on a frame; settling there is about not leaving the task in + * `input_required` limbo. Its reject reaches the receiver task's payload + * promise, which `InspectorClient` marks handled at creation for exactly this + * reason. * * Deliberately does not call `onRemove`, for the same reason as * `ElicitationCreateMessage.cancel()`: the caller iterates the queue and From e2621ae21ccdb44d40a8725f9af55aea2b988718 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:25:35 -0400 Subject: [PATCH 27/58] =?UTF-8?q?review:=20round-26=20=E2=80=94=20drop=20t?= =?UTF-8?q?he=20comment=20that=20outlived=20its=20call=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suppressReceiverPayloadRejections call went last commit; its two-line comment stayed, describing a pre-attach that no longer happens and crediting the test for handling a rejection the source now handles. Removed. Also reworded the surviving .catch() in inspectorClient.test.ts: it is still needed, but as a value capture for the assertion, not for unhandled-rejection suppression. Rewrapped the teardown paragraph in the test header — my round-25 edit left a 104-char line, and two hand-fixes left it ragged, so it is reflowed in one pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 20 +++++++++---------- .../inspectorClient-coverage-backfill.test.ts | 2 -- .../integration/mcp/inspectorClient.test.ts | 4 +++- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index ef2501984..c9dcbec97 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -44,16 +44,16 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * queue a request with us, so every path that ends a connection has to clear * that queue, announce it, and settle each entry — otherwise the web * pending-request modal outlives the connection it belongs to, and the server - * is left waiting on a request we accepted and never answered. There are three: a failed `connect()`, a - * mid-session transport close, and an explicit `disconnect()`. `disconnect()` - * and the crash path clear before dispatching `disconnect`, so a handler - * reading the queue sees it empty. `connect()`'s own catch dispatches no - * `disconnect` — though on a real transport its `dropCachedTransport()` usually - * fires `onclose` first, which clears, announces and *does* dispatch one, so - * the catch's own call is really the backstop for the auth-recovery sub-case, - * where the transport is retained and no `onclose` fires. The crash and - * failure paths emit the change events immediately; `disconnect()` batches them - * with its other teardown dispatches. + * is left waiting on a request we accepted and never answered. There are three: a + * failed `connect()`, a mid-session transport close, and an explicit + * `disconnect()`. `disconnect()` and the crash path clear before dispatching + * `disconnect`, so a handler reading the queue sees it empty. `connect()`'s + * own catch dispatches no `disconnect` — though on a real transport its + * `dropCachedTransport()` usually fires `onclose` first, which clears, + * announces and *does* dispatch one, so the catch's own call is really the + * backstop for the auth-recovery sub-case, where the transport is retained and + * no `onclose` fires. The crash and failure paths emit the change events + * immediately; `disconnect()` batches them with its other teardown dispatches. * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index 53622610f..d873453c1 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -309,8 +309,6 @@ describe("InspectorClient coverage backfill", () => { .catch((e: unknown) => e); const sample = await samplingPromise; - // Pre-attach a catch to the receiver task's payload promise so its - // rejection (driven below) doesn't surface as an unhandled rejection. // Reject instead of respond — drives the receiver-task error callback, // which sets status "failed" and calls upsertReceiverTask. await sample.reject(new Error("user rejected sampling")); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 13c57f75c..7937b219a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -5560,7 +5560,9 @@ describe("InspectorClient", () => { initialStatus: "working", statusMessage: "running", }); - // Pre-attach a catch so cancel's reject doesn't surface as unhandled + // Capture the rejection's message for the assertion below. (Not for + // unhandled-rejection suppression — `createReceiverTask` marks the + // promise handled at the source.) const payloadResult = record.payloadPromise.catch( (e) => (e as Error).message, ); From aba9919416b55c8ca1aca710e7bc60033d7fad60 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:38:47 -0400 Subject: [PATCH 28/58] =?UTF-8?q?review:=20round-27=20=E2=80=94=20skip=20t?= =?UTF-8?q?he=20roots/list=5Fchanged=20the=20SDK=20would=20refuse=20(#1797?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review reported setRoots() emitting roots/list_changed on a client that never advertised roots, inviting a re-fetch we'd answer -32601. Probed it: the premise is wrong. The SDK asserts the notification capability too — `notification()` rejects with "Client does not support roots list changed notifications" and nothing reaches the wire. No spec violation and no invitation; what actually happened is that setRoots provoked that rejection and logged it as "Failed to send", implying a transient failure where the truth is structural. Kept the early return on that corrected basis: it stops us provoking a rejection we know will come, and warns accurately instead. Doc and comment say the SDK is the enforcer rather than claiming this guard is what keeps the wire clean. The test pins the end state (no invitation for roots we can't serve) and passes either way — recorded as such rather than presented as a regression guard, since it isn't one. Also moved the advertise-category test below the two gate cases, matching the order the header introduces them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 97 +++++++++++++------ core/mcp/inspectorClient.ts | 27 ++++-- 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index c9dcbec97..868544e51 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -231,6 +231,9 @@ class ElicitAfterConnectTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; + /** Methods of every client→server notification sent, in order. */ + readonly sentNotifications: string[] = []; + async start(): Promise {} async close(): Promise {} @@ -250,6 +253,10 @@ class ElicitAfterConnectTransport implements Transport { } async send(message: JSONRPCMessage): Promise { + if ("method" in message && !("id" in message)) { + this.sentNotifications.push(message.method); + return; + } if ( "method" in message && message.method === "initialize" && @@ -596,6 +603,69 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("announces roots/list_changed only when roots were advertised", async () => { + // Pins the end state rather than this client's guard: the SDK also refuses + // the notification from a client that never declared `roots.listChanged` + // (it rejects, which `setRoots` used to log as a send *failure*), so the + // wire stays clean either way. What this asserts is that a server is never + // invited to re-read roots we have no handler to serve. + const withRoots = new ElicitAfterConnectTransport(); + const advertised = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport: withRoots }) }, + roots: [], + }, + ); + await advertised.connect(); + await advertised.setRoots([{ uri: "file:///a" }]); + expect(withRoots.sentNotifications).toContain( + "notifications/roots/list_changed", + ); + await advertised.disconnect(); + + const withoutRoots = new ElicitAfterConnectTransport(); + const silent = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport: withoutRoots }) } }, + ); + await silent.connect(); + await silent.setRoots([{ uri: "file:///a" }]); + expect(withoutRoots.sentNotifications).not.toContain( + "notifications/roots/list_changed", + ); + // Still stored locally — only the announcement is withheld. + expect(silent.getRoots()).toEqual([{ uri: "file:///a" }]); + await silent.disconnect(); + }); + + it("can reconnect after setRoots() on a client built without roots", async () => { + // `setRoots()` makes `this.roots` defined on a client that never advertised + // the capability. Gating the `roots/list` registration on that would throw + // "Client does not support roots capability" from `setRequestHandler` on + // every later connect() — before the handshake, so the client could never + // reconnect. The gate reads what was advertised at construction instead. + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => ({ transport: new ElicitAfterConnectTransport() }), + }, + }, + ); + + await client.connect(); + await client.setRoots([{ uri: "file:///late" }]); + await client.disconnect(); + + await expect(client.connect()).resolves.toBeUndefined(); + // Stored and readable, but no server can ask for them — the capability was + // never advertised, so no handler is registered. + expect(client.getRoots()).toEqual([{ uri: "file:///late" }]); + + await client.disconnect(); + }); + it("advertises task requests only for capabilities it advertised", async () => { // `capabilities.tasks.requests` tells the server which server→client // requests we accept as tasks. Built from `receiverTasks` alone it would @@ -635,31 +705,4 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(noRequests.getClientCapabilities().tasks).toBeDefined(); expect(noRequests.getClientCapabilities().tasks?.requests).toBeUndefined(); }); - - it("can reconnect after setRoots() on a client built without roots", async () => { - // `setRoots()` makes `this.roots` defined on a client that never advertised - // the capability. Gating the `roots/list` registration on that would throw - // "Client does not support roots capability" from `setRequestHandler` on - // every later connect() — before the handshake, so the client could never - // reconnect. The gate reads what was advertised at construction instead. - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { - environment: { - transport: () => ({ transport: new ElicitAfterConnectTransport() }), - }, - }, - ); - - await client.connect(); - await client.setRoots([{ uri: "file:///late" }]); - await client.disconnect(); - - await expect(client.connect()).resolves.toBeUndefined(); - // Stored and readable, but no server can ask for them — the capability was - // never advertised, so no handler is registered. - expect(client.getRoots()).toEqual([{ uri: "file:///late" }]); - - await client.disconnect(); - }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 6aea41ad2..b6d9ed2eb 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4388,10 +4388,12 @@ export class InspectorClient extends InspectorClientEventTarget { * negotiated at `initialize` and the * SDK refuses `registerCapabilities` after connect, so such a client has no * `roots/list` handler (see {@link registerPeerRequestHandlers}) and would - * answer `-32601` to a server taking up the `roots/list_changed` invitation - * below — the roots set here are stored and readable via {@link getRoots}, - * but no server can ask for them. Pass `roots` at construction — `[]` is - * enough — in any client that may call this (#1797). + * have to answer `-32601` if a server asked. So on such a client the roots + * set here are stored and readable via {@link getRoots}, but no server can + * ask for them and the change is not announced — the SDK refuses + * `roots/list_changed` from a client that never declared `roots.listChanged`, + * so the notification could not have gone out anyway. Pass `roots` at construction — `[]` is enough — in any + * client that may call this (#1797). * * The argument runs through `cleanRoots`, the same normalizer the * connect-time and settings-save paths use, so all three ways roots enter the @@ -4409,8 +4411,21 @@ export class InspectorClient extends InspectorClientEventTarget { // list we advertise. this.dispatchTypedEvent("rootsChange", [...this.roots]); - // Send notification to server - clients can send this notification to any server - // The server doesn't need to advertise support for it + // The *server* needn't advertise support for this notification, but the + // *client* must have declared `roots.listChanged` to send it. The SDK + // enforces that itself — `notification()` rejects with "Client does not + // support roots list changed notifications" — so nothing reaches the wire + // on a client built without `roots`, and the server is never invited to + // re-fetch something we'd answer `-32601`. Returning early only avoids + // provoking that rejection and logging it as a *failure*: it isn't one, it + // is a client that was never able to announce (#1797). + if (!this.rootsCapabilityAdvertised) { + this.logger.warn( + "setRoots() on a client that did not advertise the roots capability; " + + "roots are stored locally but the change is not announced", + ); + return; + } try { await this.client.notification({ method: "notifications/roots/list_changed", From 769e4a13cbcf5bcd993c953e1134c9c41320c315 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:51:17 -0400 Subject: [PATCH 29/58] =?UTF-8?q?review:=20round-28=20=E2=80=94=20gate=20t?= =?UTF-8?q?he=20notification=20on=20the=20predicate=20the=20SDK=20asserts?= =?UTF-8?q?=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roots/list_changed guard read `capabilities.roots !== undefined`, but the SDK asserts `capabilities.roots.listChanged` for the notification — wider than the flag. Identical today (roots are only ever advertised as `{ listChanged: true }`), but a conditional advertisement would un-gate it silently, which is the round-21 shape again. Split into rootsListChangedCapabilityAdvertised, mirroring urlElicitationCapabilityAdvertised, which already reads its assertion's sub-field; rootsCapabilityAdvertised keeps the roots/list registration, where presence is what setRequestHandler checks. Also moved the new test out of the gate pair it had split — it is a runtime-emission case, not a registration-gate one — and extended the header's converse-category paragraph to cover notifications, which indexes it without inventing a category. Reflowed the setRoots doc paragraph in one pass (111-char line from the last commit, plus a ragged orphan from an earlier edit). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 85 ++++++++++--------- core/mcp/inspectorClient.ts | 32 ++++--- 2 files changed, 66 insertions(+), 51 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 868544e51..1551ecec4 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -64,11 +64,14 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * so the client cannot connect at all. * * The converse category is the advertised capability object itself: it must - * only invite requests we actually serve. `capabilities.tasks.requests` names - * the server→client requests we accept as tasks, so it is built from the - * sampling/elicitation capabilities rather than from `receiverTasks` alone — - * otherwise a server takes an invitation we answer `-32601` on, which is where - * this whole thread started. + * only invite requests we actually serve, and so must the notifications we + * emit. `capabilities.tasks.requests` names the server→client requests we + * accept as tasks, so it is built from the sampling/elicitation capabilities + * rather than from `receiverTasks` alone; and a `roots/list_changed` is an + * invitation to re-read roots, so it is withheld on a client that never + * advertised the capability (the SDK refuses it too — the guard only avoids + * provoking that rejection). Otherwise a server takes an invitation we answer + * `-32601` on, which is where this whole thread started. */ class InitializedRacingTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; @@ -603,42 +606,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - it("announces roots/list_changed only when roots were advertised", async () => { - // Pins the end state rather than this client's guard: the SDK also refuses - // the notification from a client that never declared `roots.listChanged` - // (it rejects, which `setRoots` used to log as a send *failure*), so the - // wire stays clean either way. What this asserts is that a server is never - // invited to re-read roots we have no handler to serve. - const withRoots = new ElicitAfterConnectTransport(); - const advertised = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { - environment: { transport: () => ({ transport: withRoots }) }, - roots: [], - }, - ); - await advertised.connect(); - await advertised.setRoots([{ uri: "file:///a" }]); - expect(withRoots.sentNotifications).toContain( - "notifications/roots/list_changed", - ); - await advertised.disconnect(); - - const withoutRoots = new ElicitAfterConnectTransport(); - const silent = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport: withoutRoots }) } }, - ); - await silent.connect(); - await silent.setRoots([{ uri: "file:///a" }]); - expect(withoutRoots.sentNotifications).not.toContain( - "notifications/roots/list_changed", - ); - // Still stored locally — only the announcement is withheld. - expect(silent.getRoots()).toEqual([{ uri: "file:///a" }]); - await silent.disconnect(); - }); - it("can reconnect after setRoots() on a client built without roots", async () => { // `setRoots()` makes `this.roots` defined on a client that never advertised // the capability. Gating the `roots/list` registration on that would throw @@ -705,4 +672,40 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(noRequests.getClientCapabilities().tasks).toBeDefined(); expect(noRequests.getClientCapabilities().tasks?.requests).toBeUndefined(); }); + + it("announces roots/list_changed only when roots were advertised", async () => { + // Pins the end state rather than this client's guard: the SDK also refuses + // the notification from a client that never declared `roots.listChanged` + // (it rejects, which `setRoots` used to log as a send *failure*), so the + // wire stays clean either way. What this asserts is that a server is never + // invited to re-read roots we have no handler to serve. + const withRoots = new ElicitAfterConnectTransport(); + const advertised = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport: withRoots }) }, + roots: [], + }, + ); + await advertised.connect(); + await advertised.setRoots([{ uri: "file:///a" }]); + expect(withRoots.sentNotifications).toContain( + "notifications/roots/list_changed", + ); + await advertised.disconnect(); + + const withoutRoots = new ElicitAfterConnectTransport(); + const silent = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport: withoutRoots }) } }, + ); + await silent.connect(); + await silent.setRoots([{ uri: "file:///a" }]); + expect(withoutRoots.sentNotifications).not.toContain( + "notifications/roots/list_changed", + ); + // Still stored locally — only the announcement is withheld. + expect(silent.getRoots()).toEqual([{ uri: "file:///a" }]); + await silent.disconnect(); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b6d9ed2eb..24c32730e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -394,6 +394,16 @@ export class InspectorClient extends InspectorClientEventTarget { private readonly tasksCapabilityAdvertised: boolean; /** As above, for `capabilities.elicitation.url` (the URL-mode completion). */ private readonly urlElicitationCapabilityAdvertised: boolean; + /** + * As above, for `capabilities.roots.listChanged` — the predicate the SDK + * asserts before letting us *send* `roots/list_changed`, which is narrower + * than the `capabilities.roots` presence it asserts before letting us + * *register* `roots/list`. They coincide today (roots are only ever + * advertised as `{ listChanged: true }`), but reading each assertion's own + * predicate is what keeps a future conditional advertisement from silently + * un-gating one of them. + */ + private readonly rootsListChangedCapabilityAdvertised: boolean; // Content cache // ListChanged notification configuration private listChangedNotifications: { @@ -678,6 +688,8 @@ export class InspectorClient extends InspectorClientEventTarget { this.tasksCapabilityAdvertised = capabilities.tasks !== undefined; this.urlElicitationCapabilityAdvertised = capabilities.elicitation?.url !== undefined; + this.rootsListChangedCapabilityAdvertised = + capabilities.roots?.listChanged === true; this.appRendererClientProxy = null; this.clientInfo = options.clientIdentity ?? { @@ -4385,15 +4397,15 @@ export class InspectorClient extends InspectorClientEventTarget { * * Note this does **not** enable the roots capability on a client that was * built without the constructor's `roots` option. `capabilities.roots` is - * negotiated at `initialize` and the - * SDK refuses `registerCapabilities` after connect, so such a client has no - * `roots/list` handler (see {@link registerPeerRequestHandlers}) and would - * have to answer `-32601` if a server asked. So on such a client the roots - * set here are stored and readable via {@link getRoots}, but no server can - * ask for them and the change is not announced — the SDK refuses - * `roots/list_changed` from a client that never declared `roots.listChanged`, - * so the notification could not have gone out anyway. Pass `roots` at construction — `[]` is enough — in any - * client that may call this (#1797). + * negotiated at `initialize` and the SDK refuses `registerCapabilities` + * after connect, so such a client has no `roots/list` handler (see {@link + * registerPeerRequestHandlers}) and would have to answer `-32601` if a + * server asked. So on such a client the roots set here are stored and + * readable via {@link getRoots}, but no server can ask for them and the + * change is not announced — the SDK refuses `roots/list_changed` from a + * client that never declared `roots.listChanged`, so the notification could + * not have gone out anyway. Pass `roots` at construction — `[]` is enough — + * in any client that may call this (#1797). * * The argument runs through `cleanRoots`, the same normalizer the * connect-time and settings-save paths use, so all three ways roots enter the @@ -4419,7 +4431,7 @@ export class InspectorClient extends InspectorClientEventTarget { // re-fetch something we'd answer `-32601`. Returning early only avoids // provoking that rejection and logging it as a *failure*: it isn't one, it // is a client that was never able to announce (#1797). - if (!this.rootsCapabilityAdvertised) { + if (!this.rootsListChangedCapabilityAdvertised) { this.logger.warn( "setRoots() on a client that did not advertise the roots capability; " + "roots are stored locally but the change is not announced", From f0f07233f9323d29c5404e653422eb7484c8c580 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:03:15 -0400 Subject: [PATCH 30/58] =?UTF-8?q?review:=20round-29=20=E2=80=94=20chase=20?= =?UTF-8?q?the=20corrected=20premise=20into=20clients/cli=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 27 corrected "setRoots announces roots/list_changed to a server that then gets -32601" — the SDK refuses the notification from a client that never declared it, so nothing reached the wire. That reword happened in core/, but the same premise had been written into the two comments justifying the CLI's roots seed, and they still asserted it. Both now state the real pre-fix failure: a server asking for roots on its own got -32601, and roots/set could not announce at all. Also fixed the warn message under the round-28 gate: it named the roots capability while the gate is on roots.listChanged — precisely the pair that split was made to distinguish, so a client advertising `{}` would have been told it advertised nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/cli/__tests__/cli.test.ts | 9 +++++---- clients/cli/src/cli.ts | 8 +++++--- core/mcp/inspectorClient.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index 5757741f3..832cb58a5 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -330,10 +330,11 @@ describe("CLI Tests", () => { it("answers a server's roots/list instead of -32601", async () => { // The CLI used to omit `roots` when constructing its InspectorClient, so // the capability was never advertised and no `roots/list` handler was - // registered — a server that asked got -32601 Method not found, and - // `--method roots/set` announced `roots/list_changed` to a server it - // would then refuse to answer. `cli.ts` now always passes the `roots` - // option — empty on this ad-hoc path, since there is no config file. + // registered — a server that asked got -32601 Method not found. (And + // `--method roots/set` could not announce the change: the SDK refuses + // `roots/list_changed` from a client that never declared it, so nothing + // reached the wire.) `cli.ts` now always passes the `roots` option — + // empty on this ad-hoc path, since there is no config file. const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createListRootsTool()], diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 17bc6c620..87dbceb10 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -156,9 +156,11 @@ async function callMethod( // Advertise the roots configured for this server in mcp.json, exactly as // web does (`App.tsx`) so both answer `roots/list` with the same content. // Passing the option (even empty) is what negotiates `capabilities.roots` - // at `initialize` and registers the `roots/list` handler — omitting it left - // `--method roots/set` announcing `roots/list_changed` to a server that - // then got -32601 back (#1797). + // at `initialize` and registers the `roots/list` handler. Omitting it meant + // a server that asks for roots on its own — `server-filesystem` does, at + // `initialize` — got -32601, and `--method roots/set` could not announce + // the change at all: the SDK refuses `roots/list_changed` from a client + // that never declared it, which `setRoots` logged as a send failure (#1797). roots: cleanRoots(serverSettings?.roots ?? []), serverSettings, // Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation. diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 24c32730e..964c38e0b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4433,7 +4433,7 @@ export class InspectorClient extends InspectorClientEventTarget { // is a client that was never able to announce (#1797). if (!this.rootsListChangedCapabilityAdvertised) { this.logger.warn( - "setRoots() on a client that did not advertise the roots capability; " + + "setRoots() on a client that did not advertise `roots.listChanged`; " + "roots are stored locally but the change is not announced", ); return; From 5700efc67c1903f3993ef2b8481a8dcb0e3361a4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:15:34 -0400 Subject: [PATCH 31/58] fix(core): reject in-flight raw-wire requests on a mid-session crash (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rejectPendingRawWireRequests had one caller — disconnect() — so the crash path settled the inbound peer-request queue this PR added and left the outbound raw-wire map alone. That map holds the modern tasks/* frames the SDK's era gate refuses to route, so the SDK's chained _onclose doesn't know about them: a Tasks-tab poll in flight when the server died waited out its own 30s timeout and reported a timeout for what was a crash, on a connection that had already dispatched `disconnect`. Pre-existing — both the map and the crash path predate this PR. Fixing it here because the invariant it violates is one this PR wrote down: "every path that ends a connection has to clear that queue, announce it, and settle each entry", in clearAndAnnouncePendingPeerRequests' doc and the timing test's header. One channel was true of one path out of three. Test starts a raw-wire request and fires onclose; without the call it hangs to the vitest timeout, which is the symptom. Checked the two siblings and left both: taskInputAbortControllers is already unblocked via the elicitation cancel the crash path performs, and activeToolCallAbortController rides an ordinary client.request the SDK settles itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 31 +++++++++++++++++++ core/mcp/inspectorClient.ts | 8 +++++ 2 files changed, 39 insertions(+) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 1551ecec4..402932ce0 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -5,6 +5,7 @@ import type { Transport, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; /** * Regression coverage for #1797: a server may talk to us the instant it is @@ -633,6 +634,36 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("rejects an in-flight raw-wire request when the connection dies", async () => { + // The modern `tasks/*` frames ride a raw-wire channel the SDK's era gate + // refuses to route, so the SDK's own teardown doesn't know about them. A + // Tasks-tab poll in flight when the server dies would otherwise wait out + // its own 30s timeout and report a timeout for what was a crash. + const transport = new ElicitAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + const pending = ( + client as unknown as { + rawWireRequest: ( + method: string, + params: Record, + schema: { parse: (v: unknown) => unknown }, + ) => Promise; + } + ).rawWireRequest("tasks/get", { taskId: "t1" }, ModernGetTaskResultSchema); + const settled = expect(pending).rejects.toThrow(/Connection closed/); + // Let the send register the pending entry before the crash. + await Promise.resolve(); + + transport.onclose?.(); + + await settled; + }); + it("advertises task requests only for capabilities it advertised", async () => { // `capabilities.tasks.requests` tells the server which server→client // requests we accept as tasks. Built from `receiverTasks` alone it would diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 964c38e0b..a1b592a7b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -793,6 +793,14 @@ export class InspectorClient extends InspectorClientEventTarget { // change events with the rest of its teardown dispatches — so on that // path the *events* land just after its `disconnect`, not before. this.clearAndAnnouncePendingPeerRequests(); + // Same for what *we* asked the server. The SDK's chained `_onclose` + // settles its own `_responseHandlers`, but the raw-wire map (the modern + // `tasks/*` frames its era gate refuses to route) is ours and it doesn't + // know about it — so a Tasks-tab poll in flight when the server dies + // would otherwise wait out its own 30s timeout and blame the timeout for + // a crash. Rejecting a settled promise is a no-op and the helper clears + // the map, so this can't double-settle with `disconnect()`. + this.rejectPendingRawWireRequests("Connection closed"); this.dispatchTypedEvent("disconnect"); }; baseTransport.onerror = (error: Error) => { From d1044365f2bd8845f72f7eb527ab8932d00c2e2d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:28:23 -0400 Subject: [PATCH 32/58] =?UTF-8?q?review:=20round-31=20test=20fixes=20?= =?UTF-8?q?=E2=80=94=20justify=20the=20cast,=20drop=20the=20no-op=20tick?= =?UTF-8?q?=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The new test's `as unknown as` had no justification, which AGENTS.md explicitly prohibits. It is unavoidable — rawWireRequest is private and every public route is gated on isTasksExtensionNegotiated(), which needs a modern handshake the fixture doesn't perform — so that reasoning is now written down instead of left to be re-derived. - `await Promise.resolve()` was a no-op: rawWireRequest registers its entry in the promise executor, synchronously, before any suspension. Dropped, which also makes the test pin that ordering rather than paper over it — and stops the third recurrence of the microtask-counting pattern this file's own helper doc argues against. - Moved the test into the teardown group and extended the header's teardown paragraph to cover the outbound direction, which it framed entirely as inbound. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 402932ce0..f517cd9a6 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -45,7 +45,12 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * queue a request with us, so every path that ends a connection has to clear * that queue, announce it, and settle each entry — otherwise the web * pending-request modal outlives the connection it belongs to, and the server - * is left waiting on a request we accepted and never answered. There are three: a + * is left waiting on a request we accepted and never answered. The outbound + * direction needs the same: the raw-wire modern `tasks/*` map is ours — the + * SDK's era gate keeps those frames out of its own `_responseHandlers`, so its + * teardown can't settle them — and a Tasks-tab poll in flight when the server + * dies would otherwise wait out its own 30s timeout and blame the timeout for + * a crash. There are three: a * failed `connect()`, a mid-session transport close, and an explicit * `disconnect()`. `disconnect()` and the crash path clear before dispatching * `disconnect`, so a handler reading the queue sees it empty. `connect()`'s @@ -587,6 +592,44 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(elicitationCounts.at(-1)).toBe(0); }); + it("rejects an in-flight raw-wire request when the connection dies", async () => { + // The modern `tasks/*` frames ride a raw-wire channel the SDK's era gate + // refuses to route, so the SDK's own teardown doesn't know about them. A + // Tasks-tab poll in flight when the server dies would otherwise wait out + // its own 30s timeout and report a timeout for what was a crash. + const transport = new ElicitAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // `rawWireRequest` is private, and every public route to it + // (`getRequestorTask` / `updateRequestorTask` / `cancelRequestorTask`) is + // gated on `isTasksExtensionNegotiated()`, which needs a modern-era + // handshake this fixture doesn't perform — so driving the channel directly + // is the only way to exercise it. The cast asserts only the method's real + // signature. + const pending = ( + client as unknown as { + rawWireRequest: ( + method: string, + params: Record, + schema: { parse: (v: unknown) => unknown }, + ) => Promise; + } + ).rawWireRequest("tasks/get", { taskId: "t1" }, ModernGetTaskResultSchema); + // Attach the rejection handler before the crash so it is never unhandled. + // No tick in between: `rawWireRequest` registers its pending entry + // synchronously (the promise executor runs during the call), so the entry + // is already there — asserting that ordering rather than papering over it. + const settled = expect(pending).rejects.toThrow(/Connection closed/); + + transport.onclose?.(); + + await settled; + }); + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` @@ -634,36 +677,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - it("rejects an in-flight raw-wire request when the connection dies", async () => { - // The modern `tasks/*` frames ride a raw-wire channel the SDK's era gate - // refuses to route, so the SDK's own teardown doesn't know about them. A - // Tasks-tab poll in flight when the server dies would otherwise wait out - // its own 30s timeout and report a timeout for what was a crash. - const transport = new ElicitAfterConnectTransport(); - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, - ); - await client.connect(); - - const pending = ( - client as unknown as { - rawWireRequest: ( - method: string, - params: Record, - schema: { parse: (v: unknown) => unknown }, - ) => Promise; - } - ).rawWireRequest("tasks/get", { taskId: "t1" }, ModernGetTaskResultSchema); - const settled = expect(pending).rejects.toThrow(/Connection closed/); - // Let the send register the pending entry before the crash. - await Promise.resolve(); - - transport.onclose?.(); - - await settled; - }); - it("advertises task requests only for capabilities it advertised", async () => { // `capabilities.tasks.requests` tells the server which server→client // requests we accept as tasks. Built from `receiverTasks` alone it would From e92ebc68bb97735db17f46f3c05aaaad3ab74519 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:38:34 -0400 Subject: [PATCH 33/58] =?UTF-8?q?review:=20round-32=20=E2=80=94=20separate?= =?UTF-8?q?=20the=20outbound=20teardown=20claim=20from=20the=20inbound=20o?= =?UTF-8?q?ne=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My round-31 header edit spliced the outbound raw-wire passage into the middle of the inbound claim, so "There are three: a failed connect(), a mid-session transport close, and an explicit disconnect()" ended up reading as scoping the outbound settle — which is two by design, since nothing populates the map before the handshake. Round 31 established that scoping so the code wouldn't imply an impossible state; the prose then un-scoped it. Split into its own paragraph with its own scope clause, and reflowed both in one pass rather than nudging breaks. Also raced the raw-wire assertion, for the reason the file's own wait helper documents: without the teardown rejection the test waited out the request timeout and reported a bare vitest timeout. It now fails with "raw-wire request not settled by teardown" — verified. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index f517cd9a6..b3fea62be 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -45,12 +45,7 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * queue a request with us, so every path that ends a connection has to clear * that queue, announce it, and settle each entry — otherwise the web * pending-request modal outlives the connection it belongs to, and the server - * is left waiting on a request we accepted and never answered. The outbound - * direction needs the same: the raw-wire modern `tasks/*` map is ours — the - * SDK's era gate keeps those frames out of its own `_responseHandlers`, so its - * teardown can't settle them — and a Tasks-tab poll in flight when the server - * dies would otherwise wait out its own 30s timeout and blame the timeout for - * a crash. There are three: a + * is left waiting on a request we accepted and never answered. There are three: a * failed `connect()`, a mid-session transport close, and an explicit * `disconnect()`. `disconnect()` and the crash path clear before dispatching * `disconnect`, so a handler reading the queue sees it empty. `connect()`'s @@ -61,6 +56,15 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * no `onclose` fires. The crash and failure paths emit the change events * immediately; `disconnect()` batches them with its other teardown dispatches. * + * The outbound direction needs settling too, on its own terms. The raw-wire + * modern `tasks/*` map is ours — the SDK's era gate keeps those frames out of + * its own `_responseHandlers`, so its teardown can't settle them — and a + * Tasks-tab poll in flight when the server dies would otherwise wait out its + * own 30s timeout and blame the timeout for a crash. It is rejected on the two + * paths that can hold one, `disconnect()` and the crash path; the `connect()` + * catch needs no such call, because nothing populates the map before the + * handshake and both terminal paths clear it. + * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually * advertised rather than the option it was derived from: a later `setRoots()` @@ -627,7 +631,22 @@ describe("InspectorClient peer-handler timing (#1797)", () => { transport.onclose?.(); - await settled; + // Raced for the same reason `waitForNewPendingRequest` is: without the + // teardown rejection this waits out the request's own timeout, so a + // regression would surface as a bare vitest timeout naming the test rather + // than the thing that didn't happen. + let timer: ReturnType | undefined; + const unsettled = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("raw-wire request not settled by teardown")), + 1000, + ); + }); + try { + await Promise.race([settled, unsettled]); + } finally { + clearTimeout(timer); + } }); it("connects when an elicit option enables no mode", async () => { From 64bf2b99b8242a4e83ccbffae7c1cc9c6a842330 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:51:41 -0400 Subject: [PATCH 34/58] =?UTF-8?q?review:=20round-33=20=E2=80=94=20extract?= =?UTF-8?q?=20the=20timeout=20race,=20mark=20the=20failure-path=20promise?= =?UTF-8?q?=20handled=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three copies of "reject after 1s so a regression fails where it happened" had accumulated, each re-explaining itself. Extracted `withTimeout`, used by the enqueue wait and the raw-wire assertion; injectRequest keeps its own since its timeout also deletes the waiter entry. On the failing path the raw-wire test left the production 30s timer armed, so a regressed run got the intended failure plus a stray unhandled rejection up to 30s later — the armed-timer class the file's own comment names, on the failure path. `settled` is marked handled before the race. Verified: with the fix removed the run fails with "raw-wire request not settled by teardown" and no unhandled rejection. Recorded why disconnect() drains the SDK's in-flight requests but rejects the raw-wire map outright: the user asked to disconnect, and a Tasks poll is not worth delaying that for. Pre-existing and deliberate, now written down where the asymmetry is visible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 64 +++++++++---------- core/mcp/inspectorClient.ts | 4 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index b3fea62be..16b9a1787 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -289,32 +289,40 @@ class ElicitAfterConnectTransport implements Transport { } } +/** + * Race `promise` against a short reject, so a regression fails where it + * happened instead of hanging to the vitest timeout — which names the test + * rather than the thing that didn't occur. The timer is cleared on settle so + * nothing armed outlives the test (the #1760 class). + */ +function withTimeout( + promise: Promise, + message: string, + ms = 1000, +): Promise { + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} + /** * Resolve when the client enqueues a pending peer request. Waits on the * client's own signal rather than counting microtasks — the enqueue is one tick * deep today with no margin, and an added await on the SDK's inbound path would - * flake it. Raced with a reject for the same reason `injectRequest` is: a - * regression that stops the enqueue should fail here, not hang to the vitest - * timeout. + * flake it. */ function waitForNewPendingRequest( client: InspectorClient, event: "newPendingElicitation" | "newPendingSample", ): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`No ${event} after the request was delivered`)), - 1000, - ); - client.addEventListener( - event, - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); - }); + return withTimeout( + new Promise((resolve) => { + client.addEventListener(event, () => resolve(), { once: true }); + }), + `No ${event} after the request was delivered`, + ); } /** Connects cleanly, then samples on demand so a test can tear the client down. */ @@ -631,22 +639,12 @@ describe("InspectorClient peer-handler timing (#1797)", () => { transport.onclose?.(); - // Raced for the same reason `waitForNewPendingRequest` is: without the - // teardown rejection this waits out the request's own timeout, so a - // regression would surface as a bare vitest timeout naming the test rather - // than the thing that didn't happen. - let timer: ReturnType | undefined; - const unsettled = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error("raw-wire request not settled by teardown")), - 1000, - ); - }); - try { - await Promise.race([settled, unsettled]); - } finally { - clearTimeout(timer); - } + // If the teardown rejection is missing, `settled` waits out the request's + // own 30s timer instead — and would then reject with nobody awaiting it, so + // mark it handled before racing. (The race itself is why the failure names + // this expectation rather than the test.) + void settled.catch(() => {}); + await withTimeout(settled, "raw-wire request not settled by teardown"); }); it("connects when an elicit option enables no mode", async () => { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a1b592a7b..a7ff90f7c 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1852,7 +1852,9 @@ export class InspectorClient extends InspectorClientEventTarget { ); this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers - // don't hang past teardown. + // don't hang past teardown. Unlike the SDK's own in-flight requests, these + // get no share of the drain above: the user asked to disconnect, and a + // Tasks poll is not worth delaying that for. this.rejectPendingRawWireRequests("Disconnected"); // Abort any task paused at input_required so its poll loop unwinds. for (const [, controller] of this.taskInputAbortControllers) { From 271f782e7e0ad48452e1a6b7ab45a11a7e583714 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:01:46 -0400 Subject: [PATCH 35/58] =?UTF-8?q?review:=20round-34=20=E2=80=94=20drop=20a?= =?UTF-8?q?=20no-op=20guard=20and=20the=20false=20mechanism=20it=20asserte?= =?UTF-8?q?d=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 33 claimed the raw-wire test could leave `settled` rejecting unhandled on a regression run, and round 33's fix encoded that as `void settled.catch(() => {})` with a comment explaining it. Both were wrong: `Promise.race` attaches a rejection handler to every input, so `withTimeout(settled, …)` on the next line already marks it handled. Verified with a standalone script — no unhandledRejection fires, and the pre-fix version was equally safe. Removed the line and the comment. The comment mattered more than the line: it told the next reader that `Promise.race` does not mark its losers handled, which is false and would cost them elsewhere. Kept the true half by setting `timeout: 50` on that test's client, so a regression leaves no 30s timer armed and fails with `Raw request "tasks/get" timed out after 50 ms` — more self-describing than the raced generic message. Verified. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- .../inspectorClient-peer-handler-timing.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 16b9a1787..6fc98c535 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -612,7 +612,13 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const transport = new ElicitAfterConnectTransport(); const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, + { + environment: { transport: () => ({ transport }) }, + // Short, so a regression run doesn't leave the raw-wire request's own + // timer armed past the failing test — and so its message ("timed out + // after 50 ms") beats a generic race message at describing the failure. + timeout: 50, + }, ); await client.connect(); @@ -639,11 +645,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { transport.onclose?.(); - // If the teardown rejection is missing, `settled` waits out the request's - // own 30s timer instead — and would then reject with nobody awaiting it, so - // mark it handled before racing. (The race itself is why the failure names - // this expectation rather than the test.) - void settled.catch(() => {}); + // Raced so a regression names this expectation rather than hanging to a + // bare vitest timeout. The client's own request timeout is set short above, + // so nothing is left armed past a failing run. await withTimeout(settled, "raw-wire request not settled by teardown"); }); From 3301e92be7609a1e00a0b06659958f75ac831473 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:13:11 -0400 Subject: [PATCH 36/58] =?UTF-8?q?review:=20round-35=20=E2=80=94=20the=20dr?= =?UTF-8?q?ain=20comment=20described=20a=20trade-off=20nobody=20made=20(#1?= =?UTF-8?q?797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round's comment said raw-wire requests "get no share of the drain above: the user asked to disconnect, and a Tasks poll is not worth delaying that for" — framing a decision. Verified it isn't one: `safeDisconnectTimeout` defaults to 0 and the only caller anywhere that passes a value is a single test, so nothing is drained in production; and the drain polls the SDK's `_responseHandlers`, which never holds raw-wire ids, so those were never eligible regardless. Reworded to what is true. The framing came from my own round-33 reply, which described the drain as running on every disconnect — a comment faithfully encoding a half-read mechanism, which is the same failure as the `Promise.race` one it followed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- core/mcp/inspectorClient.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a7ff90f7c..1fca40282 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1852,9 +1852,11 @@ export class InspectorClient extends InspectorClientEventTarget { ); this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers - // don't hang past teardown. Unlike the SDK's own in-flight requests, these - // get no share of the drain above: the user asked to disconnect, and a - // Tasks poll is not worth delaying that for. + // don't hang past teardown. Rejected outright on every disconnect: the + // drain above polls the SDK's own response-handler map, which never holds + // raw-wire ids (those frames go straight through the transport), and it is + // opt-in anyway — every production caller leaves `safeDisconnectTimeout` at + // 0, so nothing is drained for anyone. this.rejectPendingRawWireRequests("Disconnected"); // Abort any task paused at input_required so its poll loop unwinds. for (const [, controller] of this.taskInputAbortControllers) { From 42d511f90694b4877d3f3654d10ef3636df2a2a6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:26:35 -0400 Subject: [PATCH 37/58] =?UTF-8?q?review:=20round-36=20=E2=80=94=20the=20ra?= =?UTF-8?q?ce=20comment=20outlived=20the=20mechanism=20its=20own=20commit?= =?UTF-8?q?=20replaced=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `timeout: 50` made the request settle in ~50ms, so the 1s race can never win and a regression fails on the assertion (`timed out after 50 ms` vs the expected /Connection closed/) — which the commit message said, while the comment beside it still credited the race. Reworded: the short timeout is what fails fast and names itself, the race is the backstop for a promise that never settles at all. Race kept; it covers the case where the request's own timer is broken too. Third comment in three rounds encoding a mechanism the same commit changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- .../core/mcp/inspectorClient-peer-handler-timing.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 6fc98c535..a45b44002 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -645,9 +645,11 @@ describe("InspectorClient peer-handler timing (#1797)", () => { transport.onclose?.(); - // Raced so a regression names this expectation rather than hanging to a - // bare vitest timeout. The client's own request timeout is set short above, - // so nothing is left armed past a failing run. + // The short request timeout set above is what makes a regression fail fast + // and name itself — the assertion reports `timed out after 50 ms` against + // the expected /Connection closed/. The race is the backstop for the + // narrower case where the request's own timer is broken too, so nothing + // settles at all. await withTimeout(settled, "raw-wire request not settled by teardown"); }); From 73f26c79bb5404c607e332bcd03870cc3250fd60 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:38:23 -0400 Subject: [PATCH 38/58] =?UTF-8?q?review:=20round-37=20=E2=80=94=20scope=20?= =?UTF-8?q?the=20timeout=20comment,=20align=20the=20bypass=20guards=20(#17?= =?UTF-8?q?97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `timeout: 50` is the client-wide request timeout, not a bound on the raw-wire request; it is inert in that test only because the test issues no SDK request after connect. Noted, so someone adding one later knows it would inherit a 50ms budget rather than reading the comment and looking elsewhere. The two installReceiverTaskResponseBypass guards read `this.receiverTasks` while the five gates around them read the advertisement. Installing the bypass mutates the Protocol's _requestHandlers map, so it is registration — same as the setRequestHandler it follows. Equivalent today; aligned so the convention is uniform rather than something the next reader has to re-derive per site. The genuinely-runtime `receiverTasks` reads are left. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- .../core/mcp/inspectorClient-peer-handler-timing.test.ts | 5 +++++ core/mcp/inspectorClient.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index a45b44002..c758dd2c9 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -617,6 +617,11 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // Short, so a regression run doesn't leave the raw-wire request's own // timer armed past the failing test — and so its message ("timed out // after 50 ms") beats a generic race message at describing the failure. + // Note this is the *client-wide* request timeout, not a per-call one; + // it is safe here only because this test issues no SDK request after + // connect (`fetchServerInfo` reads cached values, and no + // `setLoggingLevel` since the fixture advertises no logging). Anything + // added later would inherit the 50ms budget. timeout: 50, }, ); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 1fca40282..9aca290b4 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1228,7 +1228,9 @@ export class InspectorClient extends InspectorClientEventTarget { return this.enqueuePendingSample(request, "server-request"); }; this.client.setRequestHandler("sampling/createMessage", samplingHandler); - if (this.receiverTasks) { + // Registration, like the `setRequestHandler` above it — so it reads + // the advertisement, not the option (equivalent today). + if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "sampling/createMessage", samplingHandler, @@ -1293,7 +1295,9 @@ export class InspectorClient extends InspectorClientEventTarget { return this.enqueuePendingElicitation(request, "server-request"); }; this.client.setRequestHandler("elicitation/create", elicitHandler); - if (this.receiverTasks) { + // Registration, like the `setRequestHandler` above it — so it reads + // the advertisement, not the option (equivalent today). + if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "elicitation/create", elicitHandler, From e8ddf41ccfac49958d24c1b282505f2343c6b17c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:50:10 -0400 Subject: [PATCH 39/58] =?UTF-8?q?review:=20round-38=20=E2=80=94=20one=20pr?= =?UTF-8?q?edicate=20for=20the=20whole=20bypass=20mechanism=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 37 moved the bypass *install* guard to the advertisement and left the three branches it controls on the raw option, splitting one immutable fact across two sources. The dead direction is the harmful one: if the tasks advertisement ever acquires a condition — the exact edit that produced the elicitation wedge — then advertised goes false while receiverTasks stays true, the bypass is not installed, and the handler still returns `{ task }` into the SDK's validating wrapper, which rejects it. That is the breakage the bypass exists to prevent, and before round 37 it could not happen because install and branch read the same field. All three now read tasksCapabilityAdvertised. `receiverTasks` is left where it belongs: assigned once, and driving the advertisement. Also noted that the wrapper's own check is redundant with its install guard and kept deliberately, since it must agree with the handler branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- core/mcp/inspectorClient.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9aca290b4..46920b620 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -991,7 +991,10 @@ export class InspectorClient extends InspectorClientEventTarget { if (!validating) return; internal._requestHandlers.set(method, (request, ctx) => { const task = (request as { params?: { task?: unknown } })?.params?.task; - if (this.receiverTasks && task != null) { + // The advertisement check is redundant here — this wrapper only exists + // when tasks are advertised — but it mirrors the handler branch below + // deliberately: the two must agree, so they read one predicate. + if (this.tasksCapabilityAdvertised && task != null) { return rawHandler(request as CreateMessageRequest & ElicitRequest); } return validating(request, ctx); @@ -1171,7 +1174,7 @@ export class InspectorClient extends InspectorClientEventTarget { ): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; - if (this.receiverTasks && paramsTask != null) { + if (this.tasksCapabilityAdvertised && paramsTask != null) { const record = this.createReceiverTask({ ttl: paramsTask.ttl, initialStatus: "input_required", @@ -1228,8 +1231,9 @@ export class InspectorClient extends InspectorClientEventTarget { return this.enqueuePendingSample(request, "server-request"); }; this.client.setRequestHandler("sampling/createMessage", samplingHandler); - // Registration, like the `setRequestHandler` above it — so it reads - // the advertisement, not the option (equivalent today). + // Registration, like the `setRequestHandler` above it — and the whole + // bypass mechanism (install, wrapper branch, handler branch) reads this + // one predicate, so the install can't drift from the branch it controls. if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "sampling/createMessage", @@ -1245,7 +1249,7 @@ export class InspectorClient extends InspectorClientEventTarget { const elicitHandler = (request: ElicitRequest): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; - if (this.receiverTasks && paramsTask != null) { + if (this.tasksCapabilityAdvertised && paramsTask != null) { const record = this.createReceiverTask({ ttl: paramsTask.ttl, initialStatus: "input_required", @@ -1295,8 +1299,9 @@ export class InspectorClient extends InspectorClientEventTarget { return this.enqueuePendingElicitation(request, "server-request"); }; this.client.setRequestHandler("elicitation/create", elicitHandler); - // Registration, like the `setRequestHandler` above it — so it reads - // the advertisement, not the option (equivalent today). + // Registration, like the `setRequestHandler` above it — and the whole + // bypass mechanism (install, wrapper branch, handler branch) reads this + // one predicate, so the install can't drift from the branch it controls. if (this.tasksCapabilityAdvertised) { this.installReceiverTaskResponseBypass( "elicitation/create", From 66c854c27f80a782c785307659c88d43a8e97183 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:06:27 -0400 Subject: [PATCH 40/58] fix(core): don't carry receiver tasks into the next session (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-39 review found the last member of the enumeration rounds 12-14 and 31 built: receiverTaskRecords was cleared only by disconnect(). The crash path doesn't clear it, the connect() failure catch doesn't, and connect() never reset it — so a record created in one session survived into the next connect() on the same instance, and `tasks/list` is answered from that map. A new server would be told about a task it never created. Reachable in web (the only client with receiverTasks): a task-augmented sampling/createMessage arriving in the handshake window creates a record; if that connect then fails with a 401, App.tsx retries connect() on the same instance. Cleared at the top of connect() rather than only on the crash path — the auth-recovery retry means ending a session isn't the only way a new one begins, and starting clean covers every route in. disconnect() keeps its clear via the same extracted helper. Pre-existing — the crash path never cleared these — but the handshake window this PR opened is what lets a receiver task exist before connect resolves, and this was the one member left outside the invariant the PR documented. Test verified load-bearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 47 +++++++++++++++++++ core/mcp/inspectorClient.ts | 35 +++++++++++--- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index c758dd2c9..936638f84 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -336,6 +336,20 @@ class SampleAfterConnectTransport implements Transport { async start(): Promise {} async close(): Promise {} + /** A task-augmented sample, which creates a receiver-task record. */ + sampleAsTask(): void { + this.onmessage?.({ + jsonrpc: "2.0", + id: SampleAfterConnectTransport.SAMPLE_ID + 1, + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "hi" } }], + maxTokens: 10, + task: { ttl: 60_000 }, + }, + }); + } + sample(): void { this.onmessage?.({ jsonrpc: "2.0", @@ -658,6 +672,39 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await withTimeout(settled, "raw-wire request not settled by teardown"); }); + it("does not carry receiver tasks into the next session", async () => { + // A record is a task the *server* created with us, and `tasks/list` is + // answered from that map — so one surviving a reconnect reports a task the + // new server never created. `disconnect()` cleared them, but that is not + // the only way a session ends: a crash, or a failed connect the caller + // retries on this same instance, both leave them behind. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + receiverTasks: true, + }, + ); + await client.connect(); + + const queued = waitForNewPendingRequest(client, "newPendingSample"); + transport.sampleAsTask(); + await queued; + const internals = client as unknown as { + listReceiverTasks: () => unknown[]; + }; + expect(internals.listReceiverTasks()).toHaveLength(1); + + // The server dies; the caller reconnects on the same instance. + transport.onclose?.(); + await client.connect(); + + expect(internals.listReceiverTasks()).toEqual([]); + + await client.disconnect(); + }); + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 46920b620..9a31d54b4 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1399,6 +1399,26 @@ export class InspectorClient extends InspectorClientEventTarget { ); } + /** + * Stop the receiver tasks' TTL timers and drop the records. + * + * These are tasks a *server* created with us, so they belong to the session + * that created them: `listReceiverTasks()` is what the `tasks/list` handler + * answers with, and a record surviving into the next session would report a + * task the new server never created. `disconnect()` clears them, and so does + * `connect()` — the auth-recovery retry reconnects the *same* client + * instance, so ending the session isn't the only way a new one begins + * (#1797). + */ + private clearReceiverTasks(): void { + for (const record of this.receiverTaskRecords.values()) { + if (record.cleanupTimeoutId != null) { + clearTimeout(record.cleanupTimeoutId); + } + } + this.receiverTaskRecords.clear(); + } + /** * Settle and drop the queued peer requests (sampling / elicitation). * @@ -1463,6 +1483,13 @@ export class InspectorClient extends InspectorClientEventTarget { return; } + // Start from a clean session. `disconnect()` clears these, but it is not + // the only way a session ends — a crash, or a failed connect the caller + // retries on this same instance (the auth-recovery path), both leave the + // previous session's records behind, and `tasks/list` would then report + // them to a server that never created them. + this.clearReceiverTasks(); + const oauthManager = this.oauthManager; if ( this.baseTransport && @@ -1876,13 +1903,7 @@ export class InspectorClient extends InspectorClientEventTarget { // hanging past teardown; drop the controller reference either way. this.activeToolCallAbortController?.abort("Disconnected"); this.activeToolCallAbortController = undefined; - // Clear receiver tasks: stop TTL timers and drop records - for (const record of this.receiverTaskRecords.values()) { - if (record.cleanupTimeoutId != null) { - clearTimeout(record.cleanupTimeoutId); - } - } - this.receiverTaskRecords.clear(); + this.clearReceiverTasks(); this.appRendererClientProxy = null; this.capabilities = undefined; this.serverInfo = undefined; From 97ad44e9f2b0c20b6a5d723b0b3ebcaf25530ef7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:22:50 -0400 Subject: [PATCH 41/58] fix(core): reset all session-scoped state on connect, not just receiver tasks (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-40 review: receiverTaskRecords was one of five collections cleared only by disconnect(). Two of the other four have real symptoms on the same crash-then-reconnect path: - subscribedResources — the modern subscribeToResource early-returns on a uri already in the set, so after a reconnect the user's Subscribe click silently sends nothing to the new server while the UI keeps listing it. - cancelledTaskIds — a leftover id mislabels a *new* task sharing it as cancelled rather than failed, and server task ids are often short. taskInputAbortControllers leaked un-aborted controllers. modernLogLevel had the opposite asymmetry: disconnect() cleared it so it couldn't leak (#1629), but nothing restored it, so a reconnect after an explicit disconnect silently lost the user's configured level while a reconnect after a crash kept a mid-session override. It is now recomputed from serverSettings the way the constructor does, so both routes agree. clearReceiverTasks became resetSessionState, called from the same place. Tests cover the two symptomatic siblings and now assert receiver tasks through the `tasks/list` handler — what a server actually sees, and the symptom itself — which also removed the unjustified `as unknown as` the previous commit added. The remaining cast is justified in place, since cancelledTaskIds has no public reader. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 74 ++++++++++++++++++- core/mcp/inspectorClient.ts | 43 +++++++++-- 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 936638f84..cbfd95135 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -56,6 +56,16 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * no `onclose` fires. The crash and failure paths emit the change events * immediately; `disconnect()` batches them with its other teardown dispatches. * + * A further category is session scoping. Receiver tasks, resource + * subscriptions and cancelled task ids are state a server created *with* us — + * `tasks/list` is answered from that map, and a stale subscription makes the + * modern subscribe a silent no-op — so they belong to the session that created + * them. Note the contrast with the teardown cases above: the peer-request queue + * is cleared end-clean on all three teardown paths, while this is cleared + * start-clean at the top of `connect()`, because a crash or a failed connect + * the caller retries on the same instance means ending a session is not the + * only way a new one begins. + * * The outbound direction needs settling too, on its own terms. The raw-wire * modern `tasks/*` map is ours — the SDK's era gate keeps those frames out of * its own `_responseHandlers`, so its teardown can't settle them — and a @@ -333,6 +343,18 @@ class SampleAfterConnectTransport implements Transport { static readonly SAMPLE_ID = 9401; + /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ + private readonly waiters = new Map void>(); + + /** Deliver a server→client request, resolving with the client's reply. */ + injectRequest(method: string, id: number): Promise { + const reply = new Promise((resolve) => { + this.waiters.set(id, resolve); + }); + this.onmessage?.({ jsonrpc: "2.0", id, method }); + return withTimeout(reply, `No reply to injected ${method} (id ${id})`); + } + async start(): Promise {} async close(): Promise {} @@ -380,6 +402,14 @@ class SampleAfterConnectTransport implements Transport { }); return; } + if ( + "id" in message && + typeof message.id === "number" && + ("result" in message || "error" in message) + ) { + this.waiters.get(message.id)?.(message); + this.waiters.delete(message.id); + } } } @@ -691,16 +721,52 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const queued = waitForNewPendingRequest(client, "newPendingSample"); transport.sampleAsTask(); await queued; + // Asserted through `tasks/list` — that handler is what a server actually + // sees, and it is the symptom: a surviving record is reported to a server + // that never created it. + expect(await transport.injectRequest("tasks/list", 9501)).toMatchObject({ + result: { tasks: [expect.anything()] }, + }); + + // The server dies; the caller reconnects on the same instance. + transport.onclose?.(); + await client.connect(); + + expect(await transport.injectRequest("tasks/list", 9502)).toMatchObject({ + result: { tasks: [] }, + }); + + await client.disconnect(); + }); + + it("does not carry subscriptions or cancelled task ids into the next session", async () => { + // Same class as the receiver tasks, with sharper symptoms: a stale + // subscription makes the modern `subscribeToResource` early-return, so the + // user's next Subscribe click silently sends nothing; a stale cancelled-id + // mislabels a *new* task sharing that id as cancelled rather than failed. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // Seeded directly: subscribing for real needs a server that answers + // `resources/subscribe` and cancelling needs a live task, neither of which + // adds to what is under test — that a new session starts empty. The cast is + // the only route to `cancelledTaskIds`, which has no public reader. const internals = client as unknown as { - listReceiverTasks: () => unknown[]; + subscribedResources: Set; + cancelledTaskIds: Set; }; - expect(internals.listReceiverTasks()).toHaveLength(1); + internals.subscribedResources.add("file:///watched"); + internals.cancelledTaskIds.add("task-1"); - // The server dies; the caller reconnects on the same instance. transport.onclose?.(); await client.connect(); - expect(internals.listReceiverTasks()).toEqual([]); + expect(client.getSubscribedResources()).toEqual([]); + expect(internals.cancelledTaskIds.size).toBe(0); await client.disconnect(); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9a31d54b4..dbca2c78a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1419,6 +1419,40 @@ export class InspectorClient extends InspectorClientEventTarget { this.receiverTaskRecords.clear(); } + /** + * Drop every piece of state scoped to one connection, so a new session never + * inherits the last one's. + * + * `disconnect()` clears all of this on the way out, but it is not the only + * way a session ends — a crash, or a failed connect the caller retries on + * this same instance (the auth-recovery path), both leave it behind. Called + * start-clean from `connect()` so every route in is covered. + * + * Each member has a symptom, not just untidiness: a stale `subscribedResources` + * entry makes the modern `subscribeToResource` early-return, so the user's + * Subscribe click silently sends nothing to the new server; a stale + * `cancelledTaskIds` entry mislabels a *new* task sharing the id as + * `cancelled` rather than `failed`; a receiver-task record is reported to the + * new server by `tasks/list`; and an un-aborted `taskInputAbortControllers` + * entry leaks a paused poll loop (#1797). + */ + private resetSessionState(): void { + this.clearReceiverTasks(); + this.subscribedResources.clear(); + this.cancelledTaskIds.clear(); + for (const [, controller] of this.taskInputAbortControllers) { + controller.abort(new Error("Connection ended")); + } + this.taskInputAbortControllers.clear(); + // Restore the configured opt-in rather than carrying a mid-session + // `setModernLogLevel` override into the next connection — and rather than + // leaving it `undefined` after a `disconnect()` cleared it, which silently + // dropped the user's configured level on reconnect (#1629, #1797). + const settingLevel = + this.serverSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; + this.modernLogLevel = settingLevel === "off" ? undefined : settingLevel; + } + /** * Settle and drop the queued peer requests (sampling / elicitation). * @@ -1483,12 +1517,9 @@ export class InspectorClient extends InspectorClientEventTarget { return; } - // Start from a clean session. `disconnect()` clears these, but it is not - // the only way a session ends — a crash, or a failed connect the caller - // retries on this same instance (the auth-recovery path), both leave the - // previous session's records behind, and `tasks/list` would then report - // them to a server that never created them. - this.clearReceiverTasks(); + // Start from a clean session — see `resetSessionState` for why this is + // start-clean rather than relying on `disconnect()`. + this.resetSessionState(); const oauthManager = this.oauthManager; if ( From dc738a997f3081a6828d9d5fd6ec1a2ca6b9b776 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:43:30 -0400 Subject: [PATCH 42/58] fix(web): re-seed the Logs control when the client re-seeds its own level (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-41 review caught a consequence of the last commit. `resetSessionState` restores the client's modern log level from the server setting at connect; `resetSessionScopedUiState` still blanked the web control to null on every disconnect. On the one path that reconnects the *same* instance — auth recovery — the control then read Off while every modern request carried `_meta` logLevel "debug", the default. A tool whose job is showing what you send should not disagree with itself about what it sent. The control now re-seeds from the active server's settings exactly as setupClientForServer does, instead of blanking. Also from review: - Added the tests the restore never had, covering both halves: a mid-session override does not survive a reconnect, and a disconnect no longer drops the configured level (the #1629 half, and the actual behaviour change). - resetSessionState's doc claimed "every piece of state scoped to one connection", which invites assuming membership rather than checking it. Now states the rule: state a new session would misread; what only needs settling on the way out stays with the teardown paths. - Header: session-scoping paragraph moved below the outbound one to match test order, and it now names all five members rather than three. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/web/src/App.tsx | 14 ++++- ...nspectorClient-peer-handler-timing.test.ts | 60 +++++++++++++++---- core/mcp/inspectorClient.ts | 7 ++- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index f37791a9c..e14b371f9 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1216,7 +1216,19 @@ function App() { setConsoleUi(EMPTY_CONSOLE_UI); setProgressByTaskId({}); setCurrentLogLevel("info"); - setModernLogLevel(null); + // Re-seed rather than blank: the client restores its own opt-in from the + // server setting at connect (`resetSessionState`), so blanking here would + // leave the control reading Off while every modern request still carries + // the level — visible on the auth-recovery path, which reconnects the same + // client instance rather than rebuilding it (#1629, #1797). + const activeSettings = serversRef.current.find( + (s) => s.id === activeServerIdRef.current, + )?.settings; + const reseededModernLevel = + activeSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; + setModernLogLevel( + reseededModernLevel === "off" ? null : reseededModernLevel, + ); setPendingStepUp(null); setPendingReauth(null); setReAuthBanner(null); diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index cbfd95135..d513a0e8f 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -56,16 +56,6 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * no `onclose` fires. The crash and failure paths emit the change events * immediately; `disconnect()` batches them with its other teardown dispatches. * - * A further category is session scoping. Receiver tasks, resource - * subscriptions and cancelled task ids are state a server created *with* us — - * `tasks/list` is answered from that map, and a stale subscription makes the - * modern subscribe a silent no-op — so they belong to the session that created - * them. Note the contrast with the teardown cases above: the peer-request queue - * is cleared end-clean on all three teardown paths, while this is cleared - * start-clean at the top of `connect()`, because a crash or a failed connect - * the caller retries on the same instance means ending a session is not the - * only way a new one begins. - * * The outbound direction needs settling too, on its own terms. The raw-wire * modern `tasks/*` map is ours — the SDK's era gate keeps those frames out of * its own `_responseHandlers`, so its teardown can't settle them — and a @@ -75,6 +65,17 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * catch needs no such call, because nothing populates the map before the * handshake and both terminal paths clear it. * + * A further category is session scoping. Receiver tasks, resource + * subscriptions, cancelled task ids, paused task-input aborts and the + * modern log-level opt-in are all scoped to one connection — + * `tasks/list` is answered from that map, and a stale subscription makes the + * modern subscribe a silent no-op — so they belong to the session that created + * them. Note the contrast with the teardown cases above: the peer-request queue + * is cleared end-clean on all three teardown paths, while this is cleared + * start-clean at the top of `connect()`, because a crash or a failed connect + * the caller retries on the same instance means ending a session is not the + * only way a new one begins. + * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually * advertised rather than the option it was derived from: a later `setRoots()` @@ -771,6 +772,45 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("restores the configured modern log level across a reconnect", async () => { + // Two halves of the same member. A mid-session override must not carry into + // the next session, and — the #1629 bug — a `disconnect()` must not leave + // the configured level dropped, which silently stopped stamping + // `_meta` logLevel on everything after a reconnect. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + serverSettings: { + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 0, + maxFetchRequests: 1000, + roots: [], + modernLogLevel: "info", + }, + }, + ); + + await client.connect(); + expect(client.getModernLogLevel()).toBe("info"); + + client.setModernLogLevel("error"); + transport.onclose?.(); + await client.connect(); + expect(client.getModernLogLevel()).toBe("info"); + + await client.disconnect(); + await client.connect(); + expect(client.getModernLogLevel()).toBe("info"); + + await client.disconnect(); + }); + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index dbca2c78a..71739572d 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1420,8 +1420,11 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Drop every piece of state scoped to one connection, so a new session never - * inherits the last one's. + * Drop the session-scoped state a *new* session would misread — anything the + * next server could be told about, or that would change how we treat its + * traffic. State that only needs settling on the way out (the peer-request + * queues, the raw-wire map, the in-flight tool call) is handled by the + * teardown paths instead, not here. * * `disconnect()` clears all of this on the way out, but it is not the only * way a session ends — a crash, or a failed connect the caller retries on From 4dd1388153ce093adfcdba0dabec797124126d6d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 07:21:23 -0400 Subject: [PATCH 43/58] refactor(core): one derivation for the modern log level (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-42 review: the configured level was computed independently in four places — twice in InspectorClient (constructor, resetSessionState) and twice in App.tsx (the seed and the re-seed). The client and the Logs control agreed only because four sites happened to compute the same expression, and any drift reproduces exactly the symptom the previous commit fixed: the control reading one level while every modern request stamps another. Extracted resolveModernLogLevel into core/mcp/types.ts beside DEFAULT_MODERN_LOG_LEVEL; all four call sites use it, with the web mapping undefined→null at its own boundary (display, not a second derivation). Also from review: - disconnect()'s comment still credited itself with preventing the #1629 leak, which resetSessionState now prevents. It says what the line does now — read "not opted in" while disconnected — and notes the web control deliberately shows the configured level in that window. - resetSessionState's doc claimed an un-aborted taskInputAbortControllers entry "leaks a paused poll loop". Both registration sites release in a finally, so nothing leaks permanently; the abort closes the window between a crash and the unwind. Softened, and it now has the test it lacked — the only member of the five without one. - Noted in App.tsx that the re-seed depends on activeServerIdRef still holding the outgoing id (it syncs in a passive effect), so an eager clear would silently drop it to the default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/web/src/App.tsx | 16 +++++----- ...nspectorClient-peer-handler-timing.test.ts | 29 +++++++++++++++++++ .../src/test/core/mcp/modernLogLevel.test.ts | 20 +++++++++++++ core/mcp/inspectorClient.ts | 22 +++++++------- core/mcp/types.ts | 18 ++++++++++++ 5 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 clients/web/src/test/core/mcp/modernLogLevel.test.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index e14b371f9..27240e7cf 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -44,9 +44,9 @@ import type { } from "@inspector/core/mcp/types.js"; import { DEFAULT_MAX_FETCH_REQUESTS, - DEFAULT_MODERN_LOG_LEVEL, DEFAULT_TASK_TTL_MS, eraToVersionNegotiation, + resolveModernLogLevel, } from "@inspector/core/mcp/types.js"; import { API_SERVER_ENV_VARS, @@ -1221,14 +1221,14 @@ function App() { // leave the control reading Off while every modern request still carries // the level — visible on the auth-recovery path, which reconnects the same // client instance rather than rebuilding it (#1629, #1797). + // `activeServerIdRef` is synced in a passive effect, so it still holds the + // outgoing server's id when this runs from `onDisconnect` — which is what + // lets the re-seed find its settings. Clearing that ref eagerly would + // silently drop this back to the default. const activeSettings = serversRef.current.find( (s) => s.id === activeServerIdRef.current, )?.settings; - const reseededModernLevel = - activeSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - setModernLogLevel( - reseededModernLevel === "off" ? null : reseededModernLevel, - ); + setModernLogLevel(resolveModernLogLevel(activeSettings) ?? null); setPendingStepUp(null); setPendingReauth(null); setReAuthBanner(null); @@ -2349,9 +2349,7 @@ function App() { // setting so the Logs-tab control reflects what the client stamps by // default (the client was seeded the same way in its constructor). "off" // means not opted in (null). Only affects modern connections. - const seededModernLevel = - savedSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - setModernLogLevel(seededModernLevel === "off" ? null : seededModernLevel); + setModernLogLevel(resolveModernLogLevel(savedSettings) ?? null); setManagedToolsState(new ManagedToolsState(client)); setPagedToolsState(new PagedToolsState(client)); setPagedPromptsState(new PagedPromptsState(client)); diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index d513a0e8f..dbc3f2377 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -811,6 +811,35 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("aborts a paused task-input wait when the session ends", async () => { + // The bounded-window member: both registration sites release in a + // `finally`, so nothing leaks permanently — this closes the gap between a + // crash and the loop unwinding on its own. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // Seeded directly: reaching this map for real needs a modern task paused at + // `input_required`, which adds nothing to what is under test. No public + // reader, hence the cast. + const controller = new AbortController(); + ( + client as unknown as { + taskInputAbortControllers: Map; + } + ).taskInputAbortControllers.set("task-1", controller); + + transport.onclose?.(); + await client.connect(); + + expect(controller.signal.aborted).toBe(true); + + await client.disconnect(); + }); + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/clients/web/src/test/core/mcp/modernLogLevel.test.ts b/clients/web/src/test/core/mcp/modernLogLevel.test.ts new file mode 100644 index 000000000..c33bef73d --- /dev/null +++ b/clients/web/src/test/core/mcp/modernLogLevel.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_MODERN_LOG_LEVEL, + resolveModernLogLevel, +} from "@inspector/core/mcp/types.js"; + +describe("resolveModernLogLevel", () => { + it("falls back to the default when unset", () => { + expect(resolveModernLogLevel()).toBe(DEFAULT_MODERN_LOG_LEVEL); + expect(resolveModernLogLevel({})).toBe(DEFAULT_MODERN_LOG_LEVEL); + }); + + it('treats "off" as not opted in', () => { + expect(resolveModernLogLevel({ modernLogLevel: "off" })).toBeUndefined(); + }); + + it("passes an explicit level through", () => { + expect(resolveModernLogLevel({ modernLogLevel: "error" })).toBe("error"); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 71739572d..54c293540 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -31,9 +31,9 @@ export type { } from "./types.js"; import { getServerType as getServerTypeFromConfig } from "./config.js"; import { - DEFAULT_MODERN_LOG_LEVEL, INACTIVE_SUBSCRIPTION_STREAM_STATE, isTerminalStatus, + resolveModernLogLevel, } from "./types.js"; import { cleanRoots } from "./serverList.js"; // Fallback client identity, used ONLY when a caller doesn't pass @@ -527,9 +527,7 @@ export class InspectorClient extends InspectorClientEventTarget { // the Logs-tab control. Absence means DEFAULT_MODERN_LOG_LEVEL; `"off"` // clears the opt-in. Only stamped on modern connections (see mergeMeta) — // legacy uses `logging/setLevel`. - const settingLevel = - options.serverSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - this.modernLogLevel = settingLevel === "off" ? undefined : settingLevel; + this.modernLogLevel = resolveModernLogLevel(options.serverSettings); // Default to the legacy 2025-11-25 era when the caller doesn't pin one, per // the SDK guidance that a debugging tool must not auto-probe (#1626). this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" }; @@ -1437,7 +1435,9 @@ export class InspectorClient extends InspectorClientEventTarget { * `cancelledTaskIds` entry mislabels a *new* task sharing the id as * `cancelled` rather than `failed`; a receiver-task record is reported to the * new server by `tasks/list`; and an un-aborted `taskInputAbortControllers` - * entry leaks a paused poll loop (#1797). + * entry delays a paused poll loop unwinding — both registration sites release + * in a `finally`, so nothing leaks permanently; the abort just closes the + * window between the crash and the unwind (#1797). */ private resetSessionState(): void { this.clearReceiverTasks(); @@ -1451,9 +1451,7 @@ export class InspectorClient extends InspectorClientEventTarget { // `setModernLogLevel` override into the next connection — and rather than // leaving it `undefined` after a `disconnect()` cleared it, which silently // dropped the user's configured level on reconnect (#1629, #1797). - const settingLevel = - this.serverSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - this.modernLogLevel = settingLevel === "off" ? undefined : settingLevel; + this.modernLogLevel = resolveModernLogLevel(this.serverSettings); } /** @@ -1946,8 +1944,12 @@ export class InspectorClient extends InspectorClientEventTarget { this.protocolEra = undefined; this.discoverResult = undefined; this.excludedTools = []; - // Drop the modern per-request log-level opt-in so it doesn't leak into the - // next connection's `_meta` (#1629). + // Read as "not opted in" while disconnected. This is no longer what stops + // it leaking into the next connection — `resetSessionState()` re-derives it + // at connect, so removing this would leak nothing (#1629). Note the web + // Logs control deliberately shows the *configured* level in this window + // (`resetSessionScopedUiState`), so the two disagree until the next + // connect re-seeds both; harmless, since nothing is sent meanwhile. this.modernLogLevel = undefined; this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); this.dispatchTypedEvent( diff --git a/core/mcp/types.ts b/core/mcp/types.ts index d190dd15d..b95c65a73 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -567,6 +567,24 @@ export type ModernLogLevel = LoggingLevel | "off"; */ export const DEFAULT_MODERN_LOG_LEVEL: ModernLogLevel = "debug"; +/** + * The live modern per-request log level a server's settings imply: the + * configured value, or {@link DEFAULT_MODERN_LOG_LEVEL} when unset, with + * `"off"` meaning not opted in. + * + * One derivation, because the client stamps `_meta` from it while the web Logs + * control displays it — computing it separately on each side is how the two + * come to disagree, which is a bad failure for a tool whose job is showing what + * it sent (#1629, #1797). The web maps `undefined` to `null` at its own + * boundary; that is display, not a second derivation. + */ +export function resolveModernLogLevel( + settings?: Pick, +): LoggingLevel | undefined { + const level = settings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; + return level === "off" ? undefined : level; +} + /** All modern-log-level values, for form options and the runtime guard. */ export const MODERN_LOG_LEVELS: ModernLogLevel[] = [ "off", From 900774089cd64dd0f5df1e32c0daed818be26c29 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 07:45:06 -0400 Subject: [PATCH 44/58] fix(core): reset the subscription stream state with the set it derives from (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-43 review: resetSessionState cleared subscribedResources but left modernStreamState, so a crash-then-reconnect on the same instance held an empty set alongside `{ active: true }` — a combination the rest of the file treats as impossible, since onModernSubscriptionClosed and onModernReconnectFailed both compute `active` from `subscribedResources.size > 0`, and the comment at :4712 asserts a terminal drop clears both. No symptom today only because the UI mirror resets itself independently, in another file — which is the arrangement this PR has spent its rounds removing. Clearing it here also announces the reset; that set's every other mutation dispatches. Also from review: - The web re-seed treated "no server found" as the default level rather than Off, because resolveModernLogLevel returns the default for absent *settings*. Reachable via a second disconnect after a crash, and via removing the active server. Now distinguishes the two. - Converged the two injectRequest copies: withTimeout takes an optional onTimeout, so both drop their waiter on the timeout path. The newer copy had lost exactly the cleanup that was round 33's reason for leaving the original alone. Test seeds the stream state a live subscription would have left; verified load-bearing (the earlier version passed either way, since the fixture never activates a stream). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/web/src/App.tsx | 7 ++- ...nspectorClient-peer-handler-timing.test.ts | 43 ++++++++++++------- core/mcp/inspectorClient.ts | 11 ++++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 27240e7cf..7ca07c408 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1228,7 +1228,12 @@ function App() { const activeSettings = serversRef.current.find( (s) => s.id === activeServerIdRef.current, )?.settings; - setModernLogLevel(resolveModernLogLevel(activeSettings) ?? null); + // No server found is Off, not the default — `resolveModernLogLevel` + // returns the default for absent *settings*, which is the right answer for + // a server that has none but the wrong one for no server at all. + setModernLogLevel( + activeSettings ? (resolveModernLogLevel(activeSettings) ?? null) : null, + ); setPendingStepUp(null); setPendingReauth(null); setReAuthBanner(null); diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index dbc3f2377..4f11bea0a 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -126,20 +126,13 @@ class InitializedRacingTransport implements Transport { * regression fails where it happened. */ injectRequest(method: string, id: number): Promise { - const reply = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.waiters.delete(id); - reject(new Error(`No reply to injected ${method} (id ${id})`)); - }, 1000); - // Cleared on reply — an armed timer outliving the test is the #1760 - // teardown-crash class, even though this callback touches no `window`. - this.waiters.set(id, (m) => { - clearTimeout(timer); - resolve(m); - }); + const reply = new Promise((resolve) => { + this.waiters.set(id, resolve); }); this.deliver({ jsonrpc: "2.0", id, method }); - return reply; + return withTimeout(reply, `No reply to injected ${method} (id ${id})`, { + onTimeout: () => this.waiters.delete(id), + }); } async start(): Promise {} @@ -309,11 +302,14 @@ class ElicitAfterConnectTransport implements Transport { function withTimeout( promise: Promise, message: string, - ms = 1000, + { ms = 1000, onTimeout }: { ms?: number; onTimeout?: () => void } = {}, ): Promise { let timer: ReturnType | undefined; const expired = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms); + timer = setTimeout(() => { + onTimeout?.(); + reject(new Error(message)); + }, ms); }); return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); } @@ -347,13 +343,20 @@ class SampleAfterConnectTransport implements Transport { /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ private readonly waiters = new Map void>(); - /** Deliver a server→client request, resolving with the client's reply. */ + /** + * Deliver a server→client request, resolving with the client's reply. The + * handler is async, so awaiting the reply beats guessing how many microtasks + * it takes; the timeout drops the waiter so a failing run leaves nothing in + * the map. + */ injectRequest(method: string, id: number): Promise { const reply = new Promise((resolve) => { this.waiters.set(id, resolve); }); this.onmessage?.({ jsonrpc: "2.0", id, method }); - return withTimeout(reply, `No reply to injected ${method} (id ${id})`); + return withTimeout(reply, `No reply to injected ${method} (id ${id})`, { + onTimeout: () => this.waiters.delete(id), + }); } async start(): Promise {} @@ -759,15 +762,23 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const internals = client as unknown as { subscribedResources: Set; cancelledTaskIds: Set; + modernStreamState: { active: boolean; status: string }; }; internals.subscribedResources.add("file:///watched"); internals.cancelledTaskIds.add("task-1"); + // The stream state a live modern subscription would have left behind. + internals.modernStreamState = { active: true, status: "ended" }; transport.onclose?.(); await client.connect(); expect(client.getSubscribedResources()).toEqual([]); expect(internals.cancelledTaskIds.size).toBe(0); + // Cleared with the set it is derived from, not left reading `active` for + // an empty one. + expect(client.getResourceSubscriptionStreamState()).toMatchObject({ + active: false, + }); await client.disconnect(); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 54c293540..22535e4f8 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1433,8 +1433,10 @@ export class InspectorClient extends InspectorClientEventTarget { * entry makes the modern `subscribeToResource` early-return, so the user's * Subscribe click silently sends nothing to the new server; a stale * `cancelledTaskIds` entry mislabels a *new* task sharing the id as - * `cancelled` rather than `failed`; a receiver-task record is reported to the - * new server by `tasks/list`; and an un-aborted `taskInputAbortControllers` + * `cancelled` rather than `failed`; a stale subscription stream state reads + * `active` for a set that is now empty, which every reader of it treats as + * impossible; a receiver-task record is reported to the new server by + * `tasks/list`; and an un-aborted `taskInputAbortControllers` * entry delays a paused poll loop unwinding — both registration sites release * in a `finally`, so nothing leaks permanently; the abort just closes the * window between the crash and the unwind (#1797). @@ -1442,6 +1444,11 @@ export class InspectorClient extends InspectorClientEventTarget { private resetSessionState(): void { this.clearReceiverTasks(); this.subscribedResources.clear(); + // With the set, not after it: the rest of the file derives the stream's + // `active` from `subscribedResources.size > 0`, so clearing one without the + // other leaves a combination those readers treat as impossible. This also + // announces the reset — every other mutation of that set dispatches. + this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.cancelledTaskIds.clear(); for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); From 388de8f9cb02715034c46d76474c9357e76e59ed Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:00:59 -0400 Subject: [PATCH 45/58] fix(web,core): correct the re-seed branch; share the subscription-stream reset (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-44 review. My round-43 fix traded one wrong branch for another: it read `?.settings`, which is undefined both when no server matched *and* when the server has no settings node — and the second is the common case (a hand-written mcp.json entry never opened in Server Settings). Those servers showed Debug while connected and Off after disconnect, which is exactly the client/UI disagreement rounds 41-42 removed, with no modernLogLevelChange event to reconcile it. Now branches on the server. Worth noting no test would have caught it: App.tsx is a documented exclusion from the coverage gate. Also from review: - resetSessionState cleared subscribedResources without announcing it, two lines below a comment arguing that every other mutation of that set dispatches. And it took two statements of a six-statement cluster disconnect() clears together — leaving modernReconnectAttempts (a new session's first drop would back off as if it were the old one's nth), modernListenGeneration (the bump that makes an in-flight re-listen bail), and a stale modernSubscription. One resetSubscriptionStream helper now serves both, announcing both axes; disconnect() keeps only the live stream's best-effort close. That also removed the file's one assignment to modernStreamState that bypassed its setter. - The stream-state test pinned the value, not the dispatch — the half the UI actually tracks. Now asserts both; verified against a bare field assignment. - onModernSubscriptionClosed's comment claimed the disconnect reset always clears the set; on a crash it doesn't until the next connect. Says so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- clients/web/src/App.tsx | 15 +++--- ...nspectorClient-peer-handler-timing.test.ts | 22 +++++++-- core/mcp/inspectorClient.ts | 49 ++++++++++++------- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 7ca07c408..ebd620e23 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1225,14 +1225,17 @@ function App() { // outgoing server's id when this runs from `onDisconnect` — which is what // lets the re-seed find its settings. Clearing that ref eagerly would // silently drop this back to the default. - const activeSettings = serversRef.current.find( + // Branch on the *server*, not its settings: an entry with no settings node + // is the common case (`mcp.json` written by hand, never opened in Server + // Settings), and there the default is right — it is what the seed and the + // client both use. Only "no server at all" means Off. + const activeServer = serversRef.current.find( (s) => s.id === activeServerIdRef.current, - )?.settings; - // No server found is Off, not the default — `resolveModernLogLevel` - // returns the default for absent *settings*, which is the right answer for - // a server that has none but the wrong one for no server at all. + ); setModernLogLevel( - activeSettings ? (resolveModernLogLevel(activeSettings) ?? null) : null, + activeServer + ? (resolveModernLogLevel(activeServer.settings) ?? null) + : null, ); setPendingStepUp(null); setPendingReauth(null); diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 4f11bea0a..c25d41cd9 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -297,7 +297,8 @@ class ElicitAfterConnectTransport implements Transport { * Race `promise` against a short reject, so a regression fails where it * happened instead of hanging to the vitest timeout — which names the test * rather than the thing that didn't occur. The timer is cleared on settle so - * nothing armed outlives the test (the #1760 class). + * nothing armed outlives the test (the #1760 class). `onTimeout` runs only on + * the reject path, for callers with an entry to drop when nobody answered. */ function withTimeout( promise: Promise, @@ -762,12 +763,26 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const internals = client as unknown as { subscribedResources: Set; cancelledTaskIds: Set; - modernStreamState: { active: boolean; status: string }; + modernStreamState: { + active: boolean; + status: string; + honoredUris: string[]; + }; }; internals.subscribedResources.add("file:///watched"); internals.cancelledTaskIds.add("task-1"); // The stream state a live modern subscription would have left behind. - internals.modernStreamState = { active: true, status: "ended" }; + internals.modernStreamState = { + active: true, + status: "ended", + honoredUris: ["file:///watched"], + }; + // The dispatch is the half the UI tracks — `ResourceSubscriptionsState` + // listens only to the event, never reading the field. + const streamStates: { active: boolean }[] = []; + client.addEventListener("resourceSubscriptionStreamChange", (event) => { + streamStates.push((event as CustomEvent).detail); + }); transport.onclose?.(); await client.connect(); @@ -779,6 +794,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { expect(client.getResourceSubscriptionStreamState()).toMatchObject({ active: false, }); + expect(streamStates.at(-1)).toMatchObject({ active: false }); await client.disconnect(); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 22535e4f8..3490ea671 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1441,14 +1441,33 @@ export class InspectorClient extends InspectorClientEventTarget { * in a `finally`, so nothing leaks permanently; the abort just closes the * window between the crash and the unwind (#1797). */ - private resetSessionState(): void { - this.clearReceiverTasks(); + /** + * Reset the modern listen-stream cluster: the subscribed set, the stream + * state derived from it, and the reconnect machinery that reports on it. + * + * One helper because these move together — the rest of the file derives the + * stream's `active` from `subscribedResources.size > 0`, so clearing one + * without the other leaves a combination those readers treat as impossible, + * and a surviving `modernReconnectAttempts` makes a *new* session's first + * drop back off as if it were the old session's nth. Both axes are announced: + * every other mutation of the set dispatches, and so does the stream state. + * + * The live subscription's best-effort `close()` stays with `disconnect()` — + * that is the only caller with a stream still worth closing. + */ + private resetSubscriptionStream(): void { this.subscribedResources.clear(); - // With the set, not after it: the rest of the file derives the stream's - // `active` from `subscribedResources.size > 0`, so clearing one without the - // other leaves a combination those readers treat as impossible. This also - // announces the reset — every other mutation of that set dispatches. + this.dispatchSubscriptionsChange(); + this.modernListenGeneration++; + this.clearModernReconnectTimer(); + this.modernReconnectAttempts = 0; + this.modernSubscription = null; this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + } + + private resetSessionState(): void { + this.clearReceiverTasks(); + this.resetSubscriptionStream(); this.cancelledTaskIds.clear(); for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); @@ -1913,18 +1932,9 @@ export class InspectorClient extends InspectorClientEventTarget { // Clear resource subscriptions on disconnect. Tear down the modern listen // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). - this.subscribedResources.clear(); - this.modernListenGeneration++; - this.clearModernReconnectTimer(); - this.modernReconnectAttempts = 0; const closingSubscription = this.modernSubscription; - this.modernSubscription = null; + this.resetSubscriptionStream(); closingSubscription?.close().catch(() => {}); - this.modernStreamState = INACTIVE_SUBSCRIPTION_STREAM_STATE; - this.dispatchTypedEvent( - "resourceSubscriptionStreamChange", - INACTIVE_SUBSCRIPTION_STREAM_STATE, - ); this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers // don't hang past teardown. Rejected outright on every disconnect: the @@ -4717,8 +4727,11 @@ export class InspectorClient extends InspectorClientEventTarget { if (!shouldReconnect) { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the - // ended badge while URIs are still subscribed. (On a terminal-status drop - // the disconnect reset clears the set and forces the inactive state.) + // ended badge while URIs are still subscribed. (A `disconnect()` clears + // the set and forces the inactive state. A *crash* leaves this state in + // place until the next `connect()` calls `resetSubscriptionStream` — so + // the pair can read "active with an empty set" only in that window, which + // is what nothing else in this file is written to expect.) this.setModernStreamState({ active: this.subscribedResources.size > 0, status: "ended", From 2a56323234ba5bccb9e613556c2f14e412463154 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:14:57 -0400 Subject: [PATCH 46/58] =?UTF-8?q?review:=20round-45=20=E2=80=94=20reattach?= =?UTF-8?q?=20the=20orphaned=20doc,=20order=20the=20announcements=20(#1797?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracting resetSubscriptionStream last round inserted it *and its doc* between resetSessionState's doc and resetSessionState — so two doc blocks stacked, the new helper took the nearest, and the 23-line block explaining each member's symptom attached to nothing. Dead text where it sat, and the most load-bearing comment in the batch. Moved, not reworded. That is round 32's finding in production code, from the same cause: inserting anything between a doc block and its method is a move that needs checking, prose or code. Also from review: - resetSubscriptionStream announced the cleared set before updating the stream state derived from it, so a listener in between saw an empty set with an `active` stream — the combination the helper exists to prevent, exposed by its own dispatches. Both fields move first, then both are announced. The onModernSubscriptionClosed comment now says that window is never observable, which is newly true. - The test header called the log level "cleared"; it is re-seeded, which is the #1629 half of the test twenty lines below. Says "reset", with the distinction spelled out, and the paragraph reflowed in one pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 20 ++++--- core/mcp/inspectorClient.ts | 58 ++++++++++--------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index c25d41cd9..69182e2b8 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -66,15 +66,17 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * handshake and both terminal paths clear it. * * A further category is session scoping. Receiver tasks, resource - * subscriptions, cancelled task ids, paused task-input aborts and the - * modern log-level opt-in are all scoped to one connection — - * `tasks/list` is answered from that map, and a stale subscription makes the - * modern subscribe a silent no-op — so they belong to the session that created - * them. Note the contrast with the teardown cases above: the peer-request queue - * is cleared end-clean on all three teardown paths, while this is cleared - * start-clean at the top of `connect()`, because a crash or a failed connect - * the caller retries on the same instance means ending a session is not the - * only way a new one begins. + * subscriptions, cancelled task ids, paused task-input aborts and the modern + * log-level opt-in are all scoped to one connection — `tasks/list` is answered + * from that map, and a stale subscription makes the modern subscribe a silent + * no-op — so they belong to the session that created them. Note the contrast + * with the teardown cases above: the peer-request queue is cleared end-clean + * on all three teardown paths, while this is *reset* start-clean at the top of + * `connect()`, because a crash or a failed connect the caller retries on the + * same instance means ending a session is not the only way a new one begins. + * Reset, not cleared: the log level is re-derived from the server setting + * rather than dropped, so a mid-session override does not carry over and the + * configured level is not lost. * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 3490ea671..9f19bb787 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1417,6 +1417,33 @@ export class InspectorClient extends InspectorClientEventTarget { this.receiverTaskRecords.clear(); } + /** + * Reset the modern listen-stream cluster: the subscribed set, the stream + * state derived from it, and the reconnect machinery that reports on it. + * + * One helper because these move together — the rest of the file derives the + * stream's `active` from `subscribedResources.size > 0`, so clearing one + * without the other leaves a combination those readers treat as impossible, + * and a surviving `modernReconnectAttempts` makes a *new* session's first + * drop back off as if it were the old session's nth. Both axes are announced: + * every other mutation of the set dispatches, and so does the stream state. + * + * The live subscription's best-effort `close()` stays with `disconnect()` — + * that is the only caller with a stream still worth closing. + */ + private resetSubscriptionStream(): void { + this.subscribedResources.clear(); + this.modernListenGeneration++; + this.clearModernReconnectTimer(); + this.modernReconnectAttempts = 0; + this.modernSubscription = null; + // Announced only once both have moved: a listener that ran between them + // would see an empty set with an `active` stream — the combination this + // helper exists to prevent, so its own dispatches must not expose it. + this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + this.dispatchSubscriptionsChange(); + } + /** * Drop the session-scoped state a *new* session would misread — anything the * next server could be told about, or that would change how we treat its @@ -1441,30 +1468,6 @@ export class InspectorClient extends InspectorClientEventTarget { * in a `finally`, so nothing leaks permanently; the abort just closes the * window between the crash and the unwind (#1797). */ - /** - * Reset the modern listen-stream cluster: the subscribed set, the stream - * state derived from it, and the reconnect machinery that reports on it. - * - * One helper because these move together — the rest of the file derives the - * stream's `active` from `subscribedResources.size > 0`, so clearing one - * without the other leaves a combination those readers treat as impossible, - * and a surviving `modernReconnectAttempts` makes a *new* session's first - * drop back off as if it were the old session's nth. Both axes are announced: - * every other mutation of the set dispatches, and so does the stream state. - * - * The live subscription's best-effort `close()` stays with `disconnect()` — - * that is the only caller with a stream still worth closing. - */ - private resetSubscriptionStream(): void { - this.subscribedResources.clear(); - this.dispatchSubscriptionsChange(); - this.modernListenGeneration++; - this.clearModernReconnectTimer(); - this.modernReconnectAttempts = 0; - this.modernSubscription = null; - this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); - } - private resetSessionState(): void { this.clearReceiverTasks(); this.resetSubscriptionStream(); @@ -4728,10 +4731,9 @@ export class InspectorClient extends InspectorClientEventTarget { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the // ended badge while URIs are still subscribed. (A `disconnect()` clears - // the set and forces the inactive state. A *crash* leaves this state in - // place until the next `connect()` calls `resetSubscriptionStream` — so - // the pair can read "active with an empty set" only in that window, which - // is what nothing else in this file is written to expect.) + // the set and forces the inactive state; a *crash* leaves both in place + // until the next `connect()` calls `resetSubscriptionStream`, which moves + // them together — so "active with an empty set" is never observable.) this.setModernStreamState({ active: this.subscribedResources.size > 0, status: "ended", From 303c3924345d8e943c02ac07c6a478a34476ab4b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:30:57 -0400 Subject: [PATCH 47/58] fix(core): close the listen stream the connect-path reset drops (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-46 review: resetSubscriptionStream's doc claimed disconnect() is the only caller with a stream worth closing, and the helper did a bare `modernSubscription = null`. Not guaranteed on the connect path — an `onerror` without an `onclose` leaves the transport up, connect() reuses it, and the reset then drops the last reference to a stream still open on the server. Nothing can close it afterwards: the `closed` handler bails on the bumped generation, and `closed` never resolves for a live stream. Before the extraction the surviving reference was eventually closed by refreshModernSubscription as `previous`, so this was newly unclosable. The helper now closes it itself, best-effort, after the dispatches so the round-45 ordering is unaffected; disconnect() drops its two hand-rolled lines. Also from review: - The session test pinned the subscription set's value but not its dispatch — the half the UI tracks, and the gap round 43 closed for the stream axis one line away. Both now asserted; verified load-bearing. - resetSessionState's doc said disconnect() "clears all of this", which is true by duplication for two of the five members; it now says a sixth has to be added in both places. And "the teardown paths" handle the in-flight tool call only on the disconnect route — on a crash it is callTool's own finally. Named. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 41 +++++++++++++++++++ core/mcp/inspectorClient.ts | 19 ++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 69182e2b8..9eef4a2a0 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -785,6 +785,13 @@ describe("InspectorClient peer-handler timing (#1797)", () => { client.addEventListener("resourceSubscriptionStreamChange", (event) => { streamStates.push((event as CustomEvent).detail); }); + // Same for the set: `ResourceSubscriptionsState` reads `event.detail` and + // never the client's field, so clearing without announcing would leave the + // Resources tiles standing on a reconnected session. + const subscriptionLists: string[][] = []; + client.addEventListener("resourceSubscriptionsChange", (event) => { + subscriptionLists.push((event as CustomEvent).detail); + }); transport.onclose?.(); await client.connect(); @@ -797,6 +804,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { active: false, }); expect(streamStates.at(-1)).toMatchObject({ active: false }); + expect(subscriptionLists.at(-1)).toEqual([]); await client.disconnect(); }); @@ -869,6 +877,39 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("closes a live listen stream the next connect drops", async () => { + // An `onerror` without an `onclose` leaves the transport up, and `connect()` + // reuses it — so the reference the reset drops can be the last one to a + // stream still open on the server. Nothing else can close it afterwards: + // the `closed` handler bails on the bumped generation. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + let closed = false; + // Seeded directly: establishing a real listen stream needs a modern server + // answering `subscriptions/listen`, which adds nothing to what is tested. + ( + client as unknown as { + modernSubscription: { close: () => Promise } | null; + } + ).modernSubscription = { + close: async () => { + closed = true; + }, + }; + + transport.onerror?.(new Error("stream broke, transport still up")); + await client.connect(); + + expect(closed).toBe(true); + + await client.disconnect(); + }); + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9f19bb787..98deb2585 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1428,10 +1428,13 @@ export class InspectorClient extends InspectorClientEventTarget { * drop back off as if it were the old session's nth. Both axes are announced: * every other mutation of the set dispatches, and so does the stream state. * - * The live subscription's best-effort `close()` stays with `disconnect()` — - * that is the only caller with a stream still worth closing. + * Closes the stream itself, best-effort. `disconnect()` is the obvious caller + * with a live one, but not the only one: an `onerror` without an `onclose` + * leaves the transport up, and `connect()` then reuses it — so the reference + * dropped here can be the last one to a stream still open on the server. */ private resetSubscriptionStream(): void { + const closing = this.modernSubscription; this.subscribedResources.clear(); this.modernListenGeneration++; this.clearModernReconnectTimer(); @@ -1442,6 +1445,8 @@ export class InspectorClient extends InspectorClientEventTarget { // helper exists to prevent, so its own dispatches must not expose it. this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.dispatchSubscriptionsChange(); + // After the dispatches, so the ordering above is unaffected. + closing?.close().catch(() => {}); } /** @@ -1449,9 +1454,13 @@ export class InspectorClient extends InspectorClientEventTarget { * next server could be told about, or that would change how we treat its * traffic. State that only needs settling on the way out (the peer-request * queues, the raw-wire map, the in-flight tool call) is handled by the - * teardown paths instead, not here. + * teardown paths instead, not here — the in-flight tool call by `callTool`'s + * own `finally` once the SDK rejects it, the rest by `disconnect()` and the + * crash path. * - * `disconnect()` clears all of this on the way out, but it is not the only + * `disconnect()` clears all of this on the way out too — two members through + * the same helpers, the other two hand-rolled in both places, so a sixth + * member added here has to be added there as well. It is not the only * way a session ends — a crash, or a failed connect the caller retries on * this same instance (the auth-recovery path), both leave it behind. Called * start-clean from `connect()` so every route in is covered. @@ -1935,9 +1944,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Clear resource subscriptions on disconnect. Tear down the modern listen // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). - const closingSubscription = this.modernSubscription; this.resetSubscriptionStream(); - closingSubscription?.close().catch(() => {}); this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers // don't hang past teardown. Rejected outright on every disconnect: the From 41c2db4852a97f808f25da9e32c7e0f307fe80ab Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:45:09 -0400 Subject: [PATCH 48/58] =?UTF-8?q?review:=20round-47=20=E2=80=94=20fix=20th?= =?UTF-8?q?e=20doc's=20count,=20catch=20the=20closed-promise=20chain=20(#1?= =?UTF-8?q?797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resetSessionState's doc said "two members through the same helpers, the other two hand-rolled" for a body with five. It is two and three — and the member the enumeration dropped is the one that is *paired* rather than duplicated: modernLogLevel is re-derived here and blanked in disconnect(), deliberately. Both corrected. - `subscription.closed.then(...)` had no rejection handler, unlike every other promise around that stream. Newly reachable: before the last commit a connect-path reset dropped the reference with `closed` permanently pending, so the chain never settled at all; closing the stream is what makes it settle. Added, with the Node-terminates reasoning the file already applies to payloadPromise. - The listen-stream test exercised transport reuse without asserting it — the half that makes "still open on the server" true. Counts factory calls now, so the premise fails loudly if a future change recreates the transport rather than silently ceasing to apply. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 14 ++++++++++++- core/mcp/inspectorClient.ts | 20 +++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 9eef4a2a0..ae4191214 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -883,9 +883,20 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // stream still open on the server. Nothing else can close it afterwards: // the `closed` handler bails on the bumped generation. const transport = new SampleAfterConnectTransport(); + // Counted, because transport *reuse* is what makes "a stream still open on + // the server" true — if a future change recreated it here, the scenario + // would quietly stop applying while the test kept passing. + let created = 0; const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, + { + environment: { + transport: () => { + created++; + return { transport }; + }, + }, + }, ); await client.connect(); @@ -906,6 +917,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.connect(); expect(closed).toBe(true); + expect(created).toBe(1); await client.disconnect(); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 98deb2585..f2c084b53 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1458,9 +1458,11 @@ export class InspectorClient extends InspectorClientEventTarget { * own `finally` once the SDK rejects it, the rest by `disconnect()` and the * crash path. * - * `disconnect()` clears all of this on the way out too — two members through - * the same helpers, the other two hand-rolled in both places, so a sixth - * member added here has to be added there as well. It is not the only + * `disconnect()` touches all of this on the way out too — two members through + * the same helpers, three hand-rolled in both places — so a sixth member + * added here has to be added there as well. One of the three is *paired* + * rather than duplicated: `modernLogLevel` is re-derived here and blanked + * there, deliberately (see the comment at that site). It is not the only * way a session ends — a crash, or a failed connect the caller retries on * this same instance (the auth-recovery path), both leave it behind. Called * start-clean from `connect()` so every route in is covered. @@ -4705,9 +4707,15 @@ export class InspectorClient extends InspectorClientEventTarget { }); // Observe termination; an unexpected drop reconnects by re-listing. - void subscription.closed.then((reason) => - this.onModernSubscriptionClosed(subscription, reason, generation), - ); + void subscription.closed + .then((reason) => + this.onModernSubscriptionClosed(subscription, reason, generation), + ) + // A `closed` that rejects carries no reason to act on — and an unhandled + // rejection ends a Node process by default. Reachable since the + // connect-path reset started closing streams that previously sat with + // `closed` pending forever. + .catch(() => {}); } /** From 4669e87ebd4530ac5c78ccb533a9a048114d2a92 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:00:19 -0400 Subject: [PATCH 49/58] =?UTF-8?q?review:=20round-48=20=E2=80=94=20scope=20?= =?UTF-8?q?the=20catch,=20guard=20the=20close,=20index=20the=20test=20(#17?= =?UTF-8?q?97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The .catch comment claimed the connect-path close made a *rejecting* `closed` reachable. It doesn't: closing resolves it. What that close newly reaches is the handler running at all, where the reference used to be dropped with `closed` pending forever. And chaining after `.then` also swallowed handler throws — silently abandoning a re-listen on the path whose job is recovering a dropped stream. Now the second argument to `.then`, scoped to the rejection, with the comment saying which half is which. - `closing?.close()` reaches into third-party code from `disconnect()`'s straight-line teardown, none of which is inside a `try` — a synchronous throw would skip the raw-wire rejects, the task aborts, the receiver-task clear and nine dispatches, silently, since most callers catch a disconnect(). Best-effort now against both failure modes. - Header indexes the listen-stream test: same category, but a release obligation rather than a misread one. - Its comment described the generation guard, which the stub can't exercise; marked as production behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr --- ...nspectorClient-peer-handler-timing.test.ts | 8 +++-- core/mcp/inspectorClient.ts | 31 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index ae4191214..79e89b865 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -76,7 +76,10 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * same instance means ending a session is not the only way a new one begins. * Reset, not cleared: the log level is re-derived from the server setting * rather than dropped, so a mid-session override does not carry over and the - * configured level is not lost. + * configured level is not lost. The same category covers a release obligation, + * not just a misread one: the reset must close what it drops, since a + * `connect()` reusing a transport an `onerror` left up can be holding the last + * reference to a live stream. * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually @@ -881,7 +884,8 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // An `onerror` without an `onclose` leaves the transport up, and `connect()` // reuses it — so the reference the reset drops can be the last one to a // stream still open on the server. Nothing else can close it afterwards: - // the `closed` handler bails on the bumped generation. + // the `closed` handler bails on the bumped generation (Production behaviour; + // the stub here has no `closed` promise, so that handler never runs.) const transport = new SampleAfterConnectTransport(); // Counted, because transport *reuse* is what makes "a stream still open on // the server" true — if a future change recreated it here, the scenario diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index f2c084b53..bd9f960be 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1445,8 +1445,16 @@ export class InspectorClient extends InspectorClientEventTarget { // helper exists to prevent, so its own dispatches must not expose it. this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.dispatchSubscriptionsChange(); - // After the dispatches, so the ordering above is unaffected. - closing?.close().catch(() => {}); + // After the dispatches, so the ordering above is unaffected. Best-effort + // against both failure modes: `.catch` covers a rejected promise, the + // `try` a synchronous throw from a third-party `close()` — which would + // otherwise abort the caller's remaining teardown, and `disconnect()` runs + // most of its outside any `try`. + try { + closing?.close().catch(() => {}); + } catch { + // best-effort + } } /** @@ -4707,15 +4715,18 @@ export class InspectorClient extends InspectorClientEventTarget { }); // Observe termination; an unexpected drop reconnects by re-listing. - void subscription.closed - .then((reason) => + void subscription.closed.then( + (reason) => this.onModernSubscriptionClosed(subscription, reason, generation), - ) - // A `closed` that rejects carries no reason to act on — and an unhandled - // rejection ends a Node process by default. Reachable since the - // connect-path reset started closing streams that previously sat with - // `closed` pending forever. - .catch(() => {}); + // A `closed` that rejects carries no reason to act on, and an unhandled + // rejection ends a Node process by default. Scoped to the rejection, not + // chained after the handler: a throw from `onModernSubscriptionClosed` + // should surface rather than silently abandon a re-listen. (Closing a + // stream *resolves* `closed`; what the connect-path close newly reaches + // is the handler running at all, where the reference used to be dropped + // with `closed` pending forever.) + () => {}, + ); } /** From 3e7ac45187c0b4a8e5b16b78104fc36fd90680b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:14:54 -0400 Subject: [PATCH 50/58] =?UTF-8?q?review:=20round-49=20=E2=80=94=20pin=20th?= =?UTF-8?q?e=20best-effort=20close,=20unorphan=20the=20doc=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 48 made `resetSubscriptionStream`'s close best-effort against both a rejected promise and a synchronous throw, but nothing exercised either arm: both `modernSubscription` stubs in the tree resolve. On the `disconnect()` path an escaping failure would silently skip the raw-wire rejects, the paused task-input aborts, the tool-call abort and `clearReceiverTasks()` — the findings rounds 13 and 30 closed, re-opened at once with a green suite. Two table-driven cases now seed a `close()` that throws and one that rejects, and assert teardown continued past it. Also: the `.then` comment gave opposite verdicts on one mechanism (an unhandled rejection is named unacceptable, then chosen for a handler throw) — reworded to say the handler cannot throw today and why the shape is defensive; and `refreshModernSubscription`'s doc block, orphaned two methods up since before this branch, moved onto the method it describes. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 60 ++++++++++++++++++- core/mcp/inspectorClient.ts | 33 +++++----- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 79e89b865..58f4be58b 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -79,7 +79,11 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * configured level is not lost. The same category covers a release obligation, * not just a misread one: the reset must close what it drops, since a * `connect()` reusing a transport an `onerror` left up can be holding the last - * reference to a live stream. + * reference to a live stream. That release is best-effort in both directions — + * it is also covered that a `close()` failing either way, synchronously or by + * rejecting, does not escape into the straight-line teardown around the reset's + * other caller, `disconnect()`, whose remaining steps most callers would never + * see skipped. * * Some cases cover the other side of the registration gates. Client capabilities * are fixed at construction, so each gate must key off what was actually @@ -884,8 +888,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // An `onerror` without an `onclose` leaves the transport up, and `connect()` // reuses it — so the reference the reset drops can be the last one to a // stream still open on the server. Nothing else can close it afterwards: - // the `closed` handler bails on the bumped generation (Production behaviour; - // the stub here has no `closed` promise, so that handler never runs.) + // the `closed` handler bails on the bumped generation (production + // behaviour, not asserted here — the stub below has no `closed` promise, so + // that handler never runs). const transport = new SampleAfterConnectTransport(); // Counted, because transport *reuse* is what makes "a stream still open on // the server" true — if a future change recreated it here, the scenario @@ -907,6 +912,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { let closed = false; // Seeded directly: establishing a real listen stream needs a modern server // answering `subscriptions/listen`, which adds nothing to what is tested. + // No public writer, hence the cast. ( client as unknown as { modernSubscription: { close: () => Promise } | null; @@ -926,6 +932,54 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + for (const [label, close] of [ + [ + "throws synchronously", + (): Promise => { + throw new Error("close blew up"); + }, + ], + [ + "returns a rejected promise", + (): Promise => Promise.reject(new Error("close blew up")), + ], + ] as const) { + it(`continues teardown when the dropped stream's close() ${label}`, async () => { + // The close is best-effort against *both* failure modes because it runs + // from `disconnect()`, whose teardown is straight-line: an escaping + // failure would skip the raw-wire rejects, the paused task-input aborts, + // the in-flight tool-call abort and `clearReceiverTasks()` — silently, + // since most callers catch `disconnect()`. So the release obligation is + // pinned from the other side too: the reset closes what it drops, and a + // `close()` that fails does not take the rest of teardown with it. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // Seeded directly, for the reasons the two tests above give. No public + // writer for either, hence the casts. + ( + client as unknown as { + modernSubscription: { close: () => Promise } | null; + } + ).modernSubscription = { close }; + + // A downstream teardown step, to witness that teardown continued. + const controller = new AbortController(); + ( + client as unknown as { + taskInputAbortControllers: Map; + } + ).taskInputAbortControllers.set("task-1", controller); + + await expect(client.disconnect()).resolves.toBeUndefined(); + expect(controller.signal.aborted).toBe(true); + }); + } + it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index bd9f960be..7459d46fc 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4648,6 +4648,14 @@ export class InspectorClient extends InspectorClientEventTarget { return filter; } + /** Cancel a pending reconnect re-listen, if any (#1630). */ + private clearModernReconnectTimer(): void { + if (this.modernReconnectTimer !== undefined) { + clearTimeout(this.modernReconnectTimer); + this.modernReconnectTimer = undefined; + } + } + /** * (Re-)establish the modern `subscriptions/listen` stream to match the current * `subscribedResources` set (#1630). Because the stream is not resumable, @@ -4658,14 +4666,6 @@ export class InspectorClient extends InspectorClientEventTarget { * while this one awaits its acknowledgement, the just-opened stream is * discarded rather than overwriting the newer one. */ - /** Cancel a pending reconnect re-listen, if any (#1630). */ - private clearModernReconnectTimer(): void { - if (this.modernReconnectTimer !== undefined) { - clearTimeout(this.modernReconnectTimer); - this.modernReconnectTimer = undefined; - } - } - private async refreshModernSubscription( fromReconnect = false, ): Promise { @@ -4718,13 +4718,16 @@ export class InspectorClient extends InspectorClientEventTarget { void subscription.closed.then( (reason) => this.onModernSubscriptionClosed(subscription, reason, generation), - // A `closed` that rejects carries no reason to act on, and an unhandled - // rejection ends a Node process by default. Scoped to the rejection, not - // chained after the handler: a throw from `onModernSubscriptionClosed` - // should surface rather than silently abandon a re-listen. (Closing a - // stream *resolves* `closed`; what the connect-path close newly reaches - // is the handler running at all, where the reference used to be dropped - // with `closed` pending forever.) + // The rejection arm exists because a `closed` that rejects carries no + // reason to act on, and an unhandled rejection ends a Node process by + // default. It is scoped to the rejection rather than chained after the + // handler because the handler cannot throw today — it only assigns state, + // dispatches on a native `EventTarget` (which reports listener throws + // rather than propagating them) and arms a timer — and chaining a + // `.catch` after it would silently abandon a re-listen if that ever + // changed. (Closing a stream *resolves* `closed`; what the connect-path + // close newly reaches is the handler running at all, where the reference + // used to be dropped with `closed` pending forever.) () => {}, ); } From 69b0a0cda9470f7db50486ffff1bb0aad89c189f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:32:07 -0400 Subject: [PATCH 51/58] =?UTF-8?q?review:=20round-50=20=E2=80=94=20sweep=20?= =?UTF-8?q?the=20queues=20start-clean,=20unify=20the=20close=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resetSessionState`'s doc excluded the peer-request queues and the raw-wire map because "the teardown paths handle them" — true of the paths that run. An `onerror` without an `onclose` runs none of them: it flips status to "error" and leaves `baseTransport` cached, so a `connect()` on the same instance reuses a live transport. That is the route the round-46 stream close was built on, and it strands these two the same way — the web pending-request modal is derived from the queue length with no status gate, so it outlives the session, and answering it writes a response for the old request id onto the new connection. Both are now swept start-clean beside the reset (both helpers are idempotent, so the routes that already ran them are unaffected), the end-clean clears stay put for the `disconnect`-event consumer, and both docs say which is which. Also folds the three subscription `close()` sites onto one `closeSubscriptionBestEffort` helper, so all three absorb a synchronous throw as well as a rejection — at `refreshModernSubscription`'s first site the reference is already dropped, so a throw there both leaks a live stream and aborts the refresh before its replacement is opened. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 107 +++++++++++++++++- core/mcp/inspectorClient.ts | 67 ++++++++--- 2 files changed, 154 insertions(+), 20 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 58f4be58b..8a995d34a 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -65,15 +65,26 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * catch needs no such call, because nothing populates the map before the * handshake and both terminal paths clear it. * + * Both directions carry the same caveat, and it is covered for each: those + * paths are every way *out*, which is not every way *in*. An `onerror` without + * an `onclose` only flips the status — it runs none of them, and leaves the + * transport cached for the next `connect()` to reuse — so the queue and the + * raw-wire map are swept start-clean at the top of `connect()` as well. The + * end-clean clears stay where they are regardless: a consumer handling the + * `disconnect` event has to see them already empty, which a sweep on the way + * back in cannot provide. + * * A further category is session scoping. Receiver tasks, resource * subscriptions, cancelled task ids, paused task-input aborts and the modern * log-level opt-in are all scoped to one connection — `tasks/list` is answered * from that map, and a stale subscription makes the modern subscribe a silent * no-op — so they belong to the session that created them. Note the contrast - * with the teardown cases above: the peer-request queue is cleared end-clean - * on all three teardown paths, while this is *reset* start-clean at the top of - * `connect()`, because a crash or a failed connect the caller retries on the - * same instance means ending a session is not the only way a new one begins. + * with the teardown cases above: the peer-request queue is cleared end-clean, + * because its emptiness has to be observable from the `disconnect` event, and + * only swept on the way in as a backstop; this has no such consumer, so it is + * *reset* start-clean at the top of `connect()` and nowhere else — a crash, or + * a failed connect the caller retries on the same instance, means ending a + * session is not the only way a new one begins. * Reset, not cleared: the log level is re-derived from the server setting * rather than dropped, so a mid-session override does not carry over and the * configured level is not lost. The same category covers a release obligation, @@ -932,6 +943,94 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("clears a peer request the next connect would otherwise inherit", async () => { + // Same route as the test above, for the other collection it strands. An + // `onerror` without an `onclose` runs neither teardown path, so the queue + // the three teardown paths clear end-clean is still populated when + // `connect()` reuses the live transport — and the web modal is derived from + // its length with no status gate, so answering it would write a response + // for the old session's request id onto the new connection. `connect()` + // sweeps it start-clean for exactly this route. + const transport = new ElicitAfterConnectTransport(); + // Counted for the same reason as above: transport *reuse* is the premise. + let created = 0; + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { + transport: () => { + created++; + return { transport }; + }, + }, + }, + ); + const elicitationCounts: number[] = []; + client.addEventListener("pendingElicitationsChange", (event) => { + elicitationCounts.push((event as CustomEvent).detail.length); + }); + + await client.connect(); + const queued = waitForNewPendingRequest(client, "newPendingElicitation"); + transport.elicit(); + await queued; + expect(client.getPendingElicitations()).toHaveLength(1); + + // Status goes to "error" and stops — no `onclose`, transport left cached. + transport.onerror?.(new Error("stream broke, transport still up")); + expect(client.getPendingElicitations()).toHaveLength(1); + + await client.connect(); + + expect(client.getPendingElicitations()).toEqual([]); + // Announced, not just emptied: the modal reads the change event. + expect(elicitationCounts.at(-1)).toBe(0); + expect(created).toBe(1); + + await client.disconnect(); + }); + + it("rejects an in-flight raw-wire request the next connect would inherit", async () => { + // The outbound half of the same route. Milder than the queue — the id is + // instance-scoped and monotonic, so a stale entry can't be resolved by the + // new session's traffic — but it is round 30's symptom exactly: without the + // sweep the poll waits out its own 30s timer and blames a timeout for what + // was a crash. + const transport = new ElicitAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + // As in the crash-path case above: short so a regression names itself, + // and safe only because no SDK request is issued after connect. + timeout: 50, + }, + ); + await client.connect(); + + const pending = ( + client as unknown as { + rawWireRequest: ( + method: string, + params: Record, + schema: { parse: (v: unknown) => unknown }, + ) => Promise; + } + ).rawWireRequest("tasks/get", { taskId: "t1" }, ModernGetTaskResultSchema); + const settled = expect(pending).rejects.toThrow(/Connection ended/); + + // No `onclose`, so nothing settles it on the way out. + transport.onerror?.(new Error("stream broke, transport still up")); + await client.connect(); + + await withTimeout( + settled, + "raw-wire request not settled by the next connect", + ); + + await client.disconnect(); + }); + for (const [label, close] of [ [ "throws synchronously", diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 7459d46fc..3447b195e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -243,6 +243,26 @@ const TOOL_CALL_CANCELLED_REASON = "Tool call cancelled by user"; */ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; +/** + * Close a modern listen stream best-effort, absorbing both failure modes a + * third-party `close()` can produce: a rejected promise and a synchronous + * throw. Every close in this file goes through here, because at each of the + * three sites the caller has already dropped its reference to the stream, so + * an escaping failure abandons a stream that may still be open on the server + * *and* takes the caller's remaining work with it — teardown that most callers + * of `disconnect()` would never see skipped, or the re-listen that was about to + * replace the stream being closed (#1630, #1797). + */ +async function closeSubscriptionBestEffort( + subscription: Pick, +): Promise { + try { + await subscription.close(); + } catch { + // Best-effort: there is nothing to do about a stream that won't close. + } +} + /** * Extract the method literal from an MCP notification Zod schema (e.g. * `ToolListChangedNotificationSchema`), or `undefined` if the shape isn't @@ -1445,26 +1465,25 @@ export class InspectorClient extends InspectorClientEventTarget { // helper exists to prevent, so its own dispatches must not expose it. this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.dispatchSubscriptionsChange(); - // After the dispatches, so the ordering above is unaffected. Best-effort - // against both failure modes: `.catch` covers a rejected promise, the - // `try` a synchronous throw from a third-party `close()` — which would - // otherwise abort the caller's remaining teardown, and `disconnect()` runs - // most of its outside any `try`. - try { - closing?.close().catch(() => {}); - } catch { - // best-effort - } + // After the dispatches, so the ordering above is unaffected, and + // fire-and-forget because nothing downstream depends on it. Absorbing both + // failure modes matters most here: `disconnect()` calls this from the + // middle of a straight-line teardown that runs outside any `try`. + if (closing) void closeSubscriptionBestEffort(closing); } /** * Drop the session-scoped state a *new* session would misread — anything the * next server could be told about, or that would change how we treat its * traffic. State that only needs settling on the way out (the peer-request - * queues, the raw-wire map, the in-flight tool call) is handled by the - * teardown paths instead, not here — the in-flight tool call by `callTool`'s - * own `finally` once the SDK rejects it, the rest by `disconnect()` and the - * crash path. + * queues, the raw-wire map, the in-flight tool call) is not reset here — the + * in-flight tool call because `callTool`'s own `finally` releases it once the + * SDK rejects it, the other two because they are settled *end*-clean, by + * `disconnect()` and the crash path, so that a consumer handling the + * `disconnect` event already sees them empty. That is a difference in when, + * not in whether: the routes out do not cover every route *in* (an `onerror` + * without an `onclose` runs neither), so `connect()` sweeps those two beside + * this call as a backstop — see the comment there. * * `disconnect()` touches all of this on the way out too — two members through * the same helpers, three hand-rolled in both places — so a sixth member @@ -1569,6 +1588,22 @@ export class InspectorClient extends InspectorClientEventTarget { // Start from a clean session — see `resetSessionState` for why this is // start-clean rather than relying on `disconnect()`. this.resetSessionState(); + // The two collections `resetSessionState` excludes as "settled on the way + // out", swept here as well — because one route out settles nothing. An + // `onerror` without an `onclose` only flips status to `"error"`: it runs + // neither teardown path, and it leaves `baseTransport` cached, so a + // `connect()` on this same instance reuses a *live* transport. That is the + // route the subscription-stream close exists for, and it strands these two + // the same way. The peer queue is the sharper of them — the web + // pending-request modal is derived from its length with no status gate, so + // it outlives the session, and answering it writes a response for the old + // request id onto the reused connection. Both helpers are idempotent (one + // guards on a non-empty queue, the other clears its map and re-rejecting a + // settled promise is a no-op), so these are no-ops on the routes that + // already ran them; and anything still pending here belongs to a session + // that is, by definition, no longer connected. + this.clearAndAnnouncePendingPeerRequests(); + this.rejectPendingRawWireRequests("Connection ended"); const oauthManager = this.oauthManager; if ( @@ -4683,7 +4718,7 @@ export class InspectorClient extends InspectorClientEventTarget { const previous = this.modernSubscription; this.modernSubscription = null; if (previous) { - await previous.close().catch(() => {}); + await closeSubscriptionBestEffort(previous); } // Nothing subscribed → keep the stream closed. @@ -4699,7 +4734,7 @@ export class InspectorClient extends InspectorClientEventTarget { // A newer refresh superseded us while awaiting the ack — discard this one. if (generation !== this.modernListenGeneration) { - await subscription.close().catch(() => {}); + await closeSubscriptionBestEffort(subscription); return; } From 6cf9ae0436725ee24f344eef6ce41d11658c0029 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:50:26 -0400 Subject: [PATCH 52/58] =?UTF-8?q?review:=20round-51=20=E2=80=94=20pin=20th?= =?UTF-8?q?e=20sweep's=20ordering,=20name=20the=20category=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect-start sweep must sit after `resetSessionState()`, and the two calls read as independent. Cancelling a task-augmented peer request settles it synchronously into the record callback, which ends in `upsertReceiverTask` — a no-op only because `clearReceiverTasks()` just emptied the map. Hoisted, it would emit a `notifications/tasks/status` for the outgoing session's task onto the transport the connect is about to reuse. Stated at the call and pinned by a test (which fails on the hoist). Adding that third caller also made five docs stale, all describing the old set by count or as "teardown" — the class round 23 diagnosed. All five now name the category: every route out, plus the top of `connect()` as a backstop for the one route in that settles nothing. And the site where round 50's fold actually changed behaviour — `refreshModernSubscription`'s first close, where the reference is already dropped and a `.catch()` never caught a synchronous throw — is now tested from the public surface (a re-list over a poisoned stream), table-driven over both arms. Verified to fail on the pre-fold form. Closes #1797 --- clients/web/src/App.tsx | 4 +- ...nspectorClient-peer-handler-timing.test.ts | 50 ++++++++++++++++++- .../inspectorClient-subscriptions-era.test.ts | 48 ++++++++++++++++++ core/mcp/elicitationCreateMessage.ts | 10 ++-- core/mcp/inspectorClient.ts | 39 +++++++++++---- core/mcp/samplingCreateMessage.ts | 5 +- 6 files changed, 134 insertions(+), 22 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index ebd620e23..aa46f6518 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1223,8 +1223,8 @@ function App() { // client instance rather than rebuilding it (#1629, #1797). // `activeServerIdRef` is synced in a passive effect, so it still holds the // outgoing server's id when this runs from `onDisconnect` — which is what - // lets the re-seed find its settings. Clearing that ref eagerly would - // silently drop this back to the default. + // lets the re-seed find its settings. Clearing that ref eagerly would take + // the no-server branch below and silently drop this to Off. // Branch on the *server*, not its settings: an entry with no settings node // is the common case (`mcp.json` written by hand, never opened in Server // Settings), and there the default is right — it is what the seed and the diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 8a995d34a..74df08024 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -361,6 +361,9 @@ class SampleAfterConnectTransport implements Transport { static readonly SAMPLE_ID = 9401; + /** Methods of every client→server notification sent, in order. */ + readonly sentNotifications: string[] = []; + /** Resolvers for {@link injectRequest}, keyed by the id awaiting a reply. */ private readonly waiters = new Map void>(); @@ -410,6 +413,10 @@ class SampleAfterConnectTransport implements Transport { } async send(message: JSONRPCMessage): Promise { + if ("method" in message && !("id" in message)) { + this.sentNotifications.push(message.method); + return; + } if ( "method" in message && message.method === "initialize" && @@ -990,6 +997,41 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + it("sweeps the queue without reporting the outgoing session's task", async () => { + // The sweep must stay *after* `resetSessionState()`, and the two calls read + // as independent. Cancelling a task-augmented peer request settles it + // synchronously into the record callback, which ends in + // `upsertReceiverTask` — a no-op only because `clearReceiverTasks()` just + // emptied the map. Hoisted above the reset, it would emit a + // `notifications/tasks/status` for the ending session's task onto the + // transport this connect is about to reuse. This is what notices. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + receiverTasks: true, + }, + ); + await client.connect(); + + const queued = waitForNewPendingRequest(client, "newPendingSample"); + transport.sampleAsTask(); + await queued; + + transport.onerror?.(new Error("stream broke, transport still up")); + // Only the reconnect's frames are under test; the first session legitimately + // reported the task it created. + transport.sentNotifications.length = 0; + await client.connect(); + + expect(transport.sentNotifications).not.toContain( + "notifications/tasks/status", + ); + + await client.disconnect(); + }); + it("rejects an in-flight raw-wire request the next connect would inherit", async () => { // The outbound half of the same route. Milder than the queue — the id is // instance-scoped and monotonic, so a stale entry can't be resolved by the @@ -1001,8 +1043,12 @@ describe("InspectorClient peer-handler timing (#1797)", () => { { type: "stdio", command: "noop", args: [] }, { environment: { transport: () => ({ transport }) }, - // As in the crash-path case above: short so a regression names itself, - // and safe only because no SDK request is issued after connect. + // Short so a regression names itself, as in the crash-path case above + // — but safe here for a different reason than that one gives: this + // test connects twice, so an SDK `initialize` *is* issued under the + // client-wide budget. It can't race because this fixture answers + // `initialize` synchronously from inside `send()`, so no timer is ever + // in play. A fixture that answered a macrotask later would flake. timeout: 50, }, ); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index a0ba41007..9c1770df5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -297,6 +297,54 @@ describe("resource subscriptions era fork (#1630)", () => { return fake; } + for (const [label, close] of [ + [ + "throws synchronously", + (): Promise => { + throw new Error("close blew up"); + }, + ], + [ + "returns a rejected promise", + (): Promise => Promise.reject(new Error("close blew up")), + ], + ] as const) { + it(`re-lists when the superseded stream's close() ${label}`, async () => { + // A re-list drops its reference to the previous stream *before* closing + // it, so an escaping failure would both abandon a stream that may still + // be open on the server and abort the refresh before its replacement + // `listen()` — leaving a non-empty subscription set with no stream. The + // close is best-effort against both failure modes for that reason; this + // is the site where being best-effort against the *synchronous* one + // changed behaviour (a `.catch()` alone never caught it). + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + const int = internals(connected); + // Swap the live stream for a poisoned one, closing the real stream so + // the wire is torn down (as `installFakeSubscription` does). + const real = int.modernSubscription; + int.modernSubscription = { close } as unknown as McpSubscription; + await real?.close().catch(() => {}); + + // A second URI changes the filter, so this re-lists over the poisoned + // stream. + await expect( + connected.subscribeToResource(RESOURCE_URI_2), + ).resolves.toBeUndefined(); + + expect(connected.getSubscribedResources()).toEqual([ + RESOURCE_URI, + RESOURCE_URI_2, + ]); + const state = connected.getResourceSubscriptionStreamState(); + expect(state.active).toBe(true); + expect(state.status).toBe("acknowledged"); + expect(int.modernSubscription).not.toBeNull(); + }); + } + it("rolls back the optimistic add when listen() fails", async () => { const started = await startServer({}); const { connected } = await connect(started.url, "modern"); diff --git a/core/mcp/elicitationCreateMessage.ts b/core/mcp/elicitationCreateMessage.ts index 4b76ade03..347773c8a 100644 --- a/core/mcp/elicitationCreateMessage.ts +++ b/core/mcp/elicitationCreateMessage.ts @@ -103,11 +103,11 @@ export class ElicitationCreateMessage { /** * Settle a still-pending elicitation as cancelled, without removing it from * the queue. Called from `InspectorClient`'s `clearPendingPeerRequests()`, - * which serves all three teardown paths — an explicit `disconnect()`, a - * failed `connect()`, and a mid-session transport close — so an awaiting - * caller (notably the error-path `awaitUrlElicitation` that blocks - * `callTool`) doesn't hang forever when the pending queue is dropped - * wholesale. No-op once already resolved. + * which serves every route that drops the queue wholesale — each route out of + * a connection, and the top of `connect()` as a backstop for the one route in + * that settles nothing — so an awaiting caller (notably the error-path + * `awaitUrlElicitation` that blocks `callTool`) doesn't hang forever. The + * category, not a count: that set has grown. No-op once already resolved. * * Deliberately does not call `onRemove`: that caller iterates the queue and * clears it itself, so removing here would splice mid-iteration — skipping diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 3447b195e..6f199b411 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -246,8 +246,8 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; /** * Close a modern listen stream best-effort, absorbing both failure modes a * third-party `close()` can produce: a rejected promise and a synchronous - * throw. Every close in this file goes through here, because at each of the - * three sites the caller has already dropped its reference to the stream, so + * throw. All three stream closes go through here, because at each site the + * caller has already dropped its reference to the stream, so * an escaping failure abandons a stream that may still be open on the server * *and* takes the caller's remaining work with it — teardown that most callers * of `disconnect()` would never see skipped, or the re-listen that was about to @@ -1531,8 +1531,8 @@ export class InspectorClient extends InspectorClientEventTarget { * failed attempt; see the `connect()` catch). Callers dispatch the change * events themselves: * `disconnect()` batches them with its other teardown dispatches, and - * {@link clearAndAnnouncePendingPeerRequests} emits them immediately for the - * paths that end a connection without going through `disconnect()`. + * {@link clearAndAnnouncePendingPeerRequests} emits them immediately + * everywhere else. */ private clearPendingPeerRequests(): void { for (const sample of this.pendingSamples) { @@ -1546,12 +1546,16 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * {@link clearPendingPeerRequests} plus the change events, for the paths that - * drop a queue without going through `disconnect()` — a failed `connect()` - * and a mid-session transport close. (The connect-failure case doesn't always - * end the connection: when an auth provider holds the transport open, the - * caller re-authenticates and retries over it, and what's dropped is the - * queue left by the attempt that failed.) + * {@link clearPendingPeerRequests} plus the change events, for every route + * that drops a queue without going through `disconnect()`: the routes *out* + * that end a connection some other way, plus the top of `connect()` as a + * backstop for the one route in that settles nothing (an `onerror` without an + * `onclose` — see the comment at that call). Named as a category rather than + * counted, because the set has grown before. (One of the routes out doesn't + * always end the connection: when a `connect()` failure leaves an auth + * provider holding the transport open, the caller re-authenticates and + * retries over it, and what's dropped is the queue left by the attempt that + * failed.) * * The events are the load-bearing half: `usePendingClientRequests` tracks its * own state off them, so clearing the arrays without dispatching leaves the @@ -1602,6 +1606,15 @@ export class InspectorClient extends InspectorClientEventTarget { // settled promise is a no-op), so these are no-ops on the routes that // already ran them; and anything still pending here belongs to a session // that is, by definition, no longer connected. + // + // Must stay *after* `resetSessionState()`, which reads as independent of it + // but is not: cancelling a task-augmented peer request settles it + // synchronously into the record callback, which ends in + // `upsertReceiverTask`. That is a no-op only because `clearReceiverTasks()` + // just emptied the map — hoisted above the reset, it would instead emit a + // `notifications/tasks/status` for the outgoing session's task, onto the + // transport this connect is about to reuse, moments before the reset drops + // the record anyway. this.clearAndAnnouncePendingPeerRequests(); this.rejectPendingRawWireRequests("Connection ended"); @@ -2295,7 +2308,11 @@ export class InspectorClient extends InspectorClientEventTarget { return true; } - /** Reject and clear all pending raw-wire requests (on disconnect/teardown). */ + /** + * Reject and clear all pending raw-wire requests — on every route out that + * can hold one, and at the top of `connect()` for the route in that settles + * nothing (see the comment there). + */ private rejectPendingRawWireRequests(reason: string): void { for (const [, pending] of this.pendingRawWireRequests) { clearTimeout(pending.timer); diff --git a/core/mcp/samplingCreateMessage.ts b/core/mcp/samplingCreateMessage.ts index 9bb8f84e7..d4727cf4a 100644 --- a/core/mcp/samplingCreateMessage.ts +++ b/core/mcp/samplingCreateMessage.ts @@ -89,8 +89,9 @@ export class SamplingCreateMessage { /** * Settle a still-pending sample as cancelled, without removing it from the * queue. Called from `InspectorClient`'s `clearPendingPeerRequests()`, which - * serves all three teardown paths — an explicit `disconnect()`, a failed - * `connect()`, and a mid-session transport close. + * serves every route that drops the queue wholesale — each route out of a + * connection, and the top of `connect()` as a backstop for the one route in + * that settles nothing. The category, not a count: that set has grown. * * Unlike an elicitation there is no internal awaiter to unblock, but on the * plain server-request shape the *server* is one: we accepted its From aba28283ada4060cd8565b3b64e5af47b1e58676 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:04:43 -0400 Subject: [PATCH 53/58] =?UTF-8?q?review:=20round-52=20=E2=80=94=20say=20wh?= =?UTF-8?q?at=20the=20sweep=20prevents,=20scope=20the=20close=20doc=20(#17?= =?UTF-8?q?97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep's comment named as its harm the exact write the sweep performs: on this route the SDK's transport is still live, so cancelling a queued peer request emits a response for the old id immediately — which is right, and is the settle-don't-discard rule, at the best available moment. As written it read as an argument against emitting the frame, inviting the clear-without- settling regression rounds 24–25 removed. It now names the real hazard: a *user*-authored answer landing arbitrarily later, past the re-handshake. `closeSubscriptionBestEffort`'s "at each site" held at two of three — the superseded-generation discard stored nothing and has nothing following it, so its escaping failure would only reject an already-superseded `subscribeToResource`. Scoped, still wrapped. Also moves the `disconnect()`-side close pair next to its `connect()`-side twin, and states which axis the file's ordering follows, since route and category both describe it and cut differently. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 103 ++++++++++-------- core/mcp/inspectorClient.ts | 23 ++-- 2 files changed, 70 insertions(+), 56 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 74df08024..c238c8557 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -104,6 +104,13 @@ import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas * register an elicitation handler. Either mistake throws before the handshake, * so the client cannot connect at all. * + * A note on the ordering, since two axes describe this file equally well and + * they cut differently: the cases are grouped by *route* — everything the + * `onerror`-then-reconnect route has to settle sits together, rather than each + * collection sitting with its own category. Where a case is the counterpart of + * one in another group, it is placed next to its counterpart (the two halves of + * the stream-release obligation, one per route) rather than with its route. + * * The converse category is the advertised capability object itself: it must * only invite requests we actually serve, and so must the notifications we * emit. `capabilities.tasks.requests` names the server→client requests we @@ -950,6 +957,54 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); + for (const [label, close] of [ + [ + "throws synchronously", + (): Promise => { + throw new Error("close blew up"); + }, + ], + [ + "returns a rejected promise", + (): Promise => Promise.reject(new Error("close blew up")), + ], + ] as const) { + it(`continues teardown when the dropped stream's close() ${label}`, async () => { + // The close is best-effort against *both* failure modes because it runs + // from `disconnect()`, whose teardown is straight-line: an escaping + // failure would skip the raw-wire rejects, the paused task-input aborts, + // the in-flight tool-call abort and `clearReceiverTasks()` — silently, + // since most callers catch `disconnect()`. So the release obligation is + // pinned from the other side too: the reset closes what it drops, and a + // `close()` that fails does not take the rest of teardown with it. + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // Seeded directly, for the reasons the two tests above give. No public + // writer for either, hence the casts. + ( + client as unknown as { + modernSubscription: { close: () => Promise } | null; + } + ).modernSubscription = { close }; + + // A downstream teardown step, to witness that teardown continued. + const controller = new AbortController(); + ( + client as unknown as { + taskInputAbortControllers: Map; + } + ).taskInputAbortControllers.set("task-1", controller); + + await expect(client.disconnect()).resolves.toBeUndefined(); + expect(controller.signal.aborted).toBe(true); + }); + } + it("clears a peer request the next connect would otherwise inherit", async () => { // Same route as the test above, for the other collection it strands. An // `onerror` without an `onclose` runs neither teardown path, so the queue @@ -1077,54 +1132,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - for (const [label, close] of [ - [ - "throws synchronously", - (): Promise => { - throw new Error("close blew up"); - }, - ], - [ - "returns a rejected promise", - (): Promise => Promise.reject(new Error("close blew up")), - ], - ] as const) { - it(`continues teardown when the dropped stream's close() ${label}`, async () => { - // The close is best-effort against *both* failure modes because it runs - // from `disconnect()`, whose teardown is straight-line: an escaping - // failure would skip the raw-wire rejects, the paused task-input aborts, - // the in-flight tool-call abort and `clearReceiverTasks()` — silently, - // since most callers catch `disconnect()`. So the release obligation is - // pinned from the other side too: the reset closes what it drops, and a - // `close()` that fails does not take the rest of teardown with it. - const transport = new SampleAfterConnectTransport(); - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, - ); - await client.connect(); - - // Seeded directly, for the reasons the two tests above give. No public - // writer for either, hence the casts. - ( - client as unknown as { - modernSubscription: { close: () => Promise } | null; - } - ).modernSubscription = { close }; - - // A downstream teardown step, to witness that teardown continued. - const controller = new AbortController(); - ( - client as unknown as { - taskInputAbortControllers: Map; - } - ).taskInputAbortControllers.set("task-1", controller); - - await expect(client.disconnect()).resolves.toBeUndefined(); - expect(controller.signal.aborted).toBe(true); - }); - } - it("connects when an elicit option enables no mode", async () => { // `{ form: false, url: false }` is a valid option that advertises no // elicitation capability. Registering `elicitation/create` on `this.elicit` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 6f199b411..b22ba016d 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -246,12 +246,15 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; /** * Close a modern listen stream best-effort, absorbing both failure modes a * third-party `close()` can produce: a rejected promise and a synchronous - * throw. All three stream closes go through here, because at each site the - * caller has already dropped its reference to the stream, so - * an escaping failure abandons a stream that may still be open on the server - * *and* takes the caller's remaining work with it — teardown that most callers - * of `disconnect()` would never see skipped, or the re-listen that was about to - * replace the stream being closed (#1630, #1797). + * throw. All three stream closes go through here. At two of them the caller has + * already dropped its reference, so an escaping failure abandons a stream that + * may still be open on the server *and* takes the caller's remaining work with + * it — teardown that most callers of `disconnect()` would never see skipped, or + * the re-listen that was about to replace the stream being closed. The third + * (discarding a stream a newer refresh superseded) is milder: nothing stored it + * and nothing follows it, so an escaping failure would only reject a + * `subscribeToResource` whose replacement had already succeeded. Wrapped all the + * same, so the rule is one rule (#1630, #1797). */ async function closeSubscriptionBestEffort( subscription: Pick, @@ -1600,8 +1603,12 @@ export class InspectorClient extends InspectorClientEventTarget { // route the subscription-stream close exists for, and it strands these two // the same way. The peer queue is the sharper of them — the web // pending-request modal is derived from its length with no status gate, so - // it outlives the session, and answering it writes a response for the old - // request id onto the reused connection. Both helpers are idempotent (one + // it outlives the session, and a user answering it later would write + // *their* answer for the previous session's request id onto the new + // connection, arbitrarily far past the re-handshake. Note what the sweep + // does instead is emit a *cancel* for that same id, right here: still the + // settle-don't-discard rule, and this is the best moment for it, since the + // old connection is on the wire until `dropCachedTransport()` below. Both helpers are idempotent (one // guards on a non-empty queue, the other clears its map and re-rejecting a // settled promise is a no-op), so these are no-ops on the routes that // already ran them; and anything still pending here belongs to a session From 904cdcff56865757f87290c19ddb8c7b1269e0cd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:18:51 -0400 Subject: [PATCH 54/58] =?UTF-8?q?review:=20round-53=20=E2=80=94=20give=20e?= =?UTF-8?q?ach=20close=20site=20its=20real=20harm,=20pin=20it=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc grouped `resetSubscriptionStream`'s close with the "takes the caller's remaining work with it" sites. It can't: the call is `void`-ed onto an `async` helper, so even a synchronous `close()` throw becomes a dropped rejection — `disconnect()`'s teardown runs regardless. The harm there is an *unhandled* rejection, which this PR already established (the payloadPromise fix) is the process-fatal class in Node — so the one site with the severe failure mode was documented as having the mild one. Each site now carries its own consequence, and the local comment no longer gives two reasons that cancel each other. The test had the same gap: both its assertions hold with the helper's try/catch deleted. It now also asserts no unhandled rejection fired, which does fail without the guard (verified on both arms); the teardown assertions stay, since they still catch unwrapping the call to a bare `close()`. Also names the two back-references the round-52 move left positional, and softens the "on the wire until dropCachedTransport()" claim — that drop is conditional and never runs on stdio. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 49 ++++++++++++----- core/mcp/inspectorClient.ts | 53 ++++++++++++------- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index c238c8557..b58c7e62a 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -970,13 +970,23 @@ describe("InspectorClient peer-handler timing (#1797)", () => { ], ] as const) { it(`continues teardown when the dropped stream's close() ${label}`, async () => { - // The close is best-effort against *both* failure modes because it runs - // from `disconnect()`, whose teardown is straight-line: an escaping - // failure would skip the raw-wire rejects, the paused task-input aborts, - // the in-flight tool-call abort and `clearReceiverTasks()` — silently, - // since most callers catch `disconnect()`. So the release obligation is - // pinned from the other side too: the reset closes what it drops, and a - // `close()` that fails does not take the rest of teardown with it. + // The release obligation from the other side: the reset closes what it + // drops, and a `close()` that fails either way is absorbed. Be precise + // about *which* harm is absorbed at this site, because the two are not + // interchangeable: the call is `void`-ed onto an `async` helper, so even + // the synchronous throw becomes a dropped rejection rather than reaching + // `disconnect()` — the teardown below it runs regardless. What the + // wrapping prevents is the rejection going *unhandled*, which ends a Node + // process by default. Hence the listener: it is the assertion that fails + // if the helper's `try/catch` is removed. The two teardown assertions are + // kept for the neighbouring regression — unwrapping this to a bare + // `void closing.close()`, where the synchronous half would propagate. + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + const transport = new SampleAfterConnectTransport(); const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, @@ -984,8 +994,9 @@ describe("InspectorClient peer-handler timing (#1797)", () => { ); await client.connect(); - // Seeded directly, for the reasons the two tests above give. No public - // writer for either, hence the casts. + // Seeded directly, for the reasons `closes a live listen stream the next + // connect drops` and `aborts a paused task-input wait when the session + // ends` give. No public writer for either, hence the casts. ( client as unknown as { modernSubscription: { close: () => Promise } | null; @@ -1000,13 +1011,24 @@ describe("InspectorClient peer-handler timing (#1797)", () => { } ).taskInputAbortControllers.set("task-1", controller); - await expect(client.disconnect()).resolves.toBeUndefined(); - expect(controller.signal.aborted).toBe(true); + try { + await expect(client.disconnect()).resolves.toBeUndefined(); + expect(controller.signal.aborted).toBe(true); + + // Node reports an unhandled rejection after the microtask checkpoint, + // so yield to the macrotask queue before reading the listener — nothing + // else in this test would give it a chance to fire. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } }); } it("clears a peer request the next connect would otherwise inherit", async () => { - // Same route as the test above, for the other collection it strands. An + // Same route as `closes a live listen stream the next connect drops`, for + // the other collection it strands. An // `onerror` without an `onclose` runs neither teardown path, so the queue // the three teardown paths clear end-clean is still populated when // `connect()` reuses the live transport — and the web modal is derived from @@ -1014,7 +1036,8 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // for the old session's request id onto the new connection. `connect()` // sweeps it start-clean for exactly this route. const transport = new ElicitAfterConnectTransport(); - // Counted for the same reason as above: transport *reuse* is the premise. + // Counted for the same reason as that test: transport *reuse* is the + // premise. let created = 0; const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b22ba016d..12954c7cf 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -246,15 +246,25 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; /** * Close a modern listen stream best-effort, absorbing both failure modes a * third-party `close()` can produce: a rejected promise and a synchronous - * throw. All three stream closes go through here. At two of them the caller has - * already dropped its reference, so an escaping failure abandons a stream that - * may still be open on the server *and* takes the caller's remaining work with - * it — teardown that most callers of `disconnect()` would never see skipped, or - * the re-listen that was about to replace the stream being closed. The third - * (discarding a stream a newer refresh superseded) is milder: nothing stored it - * and nothing follows it, so an escaping failure would only reject a - * `subscribeToResource` whose replacement had already succeeded. Wrapped all the - * same, so the rule is one rule (#1630, #1797). + * throw. All three stream closes go through here, and every one of them has + * already dropped its reference to the stream — so in each case an escaping + * failure abandons a stream that may still be open on the server. What it does + * *besides* that differs per site, because the shape of the call does: + * + * - `resetSubscriptionStream` — fire-and-forget (`void`), and the last statement + * of the method. Because this helper is `async`, even a synchronous throw + * becomes a rejection of the promise it returns, which the `void` then drops: + * the caller is unaffected, and the harm is an *unhandled rejection* — fatal + * to a Node process by default, a console error in the browser. That, not + * skipped teardown, is what the wrapping buys at this site. + * - `refreshModernSubscription`'s re-listen close — awaited, with the + * replacement `listen()` after it, so an escaping failure skips the re-listen + * and leaves a non-empty subscription set with no stream. + * - the superseded-generation discard — awaited, but `return` follows, so an + * escaping failure would only reject a `subscribeToResource` whose + * replacement had already succeeded. The mildest of the three. + * + * Wrapped identically all the same, so the rule is one rule (#1630, #1797). */ async function closeSubscriptionBestEffort( subscription: Pick, @@ -1469,9 +1479,11 @@ export class InspectorClient extends InspectorClientEventTarget { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); this.dispatchSubscriptionsChange(); // After the dispatches, so the ordering above is unaffected, and - // fire-and-forget because nothing downstream depends on it. Absorbing both - // failure modes matters most here: `disconnect()` calls this from the - // middle of a straight-line teardown that runs outside any `try`. + // fire-and-forget because nothing downstream depends on it. That is also + // why the wrapping matters here in a different way than at the awaited + // sites: the `void` means a failure cannot reach the caller at all — so it + // cannot cut `disconnect()`'s straight-line teardown short — and what it + // would do instead is go unhandled, which ends a Node process by default. if (closing) void closeSubscriptionBestEffort(closing); } @@ -1607,12 +1619,17 @@ export class InspectorClient extends InspectorClientEventTarget { // *their* answer for the previous session's request id onto the new // connection, arbitrarily far past the re-handshake. Note what the sweep // does instead is emit a *cancel* for that same id, right here: still the - // settle-don't-discard rule, and this is the best moment for it, since the - // old connection is on the wire until `dropCachedTransport()` below. Both helpers are idempotent (one - // guards on a non-empty queue, the other clears its map and re-rejecting a - // settled promise is a no-op), so these are no-ops on the routes that - // already ran them; and anything still pending here belongs to a session - // that is, by definition, no longer connected. + // settle-don't-discard rule, and this is the earliest moment available: + // the old connection is still the one on the wire here, and stays so at + // least until the conditional `dropCachedTransport()` below — which on a + // stdio server never runs at all, so the same transport carries straight + // through the re-handshake. + // + // Both helpers are idempotent (one guards on a non-empty queue, the other + // clears its map and re-rejecting a settled promise is a no-op), so these + // are no-ops on the routes that already ran them; and anything still + // pending here belongs to a session that is, by definition, no longer + // connected. // // Must stay *after* `resetSessionState()`, which reads as independent of it // but is not: cancelling a task-augmented peer request settles it From 88cbd1acb1986101c8ec97df29bd5e00e926f15d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:32:02 -0400 Subject: [PATCH 55/58] =?UTF-8?q?review:=20round-54=20=E2=80=94=20the=20mi?= =?UTF-8?q?ldest=20site=20isn't=20mild=20from=20every=20caller=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshModernSubscription` has three callers, and the superseded-generation discard's failure reaches all of them. From `unsubscribeFromResource` — which deliberately keeps the removal when a re-listen fails — it surfaces "Failed to unsubscribe" for an unsubscribe that actually stuck, which is worse than the consequence the doc assigned to the site it called mildest. The re-listen bullet had the mirror gap: on the reconnect caller the escape is absorbed into the backoff run, not a set left with no stream. Both now name their callers, and the generation-bump claim is softened to what it proves (a newer refresh *started*). Test: the `unhandledRejection` listener is process-wide, so the assertion now filters to this fixture's own reason rather than failing on a rejection some other test left pending; and it is registered inside the `try` whose `finally` removes it, so a throw in the setup can't leak it into the rest of the file. Re-verified the filtered form still fails on both arms with the helper's try/catch removed. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 61 +++++++++++-------- core/mcp/inspectorClient.ts | 19 ++++-- 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index b58c7e62a..3b9a0637c 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -985,33 +985,35 @@ describe("InspectorClient peer-handler timing (#1797)", () => { const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; + // Registered from here, so every path out of the test removes it — a leak + // would otherwise accumulate across the rest of the file into an array + // nobody reads. process.on("unhandledRejection", onUnhandled); - - const transport = new SampleAfterConnectTransport(); - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, - ); - await client.connect(); - - // Seeded directly, for the reasons `closes a live listen stream the next - // connect drops` and `aborts a paused task-input wait when the session - // ends` give. No public writer for either, hence the casts. - ( - client as unknown as { - modernSubscription: { close: () => Promise } | null; - } - ).modernSubscription = { close }; - - // A downstream teardown step, to witness that teardown continued. - const controller = new AbortController(); - ( - client as unknown as { - taskInputAbortControllers: Map; - } - ).taskInputAbortControllers.set("task-1", controller); - try { + const transport = new SampleAfterConnectTransport(); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { environment: { transport: () => ({ transport }) } }, + ); + await client.connect(); + + // Seeded directly, for the reasons `closes a live listen stream the + // next connect drops` and `aborts a paused task-input wait when the + // session ends` give. No public writer for either, hence the casts. + ( + client as unknown as { + modernSubscription: { close: () => Promise } | null; + } + ).modernSubscription = { close }; + + // A downstream teardown step, to witness that teardown continued. + const controller = new AbortController(); + ( + client as unknown as { + taskInputAbortControllers: Map; + } + ).taskInputAbortControllers.set("task-1", controller); + await expect(client.disconnect()).resolves.toBeUndefined(); expect(controller.signal.aborted).toBe(true); @@ -1019,7 +1021,14 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // so yield to the macrotask queue before reading the listener — nothing // else in this test would give it a chance to fire. await new Promise((resolve) => setTimeout(resolve, 0)); - expect(unhandled).toEqual([]); + // Filtered to this fixture's own reason: the listener is process-wide, + // so a rejection another test left pending and Node reported late would + // otherwise fail here, blaming the wrong test. + expect( + unhandled.filter((reason) => + String(reason).includes("close blew up"), + ), + ).toEqual([]); } finally { process.off("unhandledRejection", onUnhandled); } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 12954c7cf..9a1f8ae2e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -259,10 +259,21 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; * skipped teardown, is what the wrapping buys at this site. * - `refreshModernSubscription`'s re-listen close — awaited, with the * replacement `listen()` after it, so an escaping failure skips the re-listen - * and leaves a non-empty subscription set with no stream. - * - the superseded-generation discard — awaited, but `return` follows, so an - * escaping failure would only reject a `subscribeToResource` whose - * replacement had already succeeded. The mildest of the three. + * and leaves a non-empty subscription set with no stream. On the reconnect + * caller it is instead absorbed into the backoff run and retried. + * - the superseded-generation discard — awaited, but `return` follows, so + * nothing local is skipped; the failure only propagates out of + * `refreshModernSubscription` to whichever caller a newer refresh had already + * superseded. Mild from `subscribeToResource` (which rolls the optimistic add + * back and rethrows) and self-healing on the reconnect path, but *not* from + * `unsubscribeFromResource`, which deliberately keeps the removal when a + * re-listen fails — so there it surfaces a "Failed to unsubscribe" for an + * unsubscribe that actually stuck. + * + * Note what the generation bump proves in that last case is that a newer + * refresh had *started*, not that it succeeded — it may yet fail at its own + * `listen()`, making an escaping failure here a second one rather than a + * redundant one. * * Wrapped identically all the same, so the rule is one rule (#1630, #1797). */ From edb01fccf6bc6cdcc85f5cd20697077670f41984 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:46:25 -0400 Subject: [PATCH 56/58] fix(core): don't roll back a subscribe a newer refresh superseded (#1797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subscribeToResource`'s rollback assumes what its comment says — that the server filter is unchanged — which is exactly what supersession falsifies. The newer refresh built its filter from the set *including* this URI, so on success the server honors it; rolling back leaves the set missing a URI the live stream carries (the UI showing it unsubscribed while its resources/updated keep arriving) and, if it was the only one, an empty set with an active stream — the combination `resetSubscriptionStream` exists to prevent, reached from the other end. Reachable without any close() failure: a rejecting `listen()` propagates the same way, which a user subscribing while the reconnect timer re-lists — i.e. exactly when the server is flaky — can produce. The rollback is now gated on the generation not having advanced past this call's own bump; the error still reaches the caller either way. Test drives two overlapping subscribes with the first `listen()` rejecting, and fails without the gate. The doc bullet that called this the mild site is corrected with it: both user-initiated callers report a failure that did not happen, in mirrored ways. Also gives the two close-failure arms distinct reasons so the unhandled-rejection filter can exclude the sibling arm as well as a foreign rejection. Closes #1797 --- ...nspectorClient-peer-handler-timing.test.ts | 22 +++++----- .../inspectorClient-subscriptions-era.test.ts | 43 +++++++++++++++++++ core/mcp/inspectorClient.ts | 36 +++++++++++----- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 3b9a0637c..84f019cc3 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -957,16 +957,20 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - for (const [label, close] of [ + // Distinct reasons per arm, so the filter below excludes the sibling arm's + // late-reported rejection as well as a foreign one. + for (const [label, reason, close] of [ [ "throws synchronously", + "close blew up (sync)", (): Promise => { - throw new Error("close blew up"); + throw new Error("close blew up (sync)"); }, ], [ "returns a rejected promise", - (): Promise => Promise.reject(new Error("close blew up")), + "close blew up (async)", + (): Promise => Promise.reject(new Error("close blew up (async)")), ], ] as const) { it(`continues teardown when the dropped stream's close() ${label}`, async () => { @@ -1021,14 +1025,10 @@ describe("InspectorClient peer-handler timing (#1797)", () => { // so yield to the macrotask queue before reading the listener — nothing // else in this test would give it a chance to fire. await new Promise((resolve) => setTimeout(resolve, 0)); - // Filtered to this fixture's own reason: the listener is process-wide, - // so a rejection another test left pending and Node reported late would - // otherwise fail here, blaming the wrong test. - expect( - unhandled.filter((reason) => - String(reason).includes("close blew up"), - ), - ).toEqual([]); + // Filtered to this arm's own reason: the listener is process-wide, so a + // rejection another test — or the sibling arm — left pending and Node + // reported late would otherwise fail here, blaming the wrong one. + expect(unhandled.filter((r) => String(r).includes(reason))).toEqual([]); } finally { process.off("unhandledRejection", onUnhandled); } diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 9c1770df5..4994c0aa5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -345,6 +345,49 @@ describe("resource subscriptions era fork (#1630)", () => { }); } + it("keeps the optimistic add when a newer refresh superseded the failure", async () => { + // The rollback's premise is that the server filter is unchanged, which is + // exactly what supersession falsifies: the newer refresh built its filter + // from the set *including* this URI, so on success the server honors it. + // Rolling back anyway would leave the set missing a URI the live stream + // carries — and with only one subscribed, an empty set with an active + // stream, the combination `resetSubscriptionStream` exists to prevent. + // Reachable without any close() failure: a rejecting `listen()` does it, + // which is what a user subscribing while the reconnect timer re-lists + // (i.e. exactly when the server is flaky) can produce. + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + const int = internals(connected); + + const real = int.client.listen; + // The first listen hangs, so the second can supersede it before it fails. + let failFirst: (error: Error) => void = () => {}; + int.client.listen = () => + new Promise((_, reject) => { + failFirst = reject; + }); + const first = connected.subscribeToResource(RESOURCE_URI); + // Attached before the supersession so the rejection is never unhandled. + const firstSettled = expect(first).rejects.toThrow(/listen boom/); + + // A newer refresh starts and acknowledges, carrying both URIs. + int.client.listen = real; + await connected.subscribeToResource(RESOURCE_URI_2); + + failFirst(new Error("listen boom")); + await firstSettled; + + // The superseded call still reports its failure, but leaves the filter to + // the refresh that owns it. + expect(connected.getSubscribedResources()).toEqual([ + RESOURCE_URI, + RESOURCE_URI_2, + ]); + const state = connected.getResourceSubscriptionStreamState(); + expect(state.active).toBe(true); + expect(state.status).toBe("acknowledged"); + }); + it("rolls back the optimistic add when listen() fails", async () => { const started = await startServer({}); const { connected } = await connect(started.url, "modern"); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9a1f8ae2e..42c86972b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -264,11 +264,12 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; * - the superseded-generation discard — awaited, but `return` follows, so * nothing local is skipped; the failure only propagates out of * `refreshModernSubscription` to whichever caller a newer refresh had already - * superseded. Mild from `subscribeToResource` (which rolls the optimistic add - * back and rethrows) and self-healing on the reconnect path, but *not* from - * `unsubscribeFromResource`, which deliberately keeps the removal when a - * re-listen fails — so there it surfaces a "Failed to unsubscribe" for an - * unsubscribe that actually stuck. + * superseded. Self-healing on the reconnect path, and on both user-initiated + * paths a report of a failure that did not happen: `unsubscribeFromResource` + * keeps its removal when a re-listen fails, so it surfaces a "Failed to + * unsubscribe" for an unsubscribe that stuck, and `subscribeToResource` would + * roll back an add the superseding refresh may already have had honored — + * which is why that rollback is gated on not having been superseded (see it). * * Note what the generation bump proves in that last case is that a newer * refresh had *started*, not that it succeeded — it may yet fail at its own @@ -4952,15 +4953,30 @@ export class InspectorClient extends InspectorClientEventTarget { status: "connecting", honoredUris: this.modernStreamState.honoredUris, }); + // Read before the call, to tell "our refresh failed" from "a newer one + // took over" in the catch — see there. + const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { // Roll back the optimistic add + stream state so both stay consistent - // with the (unchanged) server filter. - this.subscribedResources.delete(uri); - this.dispatchSubscriptionsChange(); - if (this.subscribedResources.size === 0) { - this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + // with the server filter — but only while that filter is still the + // unchanged one. A refresh bumps the generation exactly once, so + // anything beyond that means a newer refresh started, and it built + // its filter from the set *including* this URI: if it succeeded, the + // server is honoring the subscription, and rolling back would leave + // the set missing a URI the live stream carries (the UI showing it + // unsubscribed while its `resources/updated` keep arriving — and, if + // it was the only one, an empty set with an active stream, the + // combination `resetSubscriptionStream` exists to prevent). The newer + // refresh owns the filter either way, so leave it to say what the + // server honors. The error is still the caller's to see. + if (this.modernListenGeneration <= generationBefore + 1) { + this.subscribedResources.delete(uri); + this.dispatchSubscriptionsChange(); + if (this.subscribedResources.size === 0) { + this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + } } throw error; } From 2a3e002d3815cafc6f3c7e504c156e9ae1f5194b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 11:00:00 -0400 Subject: [PATCH 57/58] fix(core): reconcile the stream badge when a re-listen this call owned fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supersession gate left the both-fail case stuck: two overlapping subscribes whose listens both reject leave the superseded URI in the set (its rollback correctly skipped) while the state keeps the optimistic "connecting" — no stream, no reconnect armed (neither onModernSubscriptionClosed nor onModernReconnectFailed ran), so the badge never changes and no resources/updated ever arrive. That is the caveat this file already wrote down for the neighbouring case — the bump proves a newer refresh *started* — applied to the gate itself. Fixed in the branch that knows the answer: the call that still owns the re-listen reconciles the state instead of only handling an emptied set, reusing the "ended" badge onModernSubscriptionClosed already reports for "stream gone, URIs still subscribed". unsubscribeFromResource gets the same treatment — it rolls nothing back by design, but its failed re-listen left the badge reading Listening over no stream. Also: the gate's comment named one of the two things that bump the generation (resetSubscriptionStream also does, from disconnect and the start-clean reset) and cross-referenced the wrong invariant; both corrected, and `<=` tightened to `===`, which is what the monotonic synchronous bump actually guarantees. Closes #1797 --- .../inspectorClient-subscriptions-era.test.ts | 61 ++++++++++++ core/mcp/inspectorClient.ts | 97 ++++++++++++++----- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 4994c0aa5..a83086249 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -388,6 +388,67 @@ describe("resource subscriptions era fork (#1630)", () => { expect(state.status).toBe("acknowledged"); }); + it("ends the badge when both overlapping subscribes fail", async () => { + // The other half of the gate: skipping the superseded call's rollback is + // only safe if the superseding call reconciles when *it* fails too. + // Otherwise the set keeps the first URI while the state keeps the + // optimistic "connecting" — a badge that will never change, with no + // stream and no reconnect armed (neither `onModernSubscriptionClosed` nor + // `onModernReconnectFailed` ran, so nothing self-heals). + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + const int = internals(connected); + + let failFirst: (error: Error) => void = () => {}; + int.client.listen = () => + new Promise((_, reject) => { + failFirst = reject; + }); + const first = connected.subscribeToResource(RESOURCE_URI); + const firstSettled = expect(first).rejects.toThrow(/listen boom/); + + // The superseding call fails on its own listen. + int.client.listen = () => Promise.reject(new Error("listen boom 2")); + await expect( + connected.subscribeToResource(RESOURCE_URI_2), + ).rejects.toThrow(/listen boom 2/); + + failFirst(new Error("listen boom")); + await firstSettled; + + // Its own URI rolled back; the superseded one survives, and the badge + // reports the stream honestly rather than "connecting" forever. + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + const state = connected.getResourceSubscriptionStreamState(); + expect(state.active).toBe(true); + expect(state.status).toBe("ended"); + }); + + it("ends the badge when the unsubscribe's re-listen fails", async () => { + // `unsubscribeFromResource` keeps its removal when the re-listen fails, + // so nothing is rolled back — but the stream is gone with URIs still + // subscribed, and without the reconcile the badge would keep reporting + // the previous success ("acknowledged"/Listening) over no stream at all. + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + await connected.subscribeToResource(RESOURCE_URI_2); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + + const int = internals(connected); + int.client.listen = () => Promise.reject(new Error("re-listen boom")); + await expect( + connected.unsubscribeFromResource(RESOURCE_URI_2), + ).rejects.toThrow(/re-listen boom/); + + expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); + const state = connected.getResourceSubscriptionStreamState(); + expect(state.active).toBe(true); + expect(state.status).toBe("ended"); + }); + it("rolls back the optimistic add when listen() fails", async () => { const started = await startServer({}); const { connected } = await connect(started.url, "modern"); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 42c86972b..5e40b5c8e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -264,12 +264,13 @@ const DEFAULT_TASK_POLL_INTERVAL_MS = 500; * - the superseded-generation discard — awaited, but `return` follows, so * nothing local is skipped; the failure only propagates out of * `refreshModernSubscription` to whichever caller a newer refresh had already - * superseded. Self-healing on the reconnect path, and on both user-initiated - * paths a report of a failure that did not happen: `unsubscribeFromResource` - * keeps its removal when a re-listen fails, so it surfaces a "Failed to - * unsubscribe" for an unsubscribe that stuck, and `subscribeToResource` would - * roll back an add the superseding refresh may already have had honored — - * which is why that rollback is gated on not having been superseded (see it). + * superseded. Self-healing on the reconnect path; on the two user-initiated + * paths it is a report of a failure that did not happen — for + * `unsubscribeFromResource`, whose removal is kept regardless, a "Failed to + * unsubscribe" for an unsubscribe that stuck. Both of those gate their + * failure handling on still owning the re-listen, so a superseded call + * reports its error without editing a filter or a badge that is no longer + * its own (see `subscribeToResource`'s catch for the reasoning). * * Note what the generation bump proves in that last case is that a newer * refresh had *started*, not that it succeeded — it may yet fail at its own @@ -4865,6 +4866,38 @@ export class InspectorClient extends InspectorClientEventTarget { this.scheduleModernReconnect(); } + /** + * Reconcile the stream state after a re-listen *this* call owned and lost + * (#1797). Only correct for a caller that has not been superseded — a newer + * refresh owns the state as well as the filter — so both call sites gate on + * the generation first. + * + * The empty case is the ordinary one: nothing subscribed, no stream, inactive. + * The non-empty one exists because a failed re-listen leaves + * `modernSubscription` null with URIs still subscribed, and nothing else will + * notice: `scheduleModernReconnect` is reachable only from a stream that + * closed or a reconnect that failed, and neither happened here. Left alone, + * the state keeps whatever the last success (or the optimistic + * `"connecting"`) wrote — a badge that will never change and a stream that + * will never arrive. `"ended"` is what `onModernSubscriptionClosed` already + * reports for this exact shape, so this reuses that vocabulary rather than + * inventing a state: honest about the stream without claiming the + * subscriptions are gone. + */ + private reconcileModernStreamStateAfterFailedRefresh(): void { + if (this.subscribedResources.size === 0) { + this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + return; + } + if (this.modernSubscription === null) { + this.setModernStreamState({ + active: true, + status: "ended", + honoredUris: [], + }); + } + } + /** * Schedule a reconnect re-listen after the current backoff delay (#1630). * `modernReconnectAttempts` reflects the number of *consecutive failed* @@ -4953,30 +4986,35 @@ export class InspectorClient extends InspectorClientEventTarget { status: "connecting", honoredUris: this.modernStreamState.honoredUris, }); - // Read before the call, to tell "our refresh failed" from "a newer one + // Read before the call, to tell "our refresh failed" from "someone else // took over" in the catch — see there. const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { // Roll back the optimistic add + stream state so both stay consistent - // with the server filter — but only while that filter is still the - // unchanged one. A refresh bumps the generation exactly once, so - // anything beyond that means a newer refresh started, and it built - // its filter from the set *including* this URI: if it succeeded, the - // server is honoring the subscription, and rolling back would leave - // the set missing a URI the live stream carries (the UI showing it - // unsubscribed while its `resources/updated` keep arriving — and, if - // it was the only one, an empty set with an active stream, the - // combination `resetSubscriptionStream` exists to prevent). The newer - // refresh owns the filter either way, so leave it to say what the - // server honors. The error is still the caller's to see. - if (this.modernListenGeneration <= generationBefore + 1) { + // with the server filter — but only while this call still owns that + // filter. A refresh bumps the generation exactly once, synchronously, + // so anything past that bump means something else advanced it, and + // both bumpers make the rollback wrong: + // + // - a newer `refreshModernSubscription` built its filter from the set + // *including* this URI, so if it succeeded the server is honoring + // the subscription; deleting it here would leave the set missing a + // URI the live stream carries, the UI showing it unsubscribed while + // its `resources/updated` keep arriving. + // - `resetSubscriptionStream` (a `disconnect()`, or the start-clean + // reset in `connect()`) already cleared the set and set the state, + // so there is nothing to roll back — and if the caller has since + // re-subscribed on the new session, this stale catch would delete a + // URI that session legitimately holds. + // + // Either way the state is no longer ours to correct. The error is + // still the caller's to see. + if (this.modernListenGeneration === generationBefore + 1) { this.subscribedResources.delete(uri); this.dispatchSubscriptionsChange(); - if (this.subscribedResources.size === 0) { - this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); - } + this.reconcileModernStreamStateAfterFailedRefresh(); } throw error; } @@ -5013,7 +5051,20 @@ export class InspectorClient extends InspectorClientEventTarget { // The removal is the user's intent; keep it even if the re-listen fails // (the stale URI simply lingers in the server's honored filter). this.dispatchSubscriptionsChange(); - await this.refreshModernSubscription(); + // Same ownership test as `subscribeToResource` — see the long comment + // there. Nothing to undo on this path (the removal is kept + // deliberately), but the *state* still needs reconciling when this call + // owned the re-listen that failed: it leaves no stream behind, and the + // badge would otherwise keep reporting the last success. + const generationBefore = this.modernListenGeneration; + try { + await this.refreshModernSubscription(); + } catch (error) { + if (this.modernListenGeneration === generationBefore + 1) { + this.reconcileModernStreamStateAfterFailedRefresh(); + } + throw error; + } } else { await this.client.unsubscribeResource( { uri }, From e4b8692e215ed89c3337613d02abfa2b07fe8932 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 11:15:00 -0400 Subject: [PATCH 58/58] fix(core): order the subscription writes, retry instead of stranding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sites emptied the subscription set and announced it before the stream state caught up — the last-URI rollback in subscribeToResource's catch, and (pre-existing) the ordinary last-URI unsubscribe, which dispatches a round-trip before the re-listen sets INACTIVE. Both exposed an empty set with an active stream: the pair resetSubscriptionStream orders its own writes to prevent, and which three comments in this branch now cite. State before the announce at both, asserted from the consumer's side. And the reconcile added last round settled for an honest but dead badge: it was the only route to "stream gone, URIs live" that had made no attempt at all, over subscriptions the server may never have honored, recoverable only by an Unsubscribe/Subscribe toggle (a fresh Subscribe early-returns). It now hands them to the reconnect machinery, which already fits — base delay after a user-initiated refresh, bails on a terminal status or an emptied set, and lands on the same "ended" badge past the cap. That also removes the provably-dead null check the reconcile carried. Closes #1797 --- .../inspectorClient-subscriptions-era.test.ts | 64 +++++++++++++++---- core/mcp/inspectorClient.ts | 49 +++++++++----- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index a83086249..d10610a27 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -388,13 +388,14 @@ describe("resource subscriptions era fork (#1630)", () => { expect(state.status).toBe("acknowledged"); }); - it("ends the badge when both overlapping subscribes fail", async () => { + it("retries rather than stranding the URIs when both overlapping subscribes fail", async () => { // The other half of the gate: skipping the superseded call's rollback is // only safe if the superseding call reconciles when *it* fails too. // Otherwise the set keeps the first URI while the state keeps the - // optimistic "connecting" — a badge that will never change, with no - // stream and no reconnect armed (neither `onModernSubscriptionClosed` nor - // `onModernReconnectFailed` ran, so nothing self-heals). + // optimistic "connecting" — a badge that never changes, over a + // subscription no server ever honored, with nothing armed to fix it + // (neither `onModernSubscriptionClosed` nor `onModernReconnectFailed` + // ran). The reconcile hands it to the reconnect machinery instead. const started = await startServer({}); const { connected } = await connect(started.url, "modern"); const int = internals(connected); @@ -416,15 +417,23 @@ describe("resource subscriptions era fork (#1630)", () => { failFirst(new Error("listen boom")); await firstSettled; - // Its own URI rolled back; the superseded one survives, and the badge - // reports the stream honestly rather than "connecting" forever. + // Its own URI rolled back; the superseded one survives — and rather than + // sitting at "connecting" forever it is now a retry in progress. expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); - const state = connected.getResourceSubscriptionStreamState(); - expect(state.active).toBe(true); - expect(state.status).toBe("ended"); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + + // And the retry makes the state true: the next re-listen acknowledges. + int.client.listen = () => Promise.resolve(makeFakeSub().sub); + await vi.waitFor(() => { + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + }); }); - it("ends the badge when the unsubscribe's re-listen fails", async () => { + it("retries rather than stranding the URIs when the unsubscribe's re-listen fails", async () => { // `unsubscribeFromResource` keeps its removal when the re-listen fails, // so nothing is rolled back — but the stream is gone with URIs still // subscribed, and without the reconcile the badge would keep reporting @@ -444,9 +453,38 @@ describe("resource subscriptions era fork (#1630)", () => { ).rejects.toThrow(/re-listen boom/); expect(connected.getSubscribedResources()).toEqual([RESOURCE_URI]); - const state = connected.getResourceSubscriptionStreamState(); - expect(state.active).toBe(true); - expect(state.status).toBe("ended"); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + }); + + it("never announces an empty subscription set with an active stream", async () => { + // The pair `resetSubscriptionStream` orders its own writes to prevent, + // asserted from the consumer's side at the two sites that empty the set + // outside it: the last-URI unsubscribe (happy path, where the re-listen + // that would set INACTIVE is a round-trip away) and the last-URI rollback + // of a failed subscribe (where the state still reads "connecting"). + const started = await startServer({}); + const { connected } = await connect(started.url, "modern"); + const seen: { size: number; active: boolean }[] = []; + connected.addEventListener("resourceSubscriptionsChange", () => { + seen.push({ + size: connected.getSubscribedResources().length, + active: connected.getResourceSubscriptionStreamState().active, + }); + }); + + await connected.subscribeToResource(RESOURCE_URI); + await connected.unsubscribeFromResource(RESOURCE_URI); + + const int = internals(connected); + int.client.listen = () => Promise.reject(new Error("listen boom")); + await expect(connected.subscribeToResource(RESOURCE_URI)).rejects.toThrow( + /listen boom/, + ); + + expect(seen.length).toBeGreaterThan(0); + expect(seen.filter((s) => s.size === 0 && s.active)).toEqual([]); }); it("rolls back the optimistic add when listen() fails", async () => { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 5e40b5c8e..29629affb 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4875,27 +4875,29 @@ export class InspectorClient extends InspectorClientEventTarget { * The empty case is the ordinary one: nothing subscribed, no stream, inactive. * The non-empty one exists because a failed re-listen leaves * `modernSubscription` null with URIs still subscribed, and nothing else will - * notice: `scheduleModernReconnect` is reachable only from a stream that - * closed or a reconnect that failed, and neither happened here. Left alone, - * the state keeps whatever the last success (or the optimistic - * `"connecting"`) wrote — a badge that will never change and a stream that - * will never arrive. `"ended"` is what `onModernSubscriptionClosed` already - * reports for this exact shape, so this reuses that vocabulary rather than - * inventing a state: honest about the stream without claiming the - * subscriptions are gone. + * notice: the reconnect machinery is reachable only from a stream that closed + * or a reconnect that failed, and neither happened here. Left alone, the state + * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a + * badge that will never change over subscriptions the server may never have + * honored, recoverable only by an Unsubscribe/Subscribe toggle (a fresh + * Subscribe early-returns on the URI already being in the set). + * + * So it reconnects rather than settling for an honest-but-dead `"ended"`: + * every other route to "stream gone, URIs live" either expects the close or + * has exhausted the retry cap, and this is the one that has made no attempt + * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh + * already reset `modernReconnectAttempts`, so it starts at the base delay; the + * timer bails on a terminal status or an emptied set; and past the cap + * `onModernReconnectFailed` lands on the same `"ended"` badge. The state + * therefore becomes true or ends after a real attempt. The caller still sees + * its error either way — the retry is about the subscriptions, not the call. */ private reconcileModernStreamStateAfterFailedRefresh(): void { if (this.subscribedResources.size === 0) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } - if (this.modernSubscription === null) { - this.setModernStreamState({ - active: true, - status: "ended", - honoredUris: [], - }); - } + this.scheduleModernReconnect(); } /** @@ -5012,9 +5014,14 @@ export class InspectorClient extends InspectorClientEventTarget { // Either way the state is no longer ours to correct. The error is // still the caller's to see. if (this.modernListenGeneration === generationBefore + 1) { + // State before the announce, for `resetSubscriptionStream`'s + // reason: on a last-URI rollback, dispatching first would expose an + // empty set with a stream still reading `"connecting"` — the pair + // this file treats as impossible. The reverse intermediate is the + // benign one (and the optimistic add above already exposes it). this.subscribedResources.delete(uri); - this.dispatchSubscriptionsChange(); this.reconcileModernStreamStateAfterFailedRefresh(); + this.dispatchSubscriptionsChange(); } throw error; } @@ -5050,6 +5057,16 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.subscribedResources.delete(uri)) return; // The removal is the user's intent; keep it even if the re-listen fails // (the stale URI simply lingers in the server's honored filter). + // + // Removing the last URI moves the stream to inactive before announcing + // the set, rather than letting the re-listen below do it a round-trip + // later: dispatching first would expose an empty set with an `active` + // stream, which is the pair `resetSubscriptionStream` orders its own + // writes to prevent. The re-listen sets the same state on arrival, and + // sets it on the failure path too (see the catch). + if (this.subscribedResources.size === 0) { + this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); + } this.dispatchSubscriptionsChange(); // Same ownership test as `subscribeToResource` — see the long comment // there. Nothing to undo on this path (the removal is kept