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

View file

@ -0,0 +1,219 @@
/**
* Test-only fake InspectorClient: an InspectorClientEventTarget subclass
* implementing InspectorClientProtocol with stubbed methods. State and hook
* tests inject this so they can drive events, queue paginated list responses,
* and assert subscriber wiring without a real transport or SDK client.
*/
import { vi } from "vitest";
import type {
Implementation,
LoggingLevel,
Prompt,
Resource,
ResourceTemplate,
ServerCapabilities,
Task,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorClientEventTarget } from "../inspectorClientEventTarget.js";
import type {
AppRendererClient,
InspectorClientProtocol,
} from "../inspectorClientProtocol.js";
import type {
ConnectionStatus,
PromptGetInvocation,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
ToolCallInvocation,
} from "../types.js";
import type { JsonValue } from "../../json/jsonUtils.js";
type ListResult<TKey extends string, TItem> = {
[K in TKey]: TItem[];
} & { nextCursor?: string };
export interface FakeInspectorClientOptions {
status?: ConnectionStatus;
capabilities?: ServerCapabilities;
serverInfo?: Implementation;
instructions?: string;
}
export class FakeInspectorClient
extends InspectorClientEventTarget
implements InspectorClientProtocol
{
private status: ConnectionStatus;
private capabilities: ServerCapabilities | undefined;
private serverInfo: Implementation | undefined;
private instructions: string | undefined;
private appRendererClient: AppRendererClient | null = null;
private sessionId: string | undefined;
// Each paginated method pulls from a queue of pre-canned pages. Tests push
// pages with `queueToolPages(...)` etc. so they can assert pagination
// accumulation without wiring a real server.
toolPages: Array<ListResult<"tools", Tool>> = [];
promptPages: Array<ListResult<"prompts", Prompt>> = [];
resourcePages: Array<ListResult<"resources", Resource>> = [];
resourceTemplatePages: Array<
ListResult<"resourceTemplates", ResourceTemplate>
> = [];
taskPages: Array<ListResult<"tasks", Task>> = [];
listTools = vi.fn(async () => this.toolPages.shift() ?? { tools: [] });
listPrompts = vi.fn(async () => this.promptPages.shift() ?? { prompts: [] });
listResources = vi.fn(
async () => this.resourcePages.shift() ?? { resources: [] },
);
listResourceTemplates = vi.fn(
async () =>
this.resourceTemplatePages.shift() ?? { resourceTemplates: [] },
);
listRequestorTasks = vi.fn(
async () => this.taskPages.shift() ?? { tasks: [] },
);
callTool = vi.fn(
async (
tool: Tool,
params: Record<string, JsonValue>,
): Promise<ToolCallInvocation> => ({
toolName: tool.name,
params,
result: null,
timestamp: new Date(),
success: true,
}),
);
readResource = vi.fn(
async (uri: string): Promise<ResourceReadInvocation> => ({
result: { contents: [] },
timestamp: new Date(),
uri,
}),
);
readResourceFromTemplate = vi.fn(
async (
uriTemplate: string,
params: Record<string, string>,
): Promise<ResourceTemplateReadInvocation> => ({
uriTemplate,
expandedUri: uriTemplate,
result: { contents: [] },
timestamp: new Date(),
params,
}),
);
getPrompt = vi.fn(
async (name: string): Promise<PromptGetInvocation> => ({
result: { messages: [] },
timestamp: new Date(),
name,
}),
);
setLoggingLevel = vi.fn(async (_level: LoggingLevel) => {});
constructor(options: FakeInspectorClientOptions = {}) {
super();
this.status = options.status ?? "disconnected";
this.capabilities = options.capabilities;
this.serverInfo = options.serverInfo;
this.instructions = options.instructions;
}
getStatus(): ConnectionStatus {
return this.status;
}
getCapabilities(): ServerCapabilities | undefined {
return this.capabilities;
}
getServerInfo(): Implementation | undefined {
return this.serverInfo;
}
getInstructions(): string | undefined {
return this.instructions;
}
getAppRendererClient(): AppRendererClient | null {
return this.appRendererClient;
}
async connect(): Promise<void> {
this.setStatus("connecting");
this.setStatus("connected");
this.dispatchTypedEvent("connect");
}
async disconnect(): Promise<void> {
this.setStatus("disconnected");
this.dispatchTypedEvent("disconnect");
}
getSessionId(): string | undefined {
return this.sessionId;
}
// ---- test helpers ----
setStatus(status: ConnectionStatus): void {
this.status = status;
this.dispatchTypedEvent("statusChange", status);
}
setCapabilities(capabilities: ServerCapabilities | undefined): void {
this.capabilities = capabilities;
this.dispatchTypedEvent("capabilitiesChange", capabilities);
}
setServerInfo(info: Implementation | undefined): void {
this.serverInfo = info;
this.dispatchTypedEvent("serverInfoChange", info);
}
setInstructions(instructions: string | undefined): void {
this.instructions = instructions;
this.dispatchTypedEvent("instructionsChange", instructions);
}
setAppRendererClient(client: AppRendererClient | null): void {
this.appRendererClient = client;
}
setSessionId(id: string | undefined): void {
this.sessionId = id;
}
queueToolPages(...pages: Array<ListResult<"tools", Tool>>): void {
this.toolPages.push(...pages);
}
queuePromptPages(...pages: Array<ListResult<"prompts", Prompt>>): void {
this.promptPages.push(...pages);
}
queueResourcePages(
...pages: Array<ListResult<"resources", Resource>>
): void {
this.resourcePages.push(...pages);
}
queueResourceTemplatePages(
...pages: Array<ListResult<"resourceTemplates", ResourceTemplate>>
): void {
this.resourceTemplatePages.push(...pages);
}
queueTaskPages(...pages: Array<ListResult<"tasks", Task>>): void {
this.taskPages.push(...pages);
}
}