diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index fa97142ce..63adc5a3e 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 d68b2a6df..8775644a0 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(