feat(core): port v1.5 hook layer (#1243) (#1300)

* feat(core): port v1.5 hook layer (useInspectorClient + useManaged*/usePaged* + log hooks)

Wholesale port of v1.5's React hook layer and the EventTarget-based state machinery the hooks subscribe to (#1243). Hooks decouple from the concrete InspectorClient via a new `InspectorClientProtocol` interface so this layer can land before the runtime class is ported.

Added under `core/`:
- `core/react/` — useInspectorClient + 5 useManaged* + 5 usePaged* + 3 log hooks
- `core/mcp/state/` — 5 managed*State + 5 paged*State + messageLogState + stderrLogState + fetchRequestLogState
- `core/mcp/inspectorClientProtocol.ts`, `inspectorClientEventTarget.ts`, `typedEventTarget.ts`, `samplingCreateMessage.ts`, `sessionStorage.ts`
- v2-only wrapper types in `core/mcp/types.ts`: InspectorUrlElicitRequest, InspectorPendingRequest, InspectorRequestHistoryItem

Tests live under `clients/web/src/test/core/` (28 new test files, 264 new tests). Test fake: `core/mcp/__tests__/fakeInspectorClient.ts`.

Build wiring:
- `vite.config.ts`: dedupe react/react-dom so core/ files resolve them via clients/web/node_modules.
- `tsconfig.app.json` / `tsconfig.test.json`: paths map react/react-dom/vitest to clients/web/node_modules so `tsc -b` resolves bare imports from core/ source files (which sit above the package's node_modules). Exclude core/__tests__ from app config; include in test config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR #1300 review findings

Bugs:
- fetchRequestLogState: add .catch() on sessionStorage.loadSession so a corrupt
  or unreadable session doesn't surface as an unhandled rejection (matches the
  saveSession swallow pattern).
- messageLogState: sync pendingRequestEntries when clearMessages() evicts a
  pending request entry, so a later response with the same id falls through
  to the append branch instead of mutating an unreachable map entry. Add tests
  for both predicate and no-predicate paths.

Cleanups:
- Extract mergeTaskIntoList helper to core/mcp/state/mergeTaskIntoList.ts so
  managed/paged RequestorTasks state classes share one definition.
- Add a one-line JSDoc note on each PagedTools/Prompts/Resources/Templates
  state explaining why they deliberately do not subscribe to *ListChanged
  (cursor invalidation mid-pagination).
- Document on useInspectorClient that appRendererClient is read lazily on
  every render and is not subscribed (changes once at connect time).

Carry-overs from v1.5 deliberately left for the follow-up InspectorClient
runtime port:
- Refresh/status-change race in Managed*State.refresh() (status check is only
  at entry; a disconnect during await can re-clear before refresh resumes).
- nextCursor exposure asymmetry across usePaged* (only usePagedRequestorTasks
  surfaces it via state field; others return it from loadPage() only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-13 17:43:07 -04:00 • committed by GitHub
commit e3e70470b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 6404 additions and 5 deletions

View file

@ -0,0 +1,214 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { FetchRequestEntry } from "@inspector/core/mcp/types";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import type {
InspectorClientStorage,
InspectorClientSessionState,
} from "@inspector/core/mcp/sessionStorage";
function entry(id: string, url = `https://x/${id}`): FetchRequestEntry {
return {
id,
timestamp: new Date(2026, 4, 13),
method: "GET",
url,
requestHeaders: {},
category: "transport",
};
}
function makeStorage(
initial: Record<string, InspectorClientSessionState> = {},
): InspectorClientStorage & {
saves: Array<{ id: string; state: InspectorClientSessionState }>;
} {
const store = new Map(Object.entries(initial));
const saves: Array<{ id: string; state: InspectorClientSessionState }> = [];
return {
saves,
async saveSession(sessionId, state) {
saves.push({ id: sessionId, state });
store.set(sessionId, state);
},
async loadSession(sessionId) {
return store.get(sessionId);
},
async deleteSession(sessionId) {
store.delete(sessionId);
},
};
}
describe("FetchRequestLogState", () => {
let client: FakeInspectorClient;
let state: FetchRequestLogState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new FetchRequestLogState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getFetchRequests()).toEqual([]);
expect(state.getFetchRequests()).not.toBe(state.getFetchRequests());
});
it("appends entries and dispatches fetchRequest + fetchRequestsChange", () => {
const seenSingle: FetchRequestEntry[] = [];
const seenFull: FetchRequestEntry[][] = [];
state.addEventListener("fetchRequest", (e) => seenSingle.push(e.detail));
state.addEventListener("fetchRequestsChange", (e) =>
seenFull.push(e.detail),
);
client.dispatchTypedEvent("fetchRequest", entry("a"));
client.dispatchTypedEvent("fetchRequest", entry("b"));
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a", "b"]);
expect(seenSingle).toHaveLength(2);
expect(seenFull).toHaveLength(2);
});
it("trims the oldest entries when maxFetchRequests is exceeded", () => {
const small = new FetchRequestLogState(client, { maxFetchRequests: 2 });
client.dispatchTypedEvent("fetchRequest", entry("a"));
client.dispatchTypedEvent("fetchRequest", entry("b"));
client.dispatchTypedEvent("fetchRequest", entry("c"));
expect(small.getFetchRequests().map((e) => e.id)).toEqual(["b", "c"]);
});
it("does not trim when maxFetchRequests is 0", () => {
const big = new FetchRequestLogState(client, { maxFetchRequests: 0 });
for (let i = 0; i < 5; i++) {
client.dispatchTypedEvent("fetchRequest", entry(`m${i}`));
}
expect(big.getFetchRequests()).toHaveLength(5);
});
it("clearFetchRequests dispatches when the list was non-empty", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
let dispatched = false;
state.addEventListener("fetchRequestsChange", () => (dispatched = true));
state.clearFetchRequests();
expect(dispatched).toBe(true);
expect(state.getFetchRequests()).toEqual([]);
});
it("clearFetchRequests is a no-op when empty", () => {
let dispatched = false;
state.addEventListener("fetchRequestsChange", () => (dispatched = true));
state.clearFetchRequests();
expect(dispatched).toBe(false);
});
it("does NOT clear on connect or disconnect", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
client.dispatchTypedEvent("connect");
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a"]);
client.setStatus("disconnected");
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a"]);
});
it("persists entries via saveSession when sessionStorage is provided", () => {
const storage = makeStorage();
const sessionState = new FetchRequestLogState(client, {
sessionStorage: storage,
});
client.dispatchTypedEvent("fetchRequest", entry("a"));
client.dispatchTypedEvent("saveSession", { sessionId: "sess-1" });
expect(storage.saves).toHaveLength(1);
expect(storage.saves[0]!.id).toBe("sess-1");
expect(storage.saves[0]!.state.fetchRequests.map((e) => e.id)).toEqual([
"a",
]);
sessionState.destroy();
});
it("swallows saveSession errors (fire and forget)", async () => {
const storage = makeStorage();
storage.saveSession = vi.fn().mockRejectedValue(new Error("boom"));
new FetchRequestLogState(client, { sessionStorage: storage });
expect(() =>
client.dispatchTypedEvent("saveSession", { sessionId: "sess-1" }),
).not.toThrow();
await Promise.resolve();
expect(storage.saveSession).toHaveBeenCalled();
});
it("hydrates from storage when sessionId is provided and entries exist", async () => {
const storage = makeStorage({
"sess-restore": {
fetchRequests: [entry("r1"), entry("r2")],
createdAt: 0,
updatedAt: 0,
},
});
const hydrated = new FetchRequestLogState(client, {
sessionStorage: storage,
sessionId: "sess-restore",
});
// hydrate is async via .then(); resolve microtasks
await Promise.resolve();
await Promise.resolve();
expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["r1", "r2"]);
hydrated.destroy();
});
it("hydrate respects maxFetchRequests trimming", async () => {
const storage = makeStorage({
"sess-trim": {
fetchRequests: [entry("r1"), entry("r2"), entry("r3")],
createdAt: 0,
updatedAt: 0,
},
});
const hydrated = new FetchRequestLogState(client, {
sessionStorage: storage,
sessionId: "sess-trim",
maxFetchRequests: 2,
});
await Promise.resolve();
await Promise.resolve();
expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["r2", "r3"]);
});
it("ignores hydration when destroy() happens first", async () => {
let resolveLoad:
| ((s: InspectorClientSessionState | undefined) => void)
| undefined;
const storage: InspectorClientStorage = {
async saveSession() {},
loadSession: () =>
new Promise((resolve) => {
resolveLoad = resolve;
}),
async deleteSession() {},
};
const hydrated = new FetchRequestLogState(client, {
sessionStorage: storage,
sessionId: "sess-late",
});
hydrated.destroy();
resolveLoad?.({
fetchRequests: [entry("late")],
createdAt: 0,
updatedAt: 0,
});
await Promise.resolve();
expect(hydrated.getFetchRequests()).toEqual([]);
});
it("destroy stops listening and clears state", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
state.destroy();
expect(state.getFetchRequests()).toEqual([]);
client.dispatchTypedEvent("fetchRequest", entry("b"));
expect(state.getFetchRequests()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function prompt(name: string): Prompt {
return { name };
}
function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
return new Promise((resolve) => {
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("ManagedPromptsState", () => {
let client: FakeInspectorClient;
let state: ManagedPromptsState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new ManagedPromptsState(client);
});
it("starts with empty prompts", () => {
expect(state.getPrompts()).toEqual([]);
});
it("getPrompts returns a defensive copy", () => {
const a = state.getPrompts();
const b = state.getPrompts();
expect(a).not.toBe(b);
});
it("refresh returns early and does not call listPrompts when disconnected", async () => {
const result = await state.refresh();
expect(result).toEqual([]);
expect(client.listPrompts).not.toHaveBeenCalled();
});
it("refresh fetches a single page and dispatches promptsChange", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
const changePromise = waitForPromptsChange(state);
const result = await state.refresh();
expect(result.map((p) => p.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result);
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
});
it("refresh accumulates across multiple paginated pages", async () => {
client.setStatus("connected");
client.queuePromptPages(
{ prompts: [prompt("a")], nextCursor: "c1" },
{ prompts: [prompt("b")], nextCursor: "c2" },
{ prompts: [prompt("c")] },
);
const result = await state.refresh();
expect(result.map((p) => p.name)).toEqual(["a", "b", "c"]);
expect(client.listPrompts).toHaveBeenCalledTimes(3);
});
it("refresh passes setMetadata-supplied metadata to listPrompts", async () => {
client.setStatus("connected");
state.setMetadata({ k: "v" });
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" });
});
it("refresh argument overrides setMetadata", async () => {
client.setStatus("connected");
state.setMetadata({ k: "default" });
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh({ k: "override" });
expect(client.listPrompts).toHaveBeenCalledWith(undefined, {
k: "override",
});
});
it("connect event triggers a refresh", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
const changePromise = waitForPromptsChange(state);
client.dispatchTypedEvent("connect");
const next = await changePromise;
expect(next.map((p) => p.name)).toEqual(["a"]);
});
it("promptsListChanged event triggers a refresh", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
const changePromise = waitForPromptsChange(state);
client.dispatchTypedEvent("promptsListChanged");
const next = await changePromise;
expect(next.map((p) => p.name)).toEqual(["a", "b"]);
});
it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
expect(state.getPrompts()).toHaveLength(1);
const changePromise = waitForPromptsChange(state);
client.setStatus("disconnected");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getPrompts()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear prompts", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
client.setStatus("error");
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
});
it("throws when pagination exceeds 100 pages", async () => {
client.setStatus("connected");
client.listPrompts.mockImplementation(async () => ({
prompts: [prompt("a")],
nextCursor: "always",
}));
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
});
it("destroy unsubscribes from client events and clears state", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
expect(state.getPrompts()).toHaveLength(1);
state.destroy();
expect(state.getPrompts()).toEqual([]);
client.queuePromptPages({ prompts: [prompt("b")] });
client.dispatchTypedEvent("promptsListChanged");
await Promise.resolve();
expect(state.getPrompts()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function task(taskId: string, status: Task["status"] = "working"): Task {
return {
taskId,
status,
ttl: null,
createdAt: "2026-05-13T00:00:00Z",
lastUpdatedAt: "2026-05-13T00:00:00Z",
};
}
function waitForChange(state: ManagedRequestorTasksState): Promise<Task[]> {
return new Promise((resolve) => {
state.addEventListener("tasksChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("ManagedRequestorTasksState", () => {
let client: FakeInspectorClient;
let state: ManagedRequestorTasksState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new ManagedRequestorTasksState(client);
});
it("starts with empty tasks", () => {
expect(state.getTasks()).toEqual([]);
});
it("getTasks returns a defensive copy", () => {
const a = state.getTasks();
const b = state.getTasks();
expect(a).not.toBe(b);
});
it("refresh returns early and does not call listRequestorTasks when disconnected", async () => {
const result = await state.refresh();
expect(result).toEqual([]);
expect(client.listRequestorTasks).not.toHaveBeenCalled();
});
it("refresh fetches a single page and dispatches tasksChange", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
const changePromise = waitForChange(state);
const result = await state.refresh();
expect(result.map((t) => t.taskId)).toEqual(["t1", "t2"]);
expect(await changePromise).toEqual(result);
});
it("refresh accumulates across multiple paginated pages", async () => {
client.setStatus("connected");
client.queueTaskPages(
{ tasks: [task("t1")], nextCursor: "c1" },
{ tasks: [task("t2")], nextCursor: "c2" },
{ tasks: [task("t3")] },
);
const result = await state.refresh();
expect(result.map((t) => t.taskId)).toEqual(["t1", "t2", "t3"]);
expect(client.listRequestorTasks).toHaveBeenCalledTimes(3);
});
it("connect event triggers a refresh", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
const changePromise = waitForChange(state);
client.dispatchTypedEvent("connect");
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["t1"]);
});
it("tasksListChanged event triggers a refresh", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
const changePromise = waitForChange(state);
client.dispatchTypedEvent("tasksListChanged");
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
});
it("statusChange to disconnected clears tasks and dispatches tasksChange", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
expect(state.getTasks()).toHaveLength(1);
const changePromise = waitForChange(state);
client.setStatus("disconnected");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getTasks()).toEqual([]);
});
it("statusChange to other values does not clear tasks", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
client.setStatus("error");
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
});
it("taskStatusChange inserts a new task when the id is unknown", async () => {
client.setStatus("connected");
const newTask = task("t1", "working");
const changePromise = waitForChange(state);
client.dispatchTypedEvent("taskStatusChange", {
taskId: "t1",
task: newTask,
});
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["t1"]);
expect(next[0]!.status).toBe("working");
});
it("taskStatusChange replaces an existing task in place", async () => {
client.setStatus("connected");
client.queueTaskPages({
tasks: [task("t1", "working"), task("t2", "working")],
});
await state.refresh();
const changePromise = waitForChange(state);
client.dispatchTypedEvent("taskStatusChange", {
taskId: "t1",
task: task("t1", "completed"),
});
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
expect(next.find((t) => t.taskId === "t1")!.status).toBe("completed");
});
it("requestorTaskUpdated falls back to lastUpdatedAt when createdAt is missing", async () => {
client.setStatus("connected");
const changePromise = waitForChange(state);
client.dispatchTypedEvent("requestorTaskUpdated", {
taskId: "t1",
task: {
taskId: "t1",
status: "working",
ttl: null,
lastUpdatedAt: "2026-05-13T01:00:00Z",
},
});
const next = await changePromise;
expect(next[0]!.createdAt).toBe("2026-05-13T01:00:00Z");
});
it("taskCancelled flips status to cancelled when the task is known", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1", "working")] });
await state.refresh();
const changePromise = waitForChange(state);
client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
const next = await changePromise;
expect(next[0]!.status).toBe("cancelled");
});
it("taskCancelled is a no-op when the task is unknown", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
let dispatched = false;
state.addEventListener("tasksChange", () => {
dispatched = true;
});
client.dispatchTypedEvent("taskCancelled", { taskId: "missing" });
expect(dispatched).toBe(false);
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
});
it("throws when pagination exceeds 100 pages", async () => {
client.setStatus("connected");
client.listRequestorTasks.mockImplementation(async () => ({
tasks: [task("t1")],
nextCursor: "always",
}));
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
});
it("destroy unsubscribes from client events and clears state", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
expect(state.getTasks()).toHaveLength(1);
state.destroy();
expect(state.getTasks()).toEqual([]);
client.queueTaskPages({ tasks: [task("t2")] });
client.dispatchTypedEvent("tasksListChanged");
await Promise.resolve();
expect(state.getTasks()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function template(name: string): ResourceTemplate {
return { uriTemplate: `tpl://{${name}}`, name };
}
function waitForChange(
state: ManagedResourceTemplatesState,
): Promise<ResourceTemplate[]> {
return new Promise((resolve) => {
state.addEventListener(
"resourceTemplatesChange",
(e) => resolve(e.detail),
{ once: true },
);
});
}
describe("ManagedResourceTemplatesState", () => {
let client: FakeInspectorClient;
let state: ManagedResourceTemplatesState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new ManagedResourceTemplatesState(client);
});
it("starts with empty resource templates", () => {
expect(state.getResourceTemplates()).toEqual([]);
});
it("getResourceTemplates returns a defensive copy", () => {
const a = state.getResourceTemplates();
const b = state.getResourceTemplates();
expect(a).not.toBe(b);
});
it("refresh returns early and does not call listResourceTemplates when disconnected", async () => {
const result = await state.refresh();
expect(result).toEqual([]);
expect(client.listResourceTemplates).not.toHaveBeenCalled();
});
it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({
resourceTemplates: [template("a"), template("b")],
});
const changePromise = waitForChange(state);
const result = await state.refresh();
expect(result.map((t) => t.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result);
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]);
});
it("refresh accumulates across multiple paginated pages", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages(
{ resourceTemplates: [template("a")], nextCursor: "c1" },
{ resourceTemplates: [template("b")], nextCursor: "c2" },
{ resourceTemplates: [template("c")] },
);
const result = await state.refresh();
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
expect(client.listResourceTemplates).toHaveBeenCalledTimes(3);
});
it("refresh passes setMetadata-supplied metadata", async () => {
client.setStatus("connected");
state.setMetadata({ k: "v" });
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
k: "v",
});
});
it("refresh argument overrides setMetadata", async () => {
client.setStatus("connected");
state.setMetadata({ k: "default" });
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh({ k: "override" });
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
k: "override",
});
});
it("connect event triggers a refresh", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
const changePromise = waitForChange(state);
client.dispatchTypedEvent("connect");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a"]);
});
it("resourceTemplatesListChanged event triggers a refresh", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({
resourceTemplates: [template("a"), template("b")],
});
const changePromise = waitForChange(state);
client.dispatchTypedEvent("resourceTemplatesListChanged");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
});
it("statusChange to disconnected clears templates and dispatches change", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
expect(state.getResourceTemplates()).toHaveLength(1);
const changePromise = waitForChange(state);
client.setStatus("disconnected");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getResourceTemplates()).toEqual([]);
});
it("statusChange to other values does not clear templates", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
client.setStatus("error");
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
});
it("throws when pagination exceeds 100 pages", async () => {
client.setStatus("connected");
client.listResourceTemplates.mockImplementation(async () => ({
resourceTemplates: [template("a")],
nextCursor: "always",
}));
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
});
it("destroy unsubscribes from client events and clears state", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
expect(state.getResourceTemplates()).toHaveLength(1);
state.destroy();
expect(state.getResourceTemplates()).toEqual([]);
client.queueResourceTemplatePages({ resourceTemplates: [template("b")] });
client.dispatchTypedEvent("resourceTemplatesListChanged");
await Promise.resolve();
expect(state.getResourceTemplates()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function resource(uri: string): Resource {
return { uri, name: uri };
}
function waitForResourcesChange(
state: ManagedResourcesState,
): Promise<Resource[]> {
return new Promise((resolve) => {
state.addEventListener("resourcesChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("ManagedResourcesState", () => {
let client: FakeInspectorClient;
let state: ManagedResourcesState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new ManagedResourcesState(client);
});
it("starts with empty resources", () => {
expect(state.getResources()).toEqual([]);
});
it("getResources returns a defensive copy", () => {
const a = state.getResources();
const b = state.getResources();
expect(a).not.toBe(b);
});
it("refresh returns early and does not call listResources when disconnected", async () => {
const result = await state.refresh();
expect(result).toEqual([]);
expect(client.listResources).not.toHaveBeenCalled();
});
it("refresh fetches a single page and dispatches resourcesChange", async () => {
client.setStatus("connected");
client.queueResourcePages({
resources: [resource("a://1"), resource("a://2")],
});
const changePromise = waitForResourcesChange(state);
const result = await state.refresh();
expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
expect(await changePromise).toEqual(result);
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
});
it("refresh accumulates across multiple paginated pages", async () => {
client.setStatus("connected");
client.queueResourcePages(
{ resources: [resource("a://1")], nextCursor: "c1" },
{ resources: [resource("a://2")], nextCursor: "c2" },
{ resources: [resource("a://3")] },
);
const result = await state.refresh();
expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2", "a://3"]);
expect(client.listResources).toHaveBeenCalledTimes(3);
});
it("refresh passes setMetadata-supplied metadata to listResources", async () => {
client.setStatus("connected");
state.setMetadata({ k: "v" });
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" });
});
it("refresh argument overrides setMetadata", async () => {
client.setStatus("connected");
state.setMetadata({ k: "default" });
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh({ k: "override" });
expect(client.listResources).toHaveBeenCalledWith(undefined, {
k: "override",
});
});
it("connect event triggers a refresh", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
const changePromise = waitForResourcesChange(state);
client.dispatchTypedEvent("connect");
const next = await changePromise;
expect(next.map((r) => r.uri)).toEqual(["a://1"]);
});
it("resourcesListChanged event triggers a refresh", async () => {
client.setStatus("connected");
client.queueResourcePages({
resources: [resource("a://1"), resource("a://2")],
});
const changePromise = waitForResourcesChange(state);
client.dispatchTypedEvent("resourcesListChanged");
const next = await changePromise;
expect(next.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
});
it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
expect(state.getResources()).toHaveLength(1);
const changePromise = waitForResourcesChange(state);
client.setStatus("disconnected");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getResources()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear resources", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
client.setStatus("error");
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
});
it("throws when pagination exceeds 100 pages", async () => {
client.setStatus("connected");
client.listResources.mockImplementation(async () => ({
resources: [resource("a://1")],
nextCursor: "always",
}));
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
});
it("destroy unsubscribes from client events and clears state", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
expect(state.getResources()).toHaveLength(1);
state.destroy();
expect(state.getResources()).toEqual([]);
client.queueResourcePages({ resources: [resource("a://2")] });
client.dispatchTypedEvent("resourcesListChanged");
await Promise.resolve();
expect(state.getResources()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function tool(name: string): Tool {
return { name, inputSchema: { type: "object" } };
}
function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
return new Promise((resolve) => {
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("ManagedToolsState", () => {
let client: FakeInspectorClient;
let state: ManagedToolsState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new ManagedToolsState(client);
});
it("starts with empty tools", () => {
expect(state.getTools()).toEqual([]);
});
it("getTools returns a defensive copy", () => {
const a = state.getTools();
const b = state.getTools();
expect(a).not.toBe(b);
});
it("refresh returns early and does not call listTools when disconnected", async () => {
const result = await state.refresh();
expect(result).toEqual([]);
expect(client.listTools).not.toHaveBeenCalled();
});
it("refresh fetches a single page and dispatches toolsChange", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a"), tool("b")] });
const changePromise = waitForToolsChange(state);
const result = await state.refresh();
expect(result.map((t) => t.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result);
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
});
it("refresh accumulates across multiple paginated pages", async () => {
client.setStatus("connected");
client.queueToolPages(
{ tools: [tool("a")], nextCursor: "c1" },
{ tools: [tool("b")], nextCursor: "c2" },
{ tools: [tool("c")] },
);
const result = await state.refresh();
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
expect(client.listTools).toHaveBeenCalledTimes(3);
});
it("refresh passes setMetadata-supplied metadata to listTools", async () => {
client.setStatus("connected");
state.setMetadata({ k: "v" });
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "v" });
});
it("refresh argument overrides setMetadata", async () => {
client.setStatus("connected");
state.setMetadata({ k: "default" });
client.queueToolPages({ tools: [tool("a")] });
await state.refresh({ k: "override" });
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "override" });
});
it("connect event triggers a refresh", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
const changePromise = waitForToolsChange(state);
client.dispatchTypedEvent("connect");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a"]);
});
it("toolsListChanged event triggers a refresh", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a"), tool("b")] });
const changePromise = waitForToolsChange(state);
client.dispatchTypedEvent("toolsListChanged");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
});
it("statusChange to disconnected clears tools and dispatches toolsChange", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
expect(state.getTools()).toHaveLength(1);
const changePromise = waitForToolsChange(state);
client.setStatus("disconnected");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getTools()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear tools", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
client.setStatus("error");
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
});
it("throws when pagination exceeds 100 pages", async () => {
client.setStatus("connected");
// Always returns a non-terminal cursor; refresh should bail out at the cap.
client.listTools.mockImplementation(async () => ({
tools: [tool("a")],
nextCursor: "always",
}));
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
});
it("destroy unsubscribes from client events and clears state", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
expect(state.getTools()).toHaveLength(1);
state.destroy();
expect(state.getTools()).toEqual([]);
// No further refreshes after destroy(): events on client should be ignored.
client.queueToolPages({ tools: [tool("b")] });
client.dispatchTypedEvent("toolsListChanged");
// Give microtasks a chance to flush so a stray refresh would have landed.
await Promise.resolve();
expect(state.getTools()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,198 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { MessageEntry } from "@inspector/core/mcp/types";
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function requestEntry(id: number, method = "tools/list"): MessageEntry {
return {
id: `req-${id}`,
timestamp: new Date(2026, 4, 13, 0, 0, 0, id),
direction: "request",
message: { jsonrpc: "2.0", id, method, params: {} },
};
}
function responseEntry(id: number): MessageEntry {
return {
id: `resp-${id}`,
timestamp: new Date(2026, 4, 13, 0, 0, 0, id + 100),
direction: "response",
message: { jsonrpc: "2.0", id, result: {} },
};
}
function notificationEntry(method = "notifications/ping"): MessageEntry {
return {
id: "notif-1",
timestamp: new Date(2026, 4, 13, 0, 0, 1),
direction: "notification",
message: { jsonrpc: "2.0", method, params: {} },
};
}
describe("MessageLogState", () => {
let client: FakeInspectorClient;
let state: MessageLogState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new MessageLogState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getMessages()).toEqual([]);
expect(state.getMessages()).not.toBe(state.getMessages());
});
it("appends a request entry and dispatches message + messagesChange", () => {
const seenSingle: MessageEntry[] = [];
const seenFull: MessageEntry[][] = [];
state.addEventListener("message", (e) => seenSingle.push(e.detail));
state.addEventListener("messagesChange", (e) => seenFull.push(e.detail));
client.dispatchTypedEvent("message", requestEntry(1));
expect(seenSingle).toHaveLength(1);
expect(seenFull).toHaveLength(1);
expect(state.getMessages()).toHaveLength(1);
});
it("matches a response to its pending request and updates duration", () => {
client.dispatchTypedEvent("message", requestEntry(1));
const seen: MessageEntry[][] = [];
state.addEventListener("messagesChange", (e) => seen.push(e.detail));
client.dispatchTypedEvent("message", responseEntry(1));
const merged = state.getMessages();
expect(merged).toHaveLength(1);
expect(merged[0]!.response).toBeDefined();
expect(merged[0]!.duration).toBe(100);
});
it("appends an unmatched response when no pending request exists", () => {
client.dispatchTypedEvent("message", responseEntry(99));
expect(state.getMessages()).toHaveLength(1);
expect(state.getMessages()[0]!.direction).toBe("response");
});
it("appends notification entries", () => {
client.dispatchTypedEvent("message", notificationEntry());
expect(state.getMessages()).toHaveLength(1);
expect(state.getMessages()[0]!.direction).toBe("notification");
});
it("trims the oldest entries when maxMessages is exceeded", () => {
const small = new MessageLogState(client, { maxMessages: 2 });
client.dispatchTypedEvent("message", notificationEntry("a"));
client.dispatchTypedEvent("message", notificationEntry("b"));
client.dispatchTypedEvent("message", notificationEntry("c"));
const seen = small
.getMessages()
.map((m) => (m.message as { method?: string }).method);
expect(seen).toEqual(["b", "c"]);
});
it("does not trim when maxMessages is 0", () => {
const big = new MessageLogState(client, { maxMessages: 0 });
for (let i = 0; i < 5; i++) {
client.dispatchTypedEvent("message", notificationEntry(`m${i}`));
}
expect(big.getMessages()).toHaveLength(5);
});
it("clearMessages with no predicate empties the list and dispatches", () => {
client.dispatchTypedEvent("message", notificationEntry());
let changeCount = 0;
state.addEventListener("messagesChange", () => changeCount++);
state.clearMessages();
expect(state.getMessages()).toEqual([]);
expect(changeCount).toBe(1);
});
it("clearMessages with a predicate filters and dispatches only when changed", () => {
client.dispatchTypedEvent("message", notificationEntry("a"));
client.dispatchTypedEvent("message", notificationEntry("b"));
let changeCount = 0;
state.addEventListener("messagesChange", () => changeCount++);
state.clearMessages(
(m) => (m.message as { method?: string }).method === "a",
);
expect(state.getMessages()).toHaveLength(1);
expect(changeCount).toBe(1);
state.clearMessages(
(m) => (m.message as { method?: string }).method === "missing",
);
expect(changeCount).toBe(1);
});
it("clearMessages drops pending-request bookkeeping for evicted requests", () => {
client.dispatchTypedEvent("message", requestEntry(1));
state.clearMessages((m) => m.direction === "request");
expect(state.getMessages()).toEqual([]);
// The matching response should now fall through to the unmatched-response
// branch (append) instead of mutating an unreachable pending entry.
client.dispatchTypedEvent("message", responseEntry(1));
expect(state.getMessages()).toHaveLength(1);
expect(state.getMessages()[0]!.direction).toBe("response");
expect(state.getMessages()[0]!.duration).toBeUndefined();
});
it("clearMessages without a predicate also drops all pending bookkeeping", () => {
client.dispatchTypedEvent("message", requestEntry(1));
state.clearMessages();
client.dispatchTypedEvent("message", responseEntry(1));
expect(state.getMessages()).toHaveLength(1);
expect(state.getMessages()[0]!.direction).toBe("response");
});
it("getMessages applies the predicate filter", () => {
client.dispatchTypedEvent("message", notificationEntry("keep"));
client.dispatchTypedEvent("message", notificationEntry("drop"));
const keep = state.getMessages(
(m) => (m.message as { method?: string }).method === "keep",
);
expect(keep).toHaveLength(1);
});
it("clears on connect", () => {
client.dispatchTypedEvent("message", notificationEntry());
expect(state.getMessages()).toHaveLength(1);
let dispatched = false;
state.addEventListener("messagesChange", () => (dispatched = true));
client.dispatchTypedEvent("connect");
expect(state.getMessages()).toEqual([]);
expect(dispatched).toBe(true);
});
it("clears on disconnect (statusChange -> disconnected)", () => {
client.setStatus("connected");
client.dispatchTypedEvent("message", notificationEntry());
expect(state.getMessages()).toHaveLength(1);
let dispatched = false;
state.addEventListener("messagesChange", () => (dispatched = true));
client.setStatus("disconnected");
expect(state.getMessages()).toEqual([]);
expect(dispatched).toBe(true);
});
it("does not clear on non-disconnected status changes", () => {
client.dispatchTypedEvent("message", notificationEntry());
client.setStatus("error");
expect(state.getMessages()).toHaveLength(1);
});
it("destroy stops listening and clears state", () => {
client.dispatchTypedEvent("message", notificationEntry());
state.destroy();
expect(state.getMessages()).toEqual([]);
client.dispatchTypedEvent("message", notificationEntry());
expect(state.getMessages()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function prompt(name: string): Prompt {
return { name };
}
function waitForChange(state: PagedPromptsState): Promise<Prompt[]> {
return new Promise((resolve) => {
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedPromptsState", () => {
let client: FakeInspectorClient;
let state: PagedPromptsState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new PagedPromptsState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getPrompts()).toEqual([]);
expect(state.getPrompts()).not.toBe(state.getPrompts());
});
it("loadPage no-ops when disconnected", async () => {
const result = await state.loadPage();
expect(result).toEqual({ prompts: [], nextCursor: undefined });
expect(client.listPrompts).not.toHaveBeenCalled();
});
it("loadPage without cursor replaces the aggregated list", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
const changePromise = waitForChange(state);
const result = await state.loadPage();
expect(result.prompts.map((p) => p.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result.prompts);
});
it("loadPage with cursor appends to the aggregated list", async () => {
client.setStatus("connected");
client.queuePromptPages(
{ prompts: [prompt("a")], nextCursor: "c1" },
{ prompts: [prompt("b")] },
);
await state.loadPage();
await state.loadPage("c1");
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
});
it("loadPage forwards metadata", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage(undefined, { k: "v" });
expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" });
});
it("clear empties the aggregated list and dispatches", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
state.clear();
expect(await changePromise).toEqual([]);
expect(state.getPrompts()).toEqual([]);
});
it("statusChange to disconnected clears prompts", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("disconnected");
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
client.setStatus("error");
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
state.destroy();
expect(state.getPrompts()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import { PagedRequestorTasksState } from "@inspector/core/mcp/state/pagedRequestorTasksState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function task(taskId: string, status: Task["status"] = "working"): Task {
return {
taskId,
status,
ttl: null,
createdAt: "2026-05-13T00:00:00Z",
lastUpdatedAt: "2026-05-13T00:00:00Z",
};
}
function waitForChange(state: PagedRequestorTasksState): Promise<Task[]> {
return new Promise((resolve) => {
state.addEventListener("tasksChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedRequestorTasksState", () => {
let client: FakeInspectorClient;
let state: PagedRequestorTasksState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new PagedRequestorTasksState(client);
});
it("starts empty and exposes nextCursor", () => {
expect(state.getTasks()).toEqual([]);
expect(state.getNextCursor()).toBeUndefined();
});
it("loadPage no-ops when disconnected", async () => {
const result = await state.loadPage();
expect(result).toEqual({ tasks: [], nextCursor: undefined });
expect(client.listRequestorTasks).not.toHaveBeenCalled();
});
it("loadPage without cursor replaces tasks and stores nextCursor", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
const changePromise = waitForChange(state);
const result = await state.loadPage();
expect(result.tasks.map((t) => t.taskId)).toEqual(["t1"]);
expect(result.nextCursor).toBe("c1");
expect(state.getNextCursor()).toBe("c1");
expect(await changePromise).toEqual(result.tasks);
});
it("loadPage with cursor appends tasks and updates nextCursor", async () => {
client.setStatus("connected");
client.queueTaskPages(
{ tasks: [task("t1")], nextCursor: "c1" },
{ tasks: [task("t2")] },
);
await state.loadPage();
await state.loadPage("c1");
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1", "t2"]);
expect(state.getNextCursor()).toBeUndefined();
});
it("clear empties tasks, resets nextCursor, and dispatches", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
await state.loadPage();
const changePromise = waitForChange(state);
state.clear();
expect(await changePromise).toEqual([]);
expect(state.getTasks()).toEqual([]);
expect(state.getNextCursor()).toBeUndefined();
});
it("tasksListChanged refetches the first page", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
const changePromise = waitForChange(state);
client.dispatchTypedEvent("tasksListChanged");
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
});
it("taskStatusChange merges a known task", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1", "working")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.dispatchTypedEvent("taskStatusChange", {
taskId: "t1",
task: task("t1", "completed"),
});
const next = await changePromise;
expect(next[0]!.status).toBe("completed");
});
it("requestorTaskUpdated inserts an unknown task at the front", async () => {
client.setStatus("connected");
const changePromise = waitForChange(state);
client.dispatchTypedEvent("requestorTaskUpdated", {
taskId: "tx",
task: {
taskId: "tx",
status: "working",
ttl: null,
lastUpdatedAt: "2026-05-13T03:00:00Z",
},
});
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["tx"]);
expect(next[0]!.createdAt).toBe("2026-05-13T03:00:00Z");
});
it("taskCancelled flips status when the task is known", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1", "working")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
const next = await changePromise;
expect(next[0]!.status).toBe("cancelled");
});
it("taskCancelled is a no-op when the task is unknown", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.loadPage();
let dispatched = false;
state.addEventListener("tasksChange", () => {
dispatched = true;
});
client.dispatchTypedEvent("taskCancelled", { taskId: "missing" });
expect(dispatched).toBe(false);
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
});
it("statusChange to disconnected clears tasks and nextCursor", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("disconnected");
expect(await changePromise).toEqual([]);
expect(state.getNextCursor()).toBeUndefined();
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.loadPage();
state.destroy();
expect(state.getTasks()).toEqual([]);
expect(state.getNextCursor()).toBeUndefined();
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { PagedResourceTemplatesState } from "@inspector/core/mcp/state/pagedResourceTemplatesState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function template(name: string): ResourceTemplate {
return { uriTemplate: `tpl://{${name}}`, name };
}
function waitForChange(
state: PagedResourceTemplatesState,
): Promise<ResourceTemplate[]> {
return new Promise((resolve) => {
state.addEventListener(
"resourceTemplatesChange",
(e) => resolve(e.detail),
{ once: true },
);
});
}
describe("PagedResourceTemplatesState", () => {
let client: FakeInspectorClient;
let state: PagedResourceTemplatesState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new PagedResourceTemplatesState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getResourceTemplates()).toEqual([]);
expect(state.getResourceTemplates()).not.toBe(state.getResourceTemplates());
});
it("loadPage no-ops when disconnected", async () => {
const result = await state.loadPage();
expect(result).toEqual({ resourceTemplates: [], nextCursor: undefined });
expect(client.listResourceTemplates).not.toHaveBeenCalled();
});
it("loadPage without cursor replaces the aggregated list", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({
resourceTemplates: [template("a"), template("b")],
});
const changePromise = waitForChange(state);
const result = await state.loadPage();
expect(result.resourceTemplates.map((t) => t.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result.resourceTemplates);
});
it("loadPage with cursor appends to the aggregated list", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages(
{ resourceTemplates: [template("a")], nextCursor: "c1" },
{ resourceTemplates: [template("b")] },
);
await state.loadPage();
await state.loadPage("c1");
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]);
});
it("loadPage forwards metadata", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage(undefined, { k: "v" });
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
k: "v",
});
});
it("clear empties the aggregated list and dispatches", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
state.clear();
expect(await changePromise).toEqual([]);
expect(state.getResourceTemplates()).toEqual([]);
});
it("statusChange to disconnected clears templates", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("disconnected");
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
client.setStatus("error");
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
state.destroy();
expect(state.getResourceTemplates()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function resource(uri: string): Resource {
return { uri, name: uri };
}
function waitForChange(state: PagedResourcesState): Promise<Resource[]> {
return new Promise((resolve) => {
state.addEventListener("resourcesChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedResourcesState", () => {
let client: FakeInspectorClient;
let state: PagedResourcesState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new PagedResourcesState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getResources()).toEqual([]);
expect(state.getResources()).not.toBe(state.getResources());
});
it("loadPage no-ops when disconnected", async () => {
const result = await state.loadPage();
expect(result).toEqual({ resources: [], nextCursor: undefined });
expect(client.listResources).not.toHaveBeenCalled();
});
it("loadPage without cursor replaces the aggregated list", async () => {
client.setStatus("connected");
client.queueResourcePages({
resources: [resource("a://1"), resource("a://2")],
});
const changePromise = waitForChange(state);
const result = await state.loadPage();
expect(result.resources.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
expect(await changePromise).toEqual(result.resources);
});
it("loadPage with cursor appends to the aggregated list", async () => {
client.setStatus("connected");
client.queueResourcePages(
{ resources: [resource("a://1")], nextCursor: "c1" },
{ resources: [resource("a://2")] },
);
await state.loadPage();
await state.loadPage("c1");
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
});
it("loadPage forwards metadata", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage(undefined, { k: "v" });
expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" });
});
it("clear empties the aggregated list and dispatches", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
const changePromise = waitForChange(state);
state.clear();
expect(await changePromise).toEqual([]);
expect(state.getResources()).toEqual([]);
});
it("statusChange to disconnected clears resources", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("disconnected");
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
client.setStatus("error");
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
state.destroy();
expect(state.getResources()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function tool(name: string): Tool {
return { name, inputSchema: { type: "object" } };
}
function waitForChange(state: PagedToolsState): Promise<Tool[]> {
return new Promise((resolve) => {
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
once: true,
});
});
}
describe("PagedToolsState", () => {
let client: FakeInspectorClient;
let state: PagedToolsState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new PagedToolsState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getTools()).toEqual([]);
expect(state.getTools()).not.toBe(state.getTools());
});
it("loadPage no-ops when disconnected", async () => {
const result = await state.loadPage();
expect(result).toEqual({ tools: [], nextCursor: undefined });
expect(client.listTools).not.toHaveBeenCalled();
});
it("loadPage without cursor replaces the aggregated list", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a"), tool("b")] });
const changePromise = waitForChange(state);
const result = await state.loadPage();
expect(result.tools.map((t) => t.name)).toEqual(["a", "b"]);
expect(await changePromise).toEqual(result.tools);
});
it("loadPage with cursor appends to the aggregated list", async () => {
client.setStatus("connected");
client.queueToolPages(
{ tools: [tool("a")], nextCursor: "c1" },
{ tools: [tool("b")] },
);
const first = await state.loadPage();
expect(first.nextCursor).toBe("c1");
const second = await state.loadPage("c1");
expect(second.nextCursor).toBeUndefined();
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
});
it("clear empties the aggregated list and dispatches", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
state.clear();
expect(await changePromise).toEqual([]);
expect(state.getTools()).toEqual([]);
});
it("statusChange to disconnected clears tools", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("disconnected");
expect(await changePromise).toEqual([]);
expect(state.getTools()).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
client.setStatus("error");
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
});
it("does not subscribe to toolsListChanged (paged is caller-driven)", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
client.dispatchTypedEvent("toolsListChanged");
await Promise.resolve();
expect(state.getTools()).toEqual([]);
expect(client.listTools).not.toHaveBeenCalled();
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
state.destroy();
expect(state.getTools()).toEqual([]);
client.setStatus("disconnected");
await Promise.resolve();
expect(state.getTools()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { StderrLogEntry } from "@inspector/core/mcp/types";
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
function entry(message: string): StderrLogEntry {
return { message, timestamp: new Date(2026, 4, 13) };
}
describe("StderrLogState", () => {
let client: FakeInspectorClient;
let state: StderrLogState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new StderrLogState(client);
});
it("starts empty and returns defensive copies", () => {
expect(state.getStderrLogs()).toEqual([]);
expect(state.getStderrLogs()).not.toBe(state.getStderrLogs());
});
it("appends entries and dispatches stderrLog + stderrLogsChange", () => {
const seenSingle: StderrLogEntry[] = [];
const seenFull: StderrLogEntry[][] = [];
state.addEventListener("stderrLog", (e) => seenSingle.push(e.detail));
state.addEventListener("stderrLogsChange", (e) => seenFull.push(e.detail));
client.dispatchTypedEvent("stderrLog", entry("a"));
client.dispatchTypedEvent("stderrLog", entry("b"));
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a", "b"]);
expect(seenSingle).toHaveLength(2);
expect(seenFull).toHaveLength(2);
});
it("trims the oldest entries when maxStderrLogEvents is exceeded", () => {
const small = new StderrLogState(client, { maxStderrLogEvents: 2 });
client.dispatchTypedEvent("stderrLog", entry("a"));
client.dispatchTypedEvent("stderrLog", entry("b"));
client.dispatchTypedEvent("stderrLog", entry("c"));
expect(small.getStderrLogs().map((e) => e.message)).toEqual(["b", "c"]);
});
it("does not trim when maxStderrLogEvents is 0", () => {
const big = new StderrLogState(client, { maxStderrLogEvents: 0 });
for (let i = 0; i < 5; i++) {
client.dispatchTypedEvent("stderrLog", entry(`m${i}`));
}
expect(big.getStderrLogs()).toHaveLength(5);
});
it("clearStderrLogs dispatches when the list was non-empty", () => {
client.dispatchTypedEvent("stderrLog", entry("a"));
let dispatched = false;
state.addEventListener("stderrLogsChange", () => (dispatched = true));
state.clearStderrLogs();
expect(dispatched).toBe(true);
expect(state.getStderrLogs()).toEqual([]);
});
it("clearStderrLogs is a no-op when the list is empty", () => {
let dispatched = false;
state.addEventListener("stderrLogsChange", () => (dispatched = true));
state.clearStderrLogs();
expect(dispatched).toBe(false);
});
it("does NOT clear on connect or disconnect", () => {
client.dispatchTypedEvent("stderrLog", entry("a"));
client.dispatchTypedEvent("connect");
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a"]);
client.setStatus("disconnected");
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a"]);
});
it("destroy stops listening and clears state", () => {
client.dispatchTypedEvent("stderrLog", entry("a"));
state.destroy();
expect(state.getStderrLogs()).toEqual([]);
client.dispatchTypedEvent("stderrLog", entry("b"));
expect(state.getStderrLogs()).toEqual([]);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
});

View file

@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { FetchRequestEntry } from "@inspector/core/mcp/types";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState";
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog";
function entry(id: string): FetchRequestEntry {
return {
id,
timestamp: new Date(2026, 4, 13),
method: "GET",
url: `https://x/${id}`,
requestHeaders: {},
category: "transport",
};
}
describe("useFetchRequestLog", () => {
let client: FakeInspectorClient;
let state: FetchRequestLogState;
beforeEach(() => {
client = new FakeInspectorClient();
state = new FetchRequestLogState(client);
});
it("returns the initial snapshot from the state", () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
const { result } = renderHook(() => useFetchRequestLog(state));
expect(result.current.fetchRequests.map((e) => e.id)).toEqual(["a"]);
});
it("returns empty when state is null", () => {
const { result } = renderHook(() => useFetchRequestLog(null));
expect(result.current.fetchRequests).toEqual([]);
});
it("updates when state dispatches fetchRequestsChange", async () => {
const { result } = renderHook(() => useFetchRequestLog(state));
act(() => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
});
await waitFor(() => {
expect(result.current.fetchRequests).toHaveLength(1);
});
});
it("resets to empty when the state prop becomes null", async () => {
client.dispatchTypedEvent("fetchRequest", entry("a"));
const { result, rerender } = renderHook(
({ s }: { s: FetchRequestLogState | null }) => useFetchRequestLog(s),
{ initialProps: { s: state as FetchRequestLogState | null } },
);
rerender({ s: null });
await waitFor(() => {
expect(result.current.fetchRequests).toEqual([]);
});
});
it("unsubscribes on unmount", () => {
const { result, unmount } = renderHook(() => useFetchRequestLog(state));
unmount();
client.dispatchTypedEvent("fetchRequest", entry("a"));
expect(result.current.fetchRequests).toEqual([]);
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type {
Implementation,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient";
import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol";
const CAPABILITIES: ServerCapabilities = { tools: { listChanged: true } };
const SERVER_INFO: Implementation = { name: "srv", version: "1.0" };
describe("useInspectorClient", () => {
it("returns initial accessors from the client", () => {
const client = new FakeInspectorClient({
status: "connected",
capabilities: CAPABILITIES,
serverInfo: SERVER_INFO,
instructions: "hello",
});
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.status).toBe("connected");
expect(result.current.capabilities).toEqual(CAPABILITIES);
expect(result.current.serverInfo).toEqual(SERVER_INFO);
expect(result.current.instructions).toBe("hello");
expect(result.current.appRendererClient).toBeNull();
});
it("returns disconnected defaults when client is null", () => {
const { result } = renderHook(() => useInspectorClient(null));
expect(result.current.status).toBe("disconnected");
expect(result.current.capabilities).toBeUndefined();
expect(result.current.serverInfo).toBeUndefined();
expect(result.current.instructions).toBeUndefined();
expect(result.current.appRendererClient).toBeNull();
});
it("subscribes to statusChange and updates", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.status).toBe("disconnected");
act(() => {
client.setStatus("connecting");
});
expect(result.current.status).toBe("connecting");
act(() => {
client.setStatus("connected");
});
expect(result.current.status).toBe("connected");
});
it("subscribes to capabilities/serverInfo/instructions changes", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
act(() => {
client.setCapabilities(CAPABILITIES);
client.setServerInfo(SERVER_INFO);
client.setInstructions("after");
});
expect(result.current.capabilities).toEqual(CAPABILITIES);
expect(result.current.serverInfo).toEqual(SERVER_INFO);
expect(result.current.instructions).toBe("after");
});
it("connect() and disconnect() proxy to the client and update status", async () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
await act(async () => {
await result.current.connect();
});
expect(result.current.status).toBe("connected");
await act(async () => {
await result.current.disconnect();
});
expect(result.current.status).toBe("disconnected");
});
it("connect() and disconnect() are no-ops when client is null", async () => {
const { result } = renderHook(() => useInspectorClient(null));
await expect(result.current.connect()).resolves.toBeUndefined();
await expect(result.current.disconnect()).resolves.toBeUndefined();
});
it("resets to defaults when client becomes null", () => {
const client = new FakeInspectorClient({
status: "connected",
capabilities: CAPABILITIES,
});
const { result, rerender } = renderHook(
({ c }: { c: InspectorClientProtocol | null }) => useInspectorClient(c),
{ initialProps: { c: client as InspectorClientProtocol | null } },
);
expect(result.current.status).toBe("connected");
rerender({ c: null });
expect(result.current.status).toBe("disconnected");
expect(result.current.capabilities).toBeUndefined();
});
it("re-subscribes when the client prop changes", () => {
const a = new FakeInspectorClient({ status: "connected" });
const b = new FakeInspectorClient({ status: "disconnected" });
const { result, rerender } = renderHook(
({ c }: { c: InspectorClientProtocol }) => useInspectorClient(c),
{ initialProps: { c: a as InspectorClientProtocol } },
);
expect(result.current.status).toBe("connected");
rerender({ c: b });
expect(result.current.status).toBe("disconnected");
// After rerender, events on the OLD client should be ignored.
act(() => {
a.setStatus("error");
});
expect(result.current.status).toBe("disconnected");
// Events on the NEW client should be observed.
act(() => {
b.setStatus("connecting");
});
expect(result.current.status).toBe("connecting");
});
it("unsubscribes on unmount", () => {
const client = new FakeInspectorClient({ status: "connected" });
const { result, unmount } = renderHook(() => useInspectorClient(client));
unmount();
act(() => {
client.setStatus("error");
});
// The hook is unmounted; result.current still reads the last rendered
// value ("connected") rather than the post-unmount event.
expect(result.current.status).toBe("connected");
});
it("reads appRendererClient lazily from the client on each render", () => {
const client = new FakeInspectorClient({ status: "connected" });
const { result, rerender } = renderHook(() => useInspectorClient(client));
expect(result.current.appRendererClient).toBeNull();
const sentinel = { iam: "renderer" };
client.setAppRendererClient(sentinel);
rerender();
expect(result.current.appRendererClient).toBe(sentinel);
});
});

View file

@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts";
function prompt(name: string): Prompt {
return { name };
}
describe("useManagedPrompts", () => {
let client: FakeInspectorClient;
let state: ManagedPromptsState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
state = new ManagedPromptsState(client);
});
it("returns the initial prompts snapshot from the state", async () => {
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
await state.refresh();
const { result } = renderHook(() => useManagedPrompts(client, state));
expect(result.current.prompts.map((p) => p.name)).toEqual(["a", "b"]);
});
it("returns empty prompts when state is null", () => {
const { result } = renderHook(() => useManagedPrompts(client, null));
expect(result.current.prompts).toEqual([]);
});
it("updates when state dispatches promptsChange", async () => {
const { result } = renderHook(() => useManagedPrompts(client, state));
expect(result.current.prompts).toEqual([]);
client.queuePromptPages({ prompts: [prompt("a")] });
await act(async () => {
await state.refresh();
});
await waitFor(() => {
expect(result.current.prompts.map((p) => p.name)).toEqual(["a"]);
});
});
it("refresh() calls through to state and returns the next prompts", async () => {
client.queuePromptPages({ prompts: [prompt("x")] });
const { result } = renderHook(() => useManagedPrompts(client, state));
let next: Prompt[] = [];
await act(async () => {
next = await result.current.refresh();
});
expect(next.map((p) => p.name)).toEqual(["x"]);
expect(result.current.prompts.map((p) => p.name)).toEqual(["x"]);
});
it("refresh() returns [] when state or client is null", async () => {
const { result } = renderHook(() => useManagedPrompts(null, state));
await expect(result.current.refresh()).resolves.toEqual([]);
const { result: result2 } = renderHook(() =>
useManagedPrompts(client, null),
);
await expect(result2.current.refresh()).resolves.toEqual([]);
});
it("resets to empty prompts when the state prop becomes null", async () => {
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
const { result, rerender } = renderHook(
({ s }: { s: ManagedPromptsState | null }) =>
useManagedPrompts(client, s),
{ initialProps: { s: state as ManagedPromptsState | null } },
);
await waitFor(() => {
expect(result.current.prompts.map((p) => p.name)).toEqual(["a"]);
});
rerender({ s: null });
await waitFor(() => {
expect(result.current.prompts).toEqual([]);
});
});
it("unsubscribes from the state on unmount", async () => {
const { result, unmount } = renderHook(() =>
useManagedPrompts(client, state),
);
unmount();
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
expect(result.current.prompts).toEqual([]);
});
});

View file

@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks";
function task(taskId: string, status: Task["status"] = "working"): Task {
return {
taskId,
status,
ttl: null,
createdAt: "2026-05-13T00:00:00Z",
lastUpdatedAt: "2026-05-13T00:00:00Z",
};
}
describe("useManagedRequestorTasks", () => {
let client: FakeInspectorClient;
let state: ManagedRequestorTasksState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
state = new ManagedRequestorTasksState(client);
});
it("returns the initial tasks snapshot from the state", async () => {
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
await state.refresh();
const { result } = renderHook(() =>
useManagedRequestorTasks(client, state),
);
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1", "t2"]);
});
it("returns empty tasks when state is null", () => {
const { result } = renderHook(() => useManagedRequestorTasks(client, null));
expect(result.current.tasks).toEqual([]);
});
it("updates when state dispatches tasksChange", async () => {
const { result } = renderHook(() =>
useManagedRequestorTasks(client, state),
);
expect(result.current.tasks).toEqual([]);
client.queueTaskPages({ tasks: [task("t1")] });
await act(async () => {
await state.refresh();
});
await waitFor(() => {
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1"]);
});
});
it("refresh() calls through to state and returns the next tasks", async () => {
client.queueTaskPages({ tasks: [task("tx")] });
const { result } = renderHook(() =>
useManagedRequestorTasks(client, state),
);
let next: Task[] = [];
await act(async () => {
next = await result.current.refresh();
});
expect(next.map((t) => t.taskId)).toEqual(["tx"]);
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["tx"]);
});
it("refresh() returns [] when state or client is null", async () => {
const { result } = renderHook(() => useManagedRequestorTasks(null, state));
await expect(result.current.refresh()).resolves.toEqual([]);
const { result: result2 } = renderHook(() =>
useManagedRequestorTasks(client, null),
);
await expect(result2.current.refresh()).resolves.toEqual([]);
});
it("resets to empty tasks when the state prop becomes null", async () => {
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
const { result, rerender } = renderHook(
({ s }: { s: ManagedRequestorTasksState | null }) =>
useManagedRequestorTasks(client, s),
{ initialProps: { s: state as ManagedRequestorTasksState | null } },
);
await waitFor(() => {
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1"]);
});
rerender({ s: null });
await waitFor(() => {
expect(result.current.tasks).toEqual([]);
});
});
it("unsubscribes from the state on unmount", async () => {
const { result, unmount } = renderHook(() =>
useManagedRequestorTasks(client, state),
);
unmount();
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
expect(result.current.tasks).toEqual([]);
});
});

View file

@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates";
function template(name: string): ResourceTemplate {
return { uriTemplate: `tpl://{${name}}`, name };
}
describe("useManagedResourceTemplates", () => {
let client: FakeInspectorClient;
let state: ManagedResourceTemplatesState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
state = new ManagedResourceTemplatesState(client);
});
it("returns the initial templates snapshot from the state", async () => {
client.queueResourceTemplatePages({
resourceTemplates: [template("a"), template("b")],
});
await state.refresh();
const { result } = renderHook(() =>
useManagedResourceTemplates(client, state),
);
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
"a",
"b",
]);
});
it("returns empty templates when state is null", () => {
const { result } = renderHook(() =>
useManagedResourceTemplates(client, null),
);
expect(result.current.resourceTemplates).toEqual([]);
});
it("updates when state dispatches resourceTemplatesChange", async () => {
const { result } = renderHook(() =>
useManagedResourceTemplates(client, state),
);
expect(result.current.resourceTemplates).toEqual([]);
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await act(async () => {
await state.refresh();
});
await waitFor(() => {
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
"a",
]);
});
});
it("refresh() calls through to state and returns the next templates", async () => {
client.queueResourceTemplatePages({ resourceTemplates: [template("x")] });
const { result } = renderHook(() =>
useManagedResourceTemplates(client, state),
);
let next: ResourceTemplate[] = [];
await act(async () => {
next = await result.current.refresh();
});
expect(next.map((t) => t.name)).toEqual(["x"]);
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual(["x"]);
});
it("refresh() returns [] when state or client is null", async () => {
const { result } = renderHook(() =>
useManagedResourceTemplates(null, state),
);
await expect(result.current.refresh()).resolves.toEqual([]);
const { result: result2 } = renderHook(() =>
useManagedResourceTemplates(client, null),
);
await expect(result2.current.refresh()).resolves.toEqual([]);
});
it("resets to empty when state becomes null", async () => {
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
const { result, rerender } = renderHook(
({ s }: { s: ManagedResourceTemplatesState | null }) =>
useManagedResourceTemplates(client, s),
{ initialProps: { s: state as ManagedResourceTemplatesState | null } },
);
await waitFor(() => {
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
"a",
]);
});
rerender({ s: null });
await waitFor(() => {
expect(result.current.resourceTemplates).toEqual([]);
});
});
it("unsubscribes from the state on unmount", async () => {
const { result, unmount } = renderHook(() =>
useManagedResourceTemplates(client, state),
);
unmount();
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
expect(result.current.resourceTemplates).toEqual([]);
});
});

View file

@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
import { useManagedResources } from "@inspector/core/react/useManagedResources";
function resource(uri: string): Resource {
return { uri, name: uri };
}
describe("useManagedResources", () => {
let client: FakeInspectorClient;
let state: ManagedResourcesState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
state = new ManagedResourcesState(client);
});
it("returns the initial resources snapshot from the state", async () => {
client.queueResourcePages({
resources: [resource("a://1"), resource("a://2")],
});
await state.refresh();
const { result } = renderHook(() => useManagedResources(client, state));
expect(result.current.resources.map((r) => r.uri)).toEqual([
"a://1",
"a://2",
]);
});
it("returns empty resources when state is null", () => {
const { result } = renderHook(() => useManagedResources(client, null));
expect(result.current.resources).toEqual([]);
});
it("updates when state dispatches resourcesChange", async () => {
const { result } = renderHook(() => useManagedResources(client, state));
expect(result.current.resources).toEqual([]);
client.queueResourcePages({ resources: [resource("a://1")] });
await act(async () => {
await state.refresh();
});
await waitFor(() => {
expect(result.current.resources.map((r) => r.uri)).toEqual(["a://1"]);
});
});
it("refresh() calls through to state and returns the next resources", async () => {
client.queueResourcePages({ resources: [resource("x://1")] });
const { result } = renderHook(() => useManagedResources(client, state));
let next: Resource[] = [];
await act(async () => {
next = await result.current.refresh();
});
expect(next.map((r) => r.uri)).toEqual(["x://1"]);
expect(result.current.resources.map((r) => r.uri)).toEqual(["x://1"]);
});
it("refresh() returns [] when state or client is null", async () => {
const { result } = renderHook(() => useManagedResources(null, state));
await expect(result.current.refresh()).resolves.toEqual([]);
const { result: result2 } = renderHook(() =>
useManagedResources(client, null),
);
await expect(result2.current.refresh()).resolves.toEqual([]);
});
it("resets to empty resources when the state prop becomes null", async () => {
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
const { result, rerender } = renderHook(
({ s }: { s: ManagedResourcesState | null }) =>
useManagedResources(client, s),
{ initialProps: { s: state as ManagedResourcesState | null } },
);
await waitFor(() => {
expect(result.current.resources.map((r) => r.uri)).toEqual(["a://1"]);
});
rerender({ s: null });
await waitFor(() => {
expect(result.current.resources).toEqual([]);
});
});
it("unsubscribes from the state on unmount", async () => {
const { result, unmount } = renderHook(() =>
useManagedResources(client, state),
);
unmount();
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
expect(result.current.resources).toEqual([]);
});
});

View file

@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
import { useManagedTools } from "@inspector/core/react/useManagedTools";
function tool(name: string): Tool {
return { name, inputSchema: { type: "object" } };
}
describe("useManagedTools", () => {
let client: FakeInspectorClient;
let state: ManagedToolsState;
beforeEach(() => {
client = new FakeInspectorClient({ status: "connected" });
state = new ManagedToolsState(client);
});
it("returns the initial tools snapshot from the state", async () => {
client.queueToolPages({ tools: [tool("a"), tool("b")] });
await state.refresh();
const { result } = renderHook(() => useManagedTools(client, state));
expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]);
});
it("returns empty tools when state is null", () => {
const { result } = renderHook(() => useManagedTools(client, null));
expect(result.current.tools).toEqual([]);
});
it("updates when state dispatches toolsChange", async () => {
const { result } = renderHook(() => useManagedTools(client, state));
expect(result.current.tools).toEqual([]);
client.queueToolPages({ tools: [tool("a")] });
await act(async () => {
await state.refresh();
});
await waitFor(() => {
expect(result.current.tools.map((t) => t.name)).toEqual(["a"]);
});
});
it("refresh() calls through to state and returns the next tools", async () => {
client.queueToolPages({ tools: [tool("x")] });
const { result } = renderHook(() => useManagedTools(client, state));
let next: Tool[] = [];
await act(async () => {
next = await result.current.refresh();
});
expect(next.map((t) => t.name)).toEqual(["x"]);
expect(result.current.tools.map((t) => t.name)).toEqual(["x"]);
});
it("refresh() returns [] when state or client is null", async () => {
const { result } = renderHook(() => useManagedTools(null, state));
await expect(result.current.refresh()).resolves.toEqual([]);
const { result: result2 } = renderHook(() => useManagedTools(client, null));
await expect(result2.current.refresh()).resolves.toEqual([]);
});
it("resets to empty tools when the state prop becomes null", async () => {
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
const { result, rerender } = renderHook(
({ s }: { s: ManagedToolsState | null }) => useManagedTools(client, s),
{ initialProps: { s: state as ManagedToolsState | null } },
);
await waitFor(() => {
expect(result.current.tools.map((t) => t.name)).toEqual(["a"]);
});
rerender({ s: null });
await waitFor(() => {
expect(result.current.tools).toEqual([]);
});
});
it("unsubscribes from the state on unmount", async () => {
const { result, unmount } = renderHook(() =>
useManagedTools(client, state),
);
unmount();
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
// After unmount the hook should not be receiving updates — the last
// observed value is the empty snapshot captured before the refresh.
expect(result.current.tools).toEqual([]);
});
});

Some files were not shown because too many files have changed in this diff Show more