fix(state): dispatch connect event after capabilities are populated (#1405)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-01 11:10:07 -04:00 • committed by GitHub
commit 440b194f64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 2 deletions

View file

@ -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", () => {

View file

@ -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(