From e7f53045227341727ee2f3fc6bd4965082c71f9a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 31 May 2026 20:27:34 -0400 Subject: [PATCH] fix(state): gate list fetches on server capability (#1350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../mcp/state/managedPromptsState.test.ts | 34 ++++++++++++++++- .../managedResourceTemplatesState.test.ts | 38 ++++++++++++++++++- .../mcp/state/managedResourcesState.test.ts | 37 +++++++++++++++++- .../core/mcp/state/managedToolsState.test.ts | 34 ++++++++++++++++- .../state/resourceSubscriptionsState.test.ts | 7 +++- .../core/react/useManagedPrompts.test.tsx | 5 ++- .../useManagedResourceTemplates.test.tsx | 5 ++- .../core/react/useManagedResources.test.tsx | 5 ++- .../test/core/react/useManagedTools.test.tsx | 5 ++- core/mcp/state/managedPromptsState.ts | 10 +++++ .../state/managedResourceTemplatesState.ts | 15 ++++++++ core/mcp/state/managedResourcesState.ts | 10 +++++ core/mcp/state/managedToolsState.ts | 10 +++++ 13 files changed, 206 insertions(+), 9 deletions(-) diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index c7ef3e96e..21166356d 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -20,7 +20,10 @@ describe("ManagedPromptsState", () => { let state: ManagedPromptsState; beforeEach(() => { - client = new FakeInspectorClient(); + // Default to a server that advertises `prompts` so the existing flow tests + // exercise the live `listPrompts` path; capability-absent tests below + // override this. + client = new FakeInspectorClient({ capabilities: { prompts: {} } }); state = new ManagedPromptsState(client); }); @@ -40,6 +43,35 @@ describe("ManagedPromptsState", () => { expect(client.listPrompts).not.toHaveBeenCalled(); }); + it("refresh skips listPrompts when the server doesn't advertise prompts capability", async () => { + // Regression (#1350): a prompts-less server replied to prompts/list with + // -32601 "Method not found", surfacing in the console on every connect. + const promptless = new FakeInspectorClient({ + capabilities: { tools: {}, resources: {} }, + }); + promptless.setStatus("connected"); + const promptlessState = new ManagedPromptsState(promptless); + + const result = await promptlessState.refresh(); + expect(result).toEqual([]); + expect(promptless.listPrompts).not.toHaveBeenCalled(); + }); + + it("connect against a prompts-less server doesn't fire listPrompts", async () => { + // The connect event runs refresh; the capability gate must also catch it + // there, not only the publicly-callable refresh(). + const promptless = new FakeInspectorClient({ capabilities: { tools: {} } }); + promptless.setStatus("connected"); + const promptlessState = new ManagedPromptsState(promptless); + + promptless.dispatchTypedEvent("connect"); + // Yield so the async refresh chained off connect runs. + await Promise.resolve(); + await Promise.resolve(); + expect(promptless.listPrompts).not.toHaveBeenCalled(); + expect(promptlessState.getPrompts()).toEqual([]); + }); + it("refresh fetches a single page and dispatches promptsChange", async () => { client.setStatus("connected"); client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] }); diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index 223d2be80..d7b6372d9 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -24,7 +24,11 @@ describe("ManagedResourceTemplatesState", () => { let state: ManagedResourceTemplatesState; beforeEach(() => { - client = new FakeInspectorClient(); + // Default to a server that advertises `resources` so the existing flow + // tests exercise the live `listResourceTemplates` path; capability-absent + // tests below override this. (Templates are gated on the `resources` + // capability — the spec defines no separate `resourceTemplates` one.) + client = new FakeInspectorClient({ capabilities: { resources: {} } }); state = new ManagedResourceTemplatesState(client); }); @@ -44,6 +48,38 @@ describe("ManagedResourceTemplatesState", () => { expect(client.listResourceTemplates).not.toHaveBeenCalled(); }); + it("refresh skips listResourceTemplates when the server doesn't advertise resources capability", async () => { + // Regression (#1350): templates are part of the resources surface, so a + // resources-less server replied to resources/templates/list with -32601 + // "Method not found", surfacing in the console on every connect. + const resourceless = new FakeInspectorClient({ + capabilities: { tools: {}, prompts: {} }, + }); + resourceless.setStatus("connected"); + const resourcelessState = new ManagedResourceTemplatesState(resourceless); + + const result = await resourcelessState.refresh(); + expect(result).toEqual([]); + expect(resourceless.listResourceTemplates).not.toHaveBeenCalled(); + }); + + it("connect against a resources-less server doesn't fire listResourceTemplates", async () => { + // The connect event runs refresh; the capability gate must also catch it + // there, not only the publicly-callable refresh(). + const resourceless = new FakeInspectorClient({ + capabilities: { tools: {} }, + }); + resourceless.setStatus("connected"); + const resourcelessState = new ManagedResourceTemplatesState(resourceless); + + resourceless.dispatchTypedEvent("connect"); + // Yield so the async refresh chained off connect runs. + await Promise.resolve(); + await Promise.resolve(); + expect(resourceless.listResourceTemplates).not.toHaveBeenCalled(); + expect(resourcelessState.getResourceTemplates()).toEqual([]); + }); + it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => { client.setStatus("connected"); client.queueResourceTemplatePages({ diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index 6ce214653..cb22ca274 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -22,7 +22,10 @@ describe("ManagedResourcesState", () => { let state: ManagedResourcesState; beforeEach(() => { - client = new FakeInspectorClient(); + // Default to a server that advertises `resources` so the existing flow + // tests exercise the live `listResources` path; capability-absent tests + // below override this. + client = new FakeInspectorClient({ capabilities: { resources: {} } }); state = new ManagedResourcesState(client); }); @@ -42,6 +45,38 @@ describe("ManagedResourcesState", () => { expect(client.listResources).not.toHaveBeenCalled(); }); + it("refresh skips listResources when the server doesn't advertise resources capability", async () => { + // Regression (#1350): a resources-less server replied to resources/list + // with -32601 "Method not found", surfacing in the console on every + // connect. + const resourceless = new FakeInspectorClient({ + capabilities: { tools: {}, prompts: {} }, + }); + resourceless.setStatus("connected"); + const resourcelessState = new ManagedResourcesState(resourceless); + + const result = await resourcelessState.refresh(); + expect(result).toEqual([]); + expect(resourceless.listResources).not.toHaveBeenCalled(); + }); + + it("connect against a resources-less server doesn't fire listResources", async () => { + // The connect event runs refresh; the capability gate must also catch it + // there, not only the publicly-callable refresh(). + const resourceless = new FakeInspectorClient({ + capabilities: { tools: {} }, + }); + resourceless.setStatus("connected"); + const resourcelessState = new ManagedResourcesState(resourceless); + + resourceless.dispatchTypedEvent("connect"); + // Yield so the async refresh chained off connect runs. + await Promise.resolve(); + await Promise.resolve(); + expect(resourceless.listResources).not.toHaveBeenCalled(); + expect(resourcelessState.getResources()).toEqual([]); + }); + it("refresh fetches a single page and dispatches resourcesChange", async () => { client.setStatus("connected"); client.queueResourcePages({ diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 51916d17f..edb04b927 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -20,7 +20,10 @@ describe("ManagedToolsState", () => { let state: ManagedToolsState; beforeEach(() => { - client = new FakeInspectorClient(); + // Default to a server that advertises `tools` so the existing flow tests + // exercise the live `listTools` path; capability-absent tests below + // override this. + client = new FakeInspectorClient({ capabilities: { tools: {} } }); state = new ManagedToolsState(client); }); @@ -40,6 +43,35 @@ describe("ManagedToolsState", () => { expect(client.listTools).not.toHaveBeenCalled(); }); + it("refresh skips listTools when the server doesn't advertise tools capability", async () => { + // Regression (#1350): a tools-less server replied to tools/list with + // -32601 "Method not found", surfacing in the console on every connect. + const toolless = new FakeInspectorClient({ + capabilities: { prompts: {}, resources: {} }, + }); + toolless.setStatus("connected"); + const toollessState = new ManagedToolsState(toolless); + + const result = await toollessState.refresh(); + expect(result).toEqual([]); + expect(toolless.listTools).not.toHaveBeenCalled(); + }); + + it("connect against a tools-less server doesn't fire listTools", async () => { + // The connect event runs refresh; the capability gate must also catch it + // there, not only the publicly-callable refresh(). + const toolless = new FakeInspectorClient({ capabilities: { prompts: {} } }); + toolless.setStatus("connected"); + const toollessState = new ManagedToolsState(toolless); + + toolless.dispatchTypedEvent("connect"); + // Yield so the async refresh chained off connect runs. + await Promise.resolve(); + await Promise.resolve(); + expect(toolless.listTools).not.toHaveBeenCalled(); + expect(toollessState.getTools()).toEqual([]); + }); + it("refresh fetches a single page and dispatches toolsChange", async () => { client.setStatus("connected"); client.queueToolPages({ tools: [tool("a"), tool("b")] }); diff --git a/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts index ac21c06d6..cc9c6ef63 100644 --- a/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts +++ b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts @@ -25,7 +25,12 @@ describe("ResourceSubscriptionsState", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-19T10:00:00Z")); - client = new FakeInspectorClient({ status: "connected" }); + // `resources` capability so the ManagedResourcesState refresh used by the + // reference-resolution test exercises the live `listResources` path. + client = new FakeInspectorClient({ + status: "connected", + capabilities: { resources: {} }, + }); }); afterEach(() => { diff --git a/clients/web/src/test/core/react/useManagedPrompts.test.tsx b/clients/web/src/test/core/react/useManagedPrompts.test.tsx index 146e71f75..43888fc63 100644 --- a/clients/web/src/test/core/react/useManagedPrompts.test.tsx +++ b/clients/web/src/test/core/react/useManagedPrompts.test.tsx @@ -14,7 +14,10 @@ describe("useManagedPrompts", () => { let state: ManagedPromptsState; beforeEach(() => { - client = new FakeInspectorClient({ status: "connected" }); + client = new FakeInspectorClient({ + status: "connected", + capabilities: { prompts: {} }, + }); state = new ManagedPromptsState(client); }); diff --git a/clients/web/src/test/core/react/useManagedResourceTemplates.test.tsx b/clients/web/src/test/core/react/useManagedResourceTemplates.test.tsx index 76d142708..ac097da80 100644 --- a/clients/web/src/test/core/react/useManagedResourceTemplates.test.tsx +++ b/clients/web/src/test/core/react/useManagedResourceTemplates.test.tsx @@ -14,7 +14,10 @@ describe("useManagedResourceTemplates", () => { let state: ManagedResourceTemplatesState; beforeEach(() => { - client = new FakeInspectorClient({ status: "connected" }); + client = new FakeInspectorClient({ + status: "connected", + capabilities: { resources: {} }, + }); state = new ManagedResourceTemplatesState(client); }); diff --git a/clients/web/src/test/core/react/useManagedResources.test.tsx b/clients/web/src/test/core/react/useManagedResources.test.tsx index 31769417a..7a6d385bc 100644 --- a/clients/web/src/test/core/react/useManagedResources.test.tsx +++ b/clients/web/src/test/core/react/useManagedResources.test.tsx @@ -14,7 +14,10 @@ describe("useManagedResources", () => { let state: ManagedResourcesState; beforeEach(() => { - client = new FakeInspectorClient({ status: "connected" }); + client = new FakeInspectorClient({ + status: "connected", + capabilities: { resources: {} }, + }); state = new ManagedResourcesState(client); }); diff --git a/clients/web/src/test/core/react/useManagedTools.test.tsx b/clients/web/src/test/core/react/useManagedTools.test.tsx index 5be746eda..4b1c0f244 100644 --- a/clients/web/src/test/core/react/useManagedTools.test.tsx +++ b/clients/web/src/test/core/react/useManagedTools.test.tsx @@ -14,7 +14,10 @@ describe("useManagedTools", () => { let state: ManagedToolsState; beforeEach(() => { - client = new FakeInspectorClient({ status: "connected" }); + client = new FakeInspectorClient({ + status: "connected", + capabilities: { tools: {} }, + }); state = new ManagedToolsState(client); }); diff --git a/core/mcp/state/managedPromptsState.ts b/core/mcp/state/managedPromptsState.ts index fdaf7f917..a53c3101d 100644 --- a/core/mcp/state/managedPromptsState.ts +++ b/core/mcp/state/managedPromptsState.ts @@ -70,6 +70,16 @@ export class ManagedPromptsState extends TypedEventTarget