From 336a1d57d2c79cfbbb4516d1e328f23fd44daf7c Mon Sep 17 00:00:00 2001 From: Travis Bonnet Date: Wed, 8 Apr 2026 13:37:29 -0500 Subject: [PATCH] feat: handle tools/resources/prompts list_changed notifications When a server sends notifications/tools/list_changed, notifications/resources/list_changed, or notifications/prompts/list_changed, the inspector now clears stale state (lists, pagination cursors, and cached output schemas) and re-fetches from page 1. This follows the same clear-then-list pattern used by the UI's manual refresh buttons. The notification schemas were already imported and registered on the MCP client in useConnection.ts; this connects the missing response logic in the onNotification callback. Fixes #832 Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/App.tsx | 22 ++ .../App.listChangedNotifications.test.tsx | 293 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 client/src/__tests__/App.listChangedNotifications.test.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..fc9cb5bf0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -404,6 +404,28 @@ const App = () => { void listTasks(); } + if (notification.method === "notifications/tools/list_changed") { + setTools([]); + setNextToolCursor(undefined); + cacheToolOutputSchemas([]); + void listTools(); + } + + if (notification.method === "notifications/resources/list_changed") { + setResources([]); + setNextResourceCursor(undefined); + setResourceTemplates([]); + setNextResourceTemplateCursor(undefined); + void listResources(); + void listResourceTemplates(); + } + + if (notification.method === "notifications/prompts/list_changed") { + setPrompts([]); + setNextPromptCursor(undefined); + void listPrompts(); + } + if (notification.method === "notifications/tasks/status") { const task = notification.params as unknown as Task; setTasks((prev) => { diff --git a/client/src/__tests__/App.listChangedNotifications.test.tsx b/client/src/__tests__/App.listChangedNotifications.test.tsx new file mode 100644 index 000000000..ca9609441 --- /dev/null +++ b/client/src/__tests__/App.listChangedNotifications.test.tsx @@ -0,0 +1,293 @@ +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Notification } from "@modelcontextprotocol/sdk/types.js"; + +// Mock auth dependencies first +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), +})); + +jest.mock("../lib/oauth-state-machine", () => ({ + OAuthStateMachine: jest.fn(), +})); + +jest.mock("../lib/auth", () => ({ + InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockResolvedValue(null), + clear: jest.fn(), + })), + DebugInspectorOAuthClientProvider: jest.fn(), +})); + +jest.mock("../utils/configUtils", () => ({ + ...jest.requireActual("../utils/configUtils"), + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "", + header: "X-MCP-Proxy-Auth", + })), + getInitialTransportType: jest.fn(() => "stdio"), + getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), + getInitialCommand: jest.fn(() => "mcp-server-everything"), + getInitialArgs: jest.fn(() => ""), + initializeInspectorConfig: jest.fn(() => ({})), + saveInspectorConfig: jest.fn(), +})); + +jest.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: jest.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: jest.fn(), + }), +})); + +jest.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +jest.mock("../components/ResourcesTab", () => ({ + __esModule: true, + default: () =>
ResourcesTab
, +})); + +jest.mock("../components/PromptsTab", () => ({ + __esModule: true, + default: () =>
PromptsTab
, +})); + +jest.mock("../components/TasksTab", () => ({ + __esModule: true, + default: () =>
TasksTab
, +})); + +jest.mock("../components/ConsoleTab", () => ({ + __esModule: true, + default: () =>
ConsoleTab
, +})); + +jest.mock("../components/PingTab", () => ({ + __esModule: true, + default: () =>
PingTab
, +})); + +jest.mock("../components/SamplingTab", () => ({ + __esModule: true, + default: () =>
SamplingTab
, +})); + +jest.mock("../components/RootsTab", () => ({ + __esModule: true, + default: () =>
RootsTab
, +})); + +jest.mock("../components/ElicitationTab", () => ({ + __esModule: true, + default: () =>
ElicitationTab
, +})); + +jest.mock("../components/MetadataTab", () => ({ + __esModule: true, + default: () =>
MetadataTab
, +})); + +jest.mock("../components/AuthDebugger", () => ({ + __esModule: true, + default: () =>
AuthDebugger
, +})); + +jest.mock("../components/HistoryAndNotifications", () => ({ + __esModule: true, + default: () =>
HistoryAndNotifications
, +})); + +jest.mock("../components/ToolsTab", () => ({ + __esModule: true, + default: ({ + listTools, + tools, + }: { + listTools: () => void; + tools: Array<{ name: string }>; + }) => ( +
+ +
{JSON.stringify(tools)}
+
+ ), +})); + +jest.mock("../components/AppsTab", () => ({ + __esModule: true, + default: () =>
AppsTab
, +})); + +global.fetch = jest.fn().mockResolvedValue({ + json: () => Promise.resolve({}), +}); + +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: jest.fn(), +})); + +describe("App - list_changed notification handling", () => { + const mockUseConnection = jest.mocked(useConnection); + let capturedOnNotification: ((notification: Notification) => void) | null = + null; + let makeRequest: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + capturedOnNotification = null; + window.location.hash = "#tools"; + + makeRequest = jest.fn(async (request: { method: string }) => { + if (request.method === "tools/list") { + return { + tools: [ + { + name: "testTool", + inputSchema: { type: "object", properties: {} }, + }, + ], + nextCursor: undefined, + }; + } + if (request.method === "resources/list") { + return { resources: [], nextCursor: undefined }; + } + if (request.method === "resources/templates/list") { + return { resourceTemplates: [], nextCursor: undefined }; + } + if (request.method === "prompts/list") { + return { prompts: [], nextCursor: undefined }; + } + throw new Error(`Unexpected method: ${request.method}`); + }); + + mockUseConnection.mockImplementation((options) => { + // Capture the onNotification callback passed by App + if (options.onNotification) { + capturedOnNotification = options.onNotification; + } + + return { + connectionStatus: "connected", + serverCapabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + prompts: { listChanged: true }, + }, + serverImplementation: null, + mcpClient: { + request: jest.fn(), + notification: jest.fn(), + close: jest.fn(), + } as unknown as Client, + requestHistory: [], + clearRequestHistory: jest.fn(), + makeRequest, + cancelTask: jest.fn(), + listTasks: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: jest.fn(), + disconnect: jest.fn(), + } as ReturnType; + }); + }); + + it("refreshes tools list when notifications/tools/list_changed is received", async () => { + render(); + + // First, load tools via the UI button + fireEvent.click(screen.getByRole("button", { name: /mock list tools/i })); + + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "tools/list" }), + expect.anything(), + ); + }); + + // Clear call history to track the re-fetch + makeRequest.mockClear(); + + // Simulate receiving a tools/list_changed notification + expect(capturedOnNotification).not.toBeNull(); + act(() => { + capturedOnNotification!({ + method: "notifications/tools/list_changed", + } as Notification); + }); + + // Verify that tools/list was re-fetched + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "tools/list" }), + expect.anything(), + ); + }); + }); + + it("refreshes resources when notifications/resources/list_changed is received", async () => { + render(); + + expect(capturedOnNotification).not.toBeNull(); + + act(() => { + capturedOnNotification!({ + method: "notifications/resources/list_changed", + } as Notification); + }); + + // Verify that both resources/list and resources/templates/list are re-fetched + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "resources/list" }), + expect.anything(), + ); + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "resources/templates/list" }), + expect.anything(), + ); + }); + }); + + it("refreshes prompts when notifications/prompts/list_changed is received", async () => { + render(); + + expect(capturedOnNotification).not.toBeNull(); + + act(() => { + capturedOnNotification!({ + method: "notifications/prompts/list_changed", + } as Notification); + }); + + // Verify that prompts/list was re-fetched + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "prompts/list" }), + expect.anything(), + ); + }); + }); +});