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>
This commit is contained in:
Cliff Hall 2026-05-31 22:06:55 -04:00 • committed by GitHub
commit 9e494544e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 206 additions and 9 deletions

View file

@ -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")] });

View file

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

View file

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

View file

@ -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")] });

View file

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

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});