Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions clients/tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ function App({
const [inspectorClients, setInspectorClients] = useState<
Record<string, InspectorClient>
>({});
// ManagedToolsState per server (tools list from manager, not client)
// ManagedToolsState per server (tools list from manager)
const [managedToolsStates, setManagedToolsStates] = useState<
Record<string, ManagedToolsState>
>({});
Expand Down Expand Up @@ -463,10 +463,7 @@ function App({
: null,
[selectedServer, managedToolsStates],
);
const { tools: managedTools } = useManagedTools(
selectedInspectorClient,
selectedManagedToolsState,
);
const { tools: managedTools } = useManagedTools(selectedManagedToolsState);

// Resources, resource templates, prompts from managed state managers
const selectedManagedResourcesState = useMemo(
Expand Down Expand Up @@ -776,7 +773,7 @@ function App({
}
}, [selectedInspectorClient]);

// Build current server state from InspectorClient data (tools from ManagedToolsState)
// Build current server state from InspectorClient data (tools from managed tools store)
const currentServerState = useMemo(() => {
if (!selectedServer) return null;
return {
Expand Down
50 changes: 29 additions & 21 deletions core/__tests__/mcp/state/managedToolsState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ import {
type TestServerHttp,
} from "@modelcontextprotocol/inspector-test-server";

function waitForTools(manager: ManagedToolsState): Promise<Tool[]> {
return new Promise((resolve) => {
const store = manager.getStore();
const tools = store.getState().tools;
if (tools.length > 0) {
resolve(tools);
return;
}
const unsub = store.subscribe(() => {
const next = store.getState().tools;
if (next.length > 0) {
unsub();
resolve(next);
}
});
});
}

describe("ManagedToolsState", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
Expand All @@ -37,14 +55,6 @@ describe("ManagedToolsState", () => {
}
});

function waitForToolsChange(s: ManagedToolsState): Promise<Tool[]> {
return new Promise((resolve) => {
s.addEventListener("toolsChange", (e) => resolve(e.detail), {
once: true,
});
});
}

it("starts with empty tools before connect", () => {
client = new InspectorClient(
{ type: "streamable-http", url: "http://localhost:0" },
Expand All @@ -54,7 +64,7 @@ describe("ManagedToolsState", () => {
expect(state.getTools()).toEqual([]);
});

it("on connect loads initial tools and dispatches toolsChange", async () => {
it("on connect loads initial tools and updates store", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
Expand All @@ -67,16 +77,15 @@ describe("ManagedToolsState", () => {
},
);
state = new ManagedToolsState(client);
const toolsPromise = waitForToolsChange(state);
const toolsPromise = waitForTools(state);
await client.connect();
const tools = await toolsPromise;
expect(tools.length).toBeGreaterThan(0);
expect(tools.some((t) => t.name === "echo")).toBe(true);
expect(state.getTools()).toEqual(tools);
expect(state!.getTools()).toEqual(tools);
});

it("refresh fetches all pages and dispatches toolsChange", async () => {
// Same server config as inspectorClient.test "should accumulate tools when paginating with cursor"
it("refresh fetches all pages and updates store", async () => {
server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: createNumberedTools(6),
Expand All @@ -95,10 +104,9 @@ describe("ManagedToolsState", () => {
);
await client.connect();

// Manager refresh must see exactly 6 tools (uses listTools(), so no list interactions)
state = new ManagedToolsState(client);
const toolsPromise = waitForToolsChange(state);
const tools = await state.refresh();
const toolsPromise = waitForTools(state);
const tools = await state!.refresh();
await toolsPromise;
expect(tools).toHaveLength(6);
expect(tools.map((t) => t.name)).toEqual([
Expand All @@ -109,7 +117,7 @@ describe("ManagedToolsState", () => {
"tool_5",
"tool_6",
]);
expect(state.getTools()).toEqual(tools);
expect(state!.getTools()).toEqual(tools);
});

it("on toolsListChanged refreshes and updates tools", async () => {
Expand All @@ -129,18 +137,18 @@ describe("ManagedToolsState", () => {
);
state = new ManagedToolsState(client);
await client.connect();
await waitForToolsChange(state!);
await waitForTools(state!);
const toolsBefore = state!.getTools();
expect(toolsBefore.length).toBeGreaterThan(0);

const addTool = state!.getTools().find((t) => t.name === "add_tool");
expect(addTool).toBeDefined();
const toolsChangePromise = waitForToolsChange(state!);
const toolsPromise = waitForTools(state!);
await client!.callTool(addTool!, {
name: "newTool",
description: "A new test tool",
});
await toolsChangePromise;
await toolsPromise;
const toolsAfter = state!.getTools();
expect(toolsAfter.find((t) => t.name === "newTool")).toBeDefined();
});
Expand All @@ -159,7 +167,7 @@ describe("ManagedToolsState", () => {
);
state = new ManagedToolsState(client);
await client.connect();
await waitForToolsChange(state!);
await waitForTools(state!);
expect(state!.getTools().length).toBeGreaterThan(0);
await client!.disconnect(100);
expect(state!.getTools()).toEqual([]);
Expand Down
102 changes: 39 additions & 63 deletions core/__tests__/react/useManagedTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,84 +4,85 @@
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useManagedTools } from "../../react/useManagedTools.js";
import { createStore, type StoreApi } from "zustand/vanilla";
import type { ManagedToolsState } from "../../mcp/state/managedToolsState.js";
import type { InspectorClient } from "../../mcp/inspectorClient.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";

/**
* Mock ManagedToolsState: getTools(), refresh(), and toolsChange events.
* Mock ManagedToolsState: getStore(), getTools(), refresh(), setMetadata(), destroy().
* Not typed as implements ManagedToolsState because the real class has private fields.
*/
class MockManagedToolsState extends EventTarget {
private _tools: Tool[] = [];
class MockManagedToolsState {
private store: StoreApi<{ tools: Tool[] }>;

constructor() {
this.store = createStore<{ tools: Tool[] }>()((_set) => ({ tools: [] }));
}

getStore() {
return {
getState: () => this.store.getState(),
subscribe: (listener: () => void) => this.store.subscribe(listener),
};
}

getTools(): Tool[] {
return [...this._tools];
return this.store.getState().tools;
}

setTools(tools: Tool[]): void {
this._tools = tools;
this.dispatchEvent(new CustomEvent("toolsChange", { detail: tools }));
this.store.setState({ tools });
}

setMetadata(): void {}

async refresh(): Promise<Tool[]> {
return this.getTools();
}

destroy(): void {
this._tools = [];
this.store.setState({ tools: [] });
}
}

describe("useManagedTools", () => {
it("returns empty tools and no-op refresh when given null client and null manager", async () => {
const { result } = renderHook(() => useManagedTools(null, null));

expect(result.current.tools).toEqual([]);

await act(async () => {
const next = await result.current.refresh();
expect(next).toEqual([]);
});
expect(result.current.tools).toEqual([]);
});

it("returns empty tools when manager is null", async () => {
const client = {} as InspectorClient;
const { result } = renderHook(() => useManagedTools(client, null));

expect(result.current.tools).toEqual([]);

await act(async () => {
const next = await result.current.refresh();
expect(next).toEqual([]);
});
it("returns empty tools and no-op refresh when manager is null or undefined", async () => {
const { result: resultNull } = renderHook(() => useManagedTools(null));
expect(resultNull.current.tools).toEqual([]);
const fromNull = await resultNull.current.refresh();
expect(fromNull).toEqual([]);

const { result: resultUndef } = renderHook(() =>
useManagedTools(undefined),
);
expect(resultUndef.current.tools).toEqual([]);
const fromUndef = await resultUndef.current.refresh();
expect(fromUndef).toEqual([]);
});

it("syncs initial tools from manager", () => {
it("syncs initial tools from manager store", () => {
const manager = new MockManagedToolsState();
manager.setTools([
{ name: "a", inputSchema: { type: "object" as const } },
{ name: "b", inputSchema: { type: "object" as const } },
]);
const client = {} as InspectorClient;

const { result } = renderHook(() =>
useManagedTools(client, manager as unknown as ManagedToolsState),
useManagedTools(manager as unknown as ManagedToolsState),
);

expect(result.current.tools).toHaveLength(2);
expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]);
});

it("updates tools when manager dispatches toolsChange", async () => {
it("updates tools when manager store updates", async () => {
const manager = new MockManagedToolsState();
manager.setTools([
{ name: "first", inputSchema: { type: "object" as const } },
]);
const client = {} as InspectorClient;

const { result } = renderHook(() =>
useManagedTools(client, manager as unknown as ManagedToolsState),
useManagedTools(manager as unknown as ManagedToolsState),
);

expect(result.current.tools).toHaveLength(1);
Expand All @@ -101,13 +102,12 @@ describe("useManagedTools", () => {
]);
});

it("refresh updates state from manager", async () => {
it("refresh returns tools from manager", async () => {
const manager = new MockManagedToolsState();
manager.setTools([{ name: "x", inputSchema: { type: "object" as const } }]);
const client = {} as InspectorClient;

const { result } = renderHook(() =>
useManagedTools(client, manager as unknown as ManagedToolsState),
useManagedTools(manager as unknown as ManagedToolsState),
);

expect(result.current.tools).toHaveLength(1);
Expand All @@ -126,28 +126,4 @@ describe("useManagedTools", () => {

expect(result.current.tools).toHaveLength(2);
});

it("clears tools when manager switches to null", async () => {
const manager = new MockManagedToolsState();
manager.setTools([
{ name: "only", inputSchema: { type: "object" as const } },
]);
const client = {} as InspectorClient;

const { result, rerender } = renderHook(
({ client: c, manager: m }) => useManagedTools(c, m),
{
initialProps: {
client,
manager: manager as unknown as ManagedToolsState,
},
},
);

expect(result.current.tools).toHaveLength(1);

rerender({ client, manager: null });

expect(result.current.tools).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion core/mcp/state/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { ManagedToolsState } from "./managedToolsState.js";
export type { ManagedToolsStateEventMap } from "./managedToolsState.js";
export type { ManagedToolsReadOnlyStore } from "./managedToolsState.js";
export { MessageLogState } from "./messageLogState.js";
export type {
MessageLogStateEventMap,
Expand Down
Loading