From 440b194f64ac3d132b3ff905c7a4be0a28a5f898 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Mon, 1 Jun 2026 11:10:07 -0400 Subject: [PATCH] fix(state): dispatch connect event after capabilities are populated (#1405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(state): dispatch connect event after capabilities are populated The list-state managers (tools/prompts/resources/templates) refresh on the "connect" event and, since #1395, gate their list RPC on getCapabilities(). But connect() dispatched "connect" before fetchServerInfo() populated this.capabilities, and refresh() runs synchronously up to its first await — so the capability gate executed during the synchronous dispatch, read undefined capabilities (reset to undefined on every disconnect), tripped, and wiped every list to empty on connect. Tools, prompts, resources, and resource templates all vanished against any server. Move the "connect" dispatch to after the awaited fetchServerInfo() so capabilities are in place before any manager refreshes. The unit tests missed this because their FakeInspectorClient has capabilities populated from construction; added an integration regression test wiring a real ManagedToolsState on connect and asserting the tool list is non-empty (fails without the fix). Co-Authored-By: Claude Opus 4.8 (1M context) * test(integration): harden connect-race regression test per review Address the two non-blocking suggestions from the PR review: - Replace the 100ms settle sleep with waitForEvent() on the managers' change events, so the test is deterministic against slow CI instead of racing a fixed timeout. - Assert a second affected primitive (ManagedResourcesState alongside ManagedToolsState). The fix is a single shared line, but covering two distinct capabilities guards against a future change gating one primitive differently. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/mcp/inspectorClient.test.ts | 37 +++++++++++++++++++ core/mcp/inspectorClient.ts | 9 ++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index fa97142c..63adc5a3 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -10,6 +10,7 @@ import { PagedPromptsState, ManagedResourcesState, ManagedPromptsState, + ManagedToolsState, } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { SamplingCreateMessage } from "@inspector/core/mcp/samplingCreateMessage.js"; @@ -687,6 +688,42 @@ describe("InspectorClient", () => { // Client no longer stores tools; listTools() still returns server tools when called expect((await client.listTools()).tools.length).toBeGreaterThan(0); }); + + it("managed list states populate on connect (regression: capability gate must see capabilities before the connect event)", async () => { + // Regression for #1395 + connect-ordering: the managed list-state managers + // refresh on the "connect" event and gate their list RPC on + // getCapabilities(). If "connect" is dispatched before fetchServerInfo() + // populates capabilities, the synchronous gate reads undefined and wipes + // the list to empty — tools/prompts/resources all vanish on every connect. + // The single fix is shared by all the managed states, so we cover tools + // and resources (two distinct capabilities) to guard against a future + // change gating only one primitive differently. + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode } }, + ); + const toolsState = new ManagedToolsState(client); + const resourcesState = new ManagedResourcesState(client); + + // Await the change events the connect-triggered refresh() emits rather than + // a fixed sleep — refresh() is async past its synchronous capability gate. + const toolsChanged = waitForEvent(toolsState, "toolsChange"); + const resourcesChanged = waitForEvent(resourcesState, "resourcesChange"); + await client.connect(); + await Promise.all([toolsChanged, resourcesChanged]); + + expect(client.getCapabilities()?.tools).toBeDefined(); + expect(toolsState.getTools().length).toBeGreaterThan(0); + expect(client.getCapabilities()?.resources).toBeDefined(); + expect(resourcesState.getResources().length).toBeGreaterThan(0); + + toolsState.destroy(); + resourcesState.destroy(); + }); }); describe("Tool Methods", () => { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d68b2a6d..8775644a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -656,11 +656,16 @@ export class InspectorClient extends InspectorClientEventTarget { } this.status = "connected"; this.dispatchTypedEvent("statusChange", this.status); - this.dispatchTypedEvent("connect"); - // Always fetch server info (capabilities, serverInfo, instructions) - this is just cached data from initialize + // Always fetch server info (capabilities, serverInfo, instructions) - this is just cached data from initialize. + // Must run BEFORE the "connect" event: the managed list-state managers + // refresh on "connect" and gate their list RPC on getCapabilities() (see + // #1395). If "connect" fired first, that gate would read undefined + // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); + this.dispatchTypedEvent("connect"); + // Set initial logging level if configured and server supports it if (this.initialLoggingLevel && this.capabilities?.logging) { await this.client.setLoggingLevel(