Skip to content

Commit 9e49454

Browse files
cliffhallclaude
andauthored
fix(state): gate list fetches on server capability (#1350) (#1395)
The four list-fetching state managers fired their list RPC against every server on connect regardless of advertised capabilities, so a server that doesn't implement a given primitive replied -32601 "Method not found", surfacing in the console on every connect. Mirrors the `tasks`-capability gate added for ManagedRequestorTasksState in #1349. - managedToolsState → gate on capabilities.tools - managedPromptsState → gate on capabilities.prompts - managedResourcesState → gate on capabilities.resources - managedResourceTemplatesState → gate on capabilities.resources (the spec defines no separate resourceTemplates capability; resources/templates/list is part of the resources surface) When the capability is absent each manager sets an empty list, dispatches its change event, and returns — the right semantics for "this server doesn't support X." Tests: existing flow tests now construct their FakeInspectorClient with the relevant capability so the live list path is still exercised; added a "refresh skips listX when capability absent" and a "connect against an X-less server doesn't fire listX" test per manager. Updated the useManagedX peers and resourceSubscriptionsState (which drives a ManagedResourcesState refresh) to advertise the capability too. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 905b80a commit 9e49454

13 files changed

Lines changed: 206 additions & 9 deletions

clients/web/src/test/core/mcp/state/managedPromptsState.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ describe("ManagedPromptsState", () => {
2020
let state: ManagedPromptsState;
2121

2222
beforeEach(() => {
23-
client = new FakeInspectorClient();
23+
// Default to a server that advertises `prompts` so the existing flow tests
24+
// exercise the live `listPrompts` path; capability-absent tests below
25+
// override this.
26+
client = new FakeInspectorClient({ capabilities: { prompts: {} } });
2427
state = new ManagedPromptsState(client);
2528
});
2629

@@ -40,6 +43,35 @@ describe("ManagedPromptsState", () => {
4043
expect(client.listPrompts).not.toHaveBeenCalled();
4144
});
4245

46+
it("refresh skips listPrompts when the server doesn't advertise prompts capability", async () => {
47+
// Regression (#1350): a prompts-less server replied to prompts/list with
48+
// -32601 "Method not found", surfacing in the console on every connect.
49+
const promptless = new FakeInspectorClient({
50+
capabilities: { tools: {}, resources: {} },
51+
});
52+
promptless.setStatus("connected");
53+
const promptlessState = new ManagedPromptsState(promptless);
54+
55+
const result = await promptlessState.refresh();
56+
expect(result).toEqual([]);
57+
expect(promptless.listPrompts).not.toHaveBeenCalled();
58+
});
59+
60+
it("connect against a prompts-less server doesn't fire listPrompts", async () => {
61+
// The connect event runs refresh; the capability gate must also catch it
62+
// there, not only the publicly-callable refresh().
63+
const promptless = new FakeInspectorClient({ capabilities: { tools: {} } });
64+
promptless.setStatus("connected");
65+
const promptlessState = new ManagedPromptsState(promptless);
66+
67+
promptless.dispatchTypedEvent("connect");
68+
// Yield so the async refresh chained off connect runs.
69+
await Promise.resolve();
70+
await Promise.resolve();
71+
expect(promptless.listPrompts).not.toHaveBeenCalled();
72+
expect(promptlessState.getPrompts()).toEqual([]);
73+
});
74+
4375
it("refresh fetches a single page and dispatches promptsChange", async () => {
4476
client.setStatus("connected");
4577
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });

clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ describe("ManagedResourceTemplatesState", () => {
2424
let state: ManagedResourceTemplatesState;
2525

2626
beforeEach(() => {
27-
client = new FakeInspectorClient();
27+
// Default to a server that advertises `resources` so the existing flow
28+
// tests exercise the live `listResourceTemplates` path; capability-absent
29+
// tests below override this. (Templates are gated on the `resources`
30+
// capability — the spec defines no separate `resourceTemplates` one.)
31+
client = new FakeInspectorClient({ capabilities: { resources: {} } });
2832
state = new ManagedResourceTemplatesState(client);
2933
});
3034

@@ -44,6 +48,38 @@ describe("ManagedResourceTemplatesState", () => {
4448
expect(client.listResourceTemplates).not.toHaveBeenCalled();
4549
});
4650

51+
it("refresh skips listResourceTemplates when the server doesn't advertise resources capability", async () => {
52+
// Regression (#1350): templates are part of the resources surface, so a
53+
// resources-less server replied to resources/templates/list with -32601
54+
// "Method not found", surfacing in the console on every connect.
55+
const resourceless = new FakeInspectorClient({
56+
capabilities: { tools: {}, prompts: {} },
57+
});
58+
resourceless.setStatus("connected");
59+
const resourcelessState = new ManagedResourceTemplatesState(resourceless);
60+
61+
const result = await resourcelessState.refresh();
62+
expect(result).toEqual([]);
63+
expect(resourceless.listResourceTemplates).not.toHaveBeenCalled();
64+
});
65+
66+
it("connect against a resources-less server doesn't fire listResourceTemplates", async () => {
67+
// The connect event runs refresh; the capability gate must also catch it
68+
// there, not only the publicly-callable refresh().
69+
const resourceless = new FakeInspectorClient({
70+
capabilities: { tools: {} },
71+
});
72+
resourceless.setStatus("connected");
73+
const resourcelessState = new ManagedResourceTemplatesState(resourceless);
74+
75+
resourceless.dispatchTypedEvent("connect");
76+
// Yield so the async refresh chained off connect runs.
77+
await Promise.resolve();
78+
await Promise.resolve();
79+
expect(resourceless.listResourceTemplates).not.toHaveBeenCalled();
80+
expect(resourcelessState.getResourceTemplates()).toEqual([]);
81+
});
82+
4783
it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => {
4884
client.setStatus("connected");
4985
client.queueResourceTemplatePages({

clients/web/src/test/core/mcp/state/managedResourcesState.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ describe("ManagedResourcesState", () => {
2222
let state: ManagedResourcesState;
2323

2424
beforeEach(() => {
25-
client = new FakeInspectorClient();
25+
// Default to a server that advertises `resources` so the existing flow
26+
// tests exercise the live `listResources` path; capability-absent tests
27+
// below override this.
28+
client = new FakeInspectorClient({ capabilities: { resources: {} } });
2629
state = new ManagedResourcesState(client);
2730
});
2831

@@ -42,6 +45,38 @@ describe("ManagedResourcesState", () => {
4245
expect(client.listResources).not.toHaveBeenCalled();
4346
});
4447

48+
it("refresh skips listResources when the server doesn't advertise resources capability", async () => {
49+
// Regression (#1350): a resources-less server replied to resources/list
50+
// with -32601 "Method not found", surfacing in the console on every
51+
// connect.
52+
const resourceless = new FakeInspectorClient({
53+
capabilities: { tools: {}, prompts: {} },
54+
});
55+
resourceless.setStatus("connected");
56+
const resourcelessState = new ManagedResourcesState(resourceless);
57+
58+
const result = await resourcelessState.refresh();
59+
expect(result).toEqual([]);
60+
expect(resourceless.listResources).not.toHaveBeenCalled();
61+
});
62+
63+
it("connect against a resources-less server doesn't fire listResources", async () => {
64+
// The connect event runs refresh; the capability gate must also catch it
65+
// there, not only the publicly-callable refresh().
66+
const resourceless = new FakeInspectorClient({
67+
capabilities: { tools: {} },
68+
});
69+
resourceless.setStatus("connected");
70+
const resourcelessState = new ManagedResourcesState(resourceless);
71+
72+
resourceless.dispatchTypedEvent("connect");
73+
// Yield so the async refresh chained off connect runs.
74+
await Promise.resolve();
75+
await Promise.resolve();
76+
expect(resourceless.listResources).not.toHaveBeenCalled();
77+
expect(resourcelessState.getResources()).toEqual([]);
78+
});
79+
4580
it("refresh fetches a single page and dispatches resourcesChange", async () => {
4681
client.setStatus("connected");
4782
client.queueResourcePages({

clients/web/src/test/core/mcp/state/managedToolsState.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ describe("ManagedToolsState", () => {
2020
let state: ManagedToolsState;
2121

2222
beforeEach(() => {
23-
client = new FakeInspectorClient();
23+
// Default to a server that advertises `tools` so the existing flow tests
24+
// exercise the live `listTools` path; capability-absent tests below
25+
// override this.
26+
client = new FakeInspectorClient({ capabilities: { tools: {} } });
2427
state = new ManagedToolsState(client);
2528
});
2629

@@ -40,6 +43,35 @@ describe("ManagedToolsState", () => {
4043
expect(client.listTools).not.toHaveBeenCalled();
4144
});
4245

46+
it("refresh skips listTools when the server doesn't advertise tools capability", async () => {
47+
// Regression (#1350): a tools-less server replied to tools/list with
48+
// -32601 "Method not found", surfacing in the console on every connect.
49+
const toolless = new FakeInspectorClient({
50+
capabilities: { prompts: {}, resources: {} },
51+
});
52+
toolless.setStatus("connected");
53+
const toollessState = new ManagedToolsState(toolless);
54+
55+
const result = await toollessState.refresh();
56+
expect(result).toEqual([]);
57+
expect(toolless.listTools).not.toHaveBeenCalled();
58+
});
59+
60+
it("connect against a tools-less server doesn't fire listTools", async () => {
61+
// The connect event runs refresh; the capability gate must also catch it
62+
// there, not only the publicly-callable refresh().
63+
const toolless = new FakeInspectorClient({ capabilities: { prompts: {} } });
64+
toolless.setStatus("connected");
65+
const toollessState = new ManagedToolsState(toolless);
66+
67+
toolless.dispatchTypedEvent("connect");
68+
// Yield so the async refresh chained off connect runs.
69+
await Promise.resolve();
70+
await Promise.resolve();
71+
expect(toolless.listTools).not.toHaveBeenCalled();
72+
expect(toollessState.getTools()).toEqual([]);
73+
});
74+
4375
it("refresh fetches a single page and dispatches toolsChange", async () => {
4476
client.setStatus("connected");
4577
client.queueToolPages({ tools: [tool("a"), tool("b")] });

clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ describe("ResourceSubscriptionsState", () => {
2525
beforeEach(() => {
2626
vi.useFakeTimers();
2727
vi.setSystemTime(new Date("2026-05-19T10:00:00Z"));
28-
client = new FakeInspectorClient({ status: "connected" });
28+
// `resources` capability so the ManagedResourcesState refresh used by the
29+
// reference-resolution test exercises the live `listResources` path.
30+
client = new FakeInspectorClient({
31+
status: "connected",
32+
capabilities: { resources: {} },
33+
});
2934
});
3035

3136
afterEach(() => {

clients/web/src/test/core/react/useManagedPrompts.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ describe("useManagedPrompts", () => {
1414
let state: ManagedPromptsState;
1515

1616
beforeEach(() => {
17-
client = new FakeInspectorClient({ status: "connected" });
17+
client = new FakeInspectorClient({
18+
status: "connected",
19+
capabilities: { prompts: {} },
20+
});
1821
state = new ManagedPromptsState(client);
1922
});
2023

clients/web/src/test/core/react/useManagedResourceTemplates.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ describe("useManagedResourceTemplates", () => {
1414
let state: ManagedResourceTemplatesState;
1515

1616
beforeEach(() => {
17-
client = new FakeInspectorClient({ status: "connected" });
17+
client = new FakeInspectorClient({
18+
status: "connected",
19+
capabilities: { resources: {} },
20+
});
1821
state = new ManagedResourceTemplatesState(client);
1922
});
2023

clients/web/src/test/core/react/useManagedResources.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ describe("useManagedResources", () => {
1414
let state: ManagedResourcesState;
1515

1616
beforeEach(() => {
17-
client = new FakeInspectorClient({ status: "connected" });
17+
client = new FakeInspectorClient({
18+
status: "connected",
19+
capabilities: { resources: {} },
20+
});
1821
state = new ManagedResourcesState(client);
1922
});
2023

clients/web/src/test/core/react/useManagedTools.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ describe("useManagedTools", () => {
1414
let state: ManagedToolsState;
1515

1616
beforeEach(() => {
17-
client = new FakeInspectorClient({ status: "connected" });
17+
client = new FakeInspectorClient({
18+
status: "connected",
19+
capabilities: { tools: {} },
20+
});
1821
state = new ManagedToolsState(client);
1922
});
2023

core/mcp/state/managedPromptsState.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ export class ManagedPromptsState extends TypedEventTarget<ManagedPromptsStateEve
7070
if (!client || client.getStatus() !== "connected") {
7171
return this.getPrompts();
7272
}
73+
// Gate on the server's `prompts` capability — calling prompts/list against
74+
// a server that doesn't advertise it returns -32601 "Method not found",
75+
// which then surfaces in the console for every connect against a
76+
// prompts-less server. Empty list is the right semantics for "this server
77+
// doesn't support prompts."
78+
if (!client.getCapabilities()?.prompts) {
79+
this.prompts = [];
80+
this.dispatchTypedEvent("promptsChange", this.prompts);
81+
return this.getPrompts();
82+
}
7383
const effectiveMetadata = metadata ?? this._metadata;
7484
this.prompts = [];
7585
let cursor: string | undefined;

0 commit comments

Comments
 (0)