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
14 changed files with 6404 additions and 5 deletions

View file

@ -0,0 +1,156 @@
/**
* FetchRequestLogState: holds the fetch request log, subscribes to the protocol
* "fetchRequest" event. Protocol emits per-entry; this manager owns the list and
* emits both `fetchRequest` (single entry) and `fetchRequestsChange` (full list)
* on append, and `fetchRequestsChange` on clear. Does not clear on connect or
* disconnect.
*
* When `sessionStorage` and `sessionId` are provided, restores fetch requests
* from storage on creation and listens for the client's `saveSession` event to
* persist before OAuth redirect.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { FetchRequestEntry } from "../types.js";
import type {
InspectorClientStorage,
InspectorClientSessionState,
} from "../sessionStorage.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
export interface FetchRequestLogStateEventMap {
fetchRequest: FetchRequestEntry;
fetchRequestsChange: FetchRequestEntry[];
}
export interface FetchRequestLogStateOptions {
/**
* Maximum number of fetch requests to store (0 = unlimited, not recommended).
* When exceeded, oldest entries are dropped. Default 1000.
*/
maxFetchRequests?: number;
/**
* When provided with sessionId, fetch requests are restored from storage on
* creation and saved when the client dispatches `saveSession` (e.g. before
* OAuth redirect).
*/
sessionStorage?: InspectorClientStorage;
/** Session ID for load/save; required for sessionStorage to have effect. */
sessionId?: string;
}
export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateEventMap> {
private fetchRequests: FetchRequestEntry[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private readonly maxFetchRequests: number;
constructor(
client: InspectorClientProtocol,
options: FetchRequestLogStateOptions = {},
) {
super();
this.maxFetchRequests = options.maxFetchRequests ?? 1000;
this.client = client;
const onFetchRequest = (
event: TypedEventGeneric<InspectorClientEventMap, "fetchRequest">,
): void => {
const entry = event.detail;
if (
this.maxFetchRequests > 0 &&
this.fetchRequests.length >= this.maxFetchRequests
) {
this.fetchRequests.shift();
}
this.fetchRequests.push(entry);
this.dispatchTypedEvent("fetchRequest", entry);
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
};
this.client.addEventListener("fetchRequest", onFetchRequest);
const sessionStorage = options.sessionStorage;
const sessionId = options.sessionId;
if (sessionStorage) {
const onSaveSession = (
event: TypedEventGeneric<InspectorClientEventMap, "saveSession">,
): void => {
const { sessionId: id } = event.detail;
const state: InspectorClientSessionState = {
fetchRequests: this.getFetchRequests(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
sessionStorage.saveSession(id, state).catch(() => {
// fire-and-forget; storage may log internally
});
};
this.client.addEventListener("saveSession", onSaveSession);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
this.client.removeEventListener("saveSession", onSaveSession);
}
this.client = null;
};
if (sessionId) {
sessionStorage
.loadSession(sessionId)
.then((state) => {
if (this.client && state?.fetchRequests?.length) {
this.hydrateFetchRequests(state.fetchRequests);
}
})
.catch(() => {
// fire-and-forget; storage may log internally. Matches the
// saveSession swallow above so a corrupt or unreadable session
// doesn't surface as an unhandled rejection.
});
}
} else {
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
}
this.client = null;
};
}
}
private hydrateFetchRequests(entries: FetchRequestEntry[]): void {
const trimmed =
this.maxFetchRequests > 0
? entries.slice(-this.maxFetchRequests)
: entries;
this.fetchRequests = trimmed;
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
}
getFetchRequests(): FetchRequestEntry[] {
return [...this.fetchRequests];
}
/**
* Clear all fetch requests. Dispatches fetchRequestsChange only if the list
* was non-empty.
*/
clearFetchRequests(): void {
if (this.fetchRequests.length === 0) return;
this.fetchRequests = [];
this.dispatchTypedEvent("fetchRequestsChange", []);
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.fetchRequests = [];
}
}

View file

@ -0,0 +1,97 @@
/**
* ManagedPromptsState: holds full prompt list, syncs on promptsListChanged.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
const MAX_PAGES = 100;
export interface ManagedPromptsStateEventMap {
promptsChange: Prompt[];
}
/**
* State manager that keeps a full prompt list in sync with the server.
* Subscribes to connect, promptsListChanged, and statusChange; fetches all
* pages on refresh.
*/
export class ManagedPromptsState extends TypedEventTarget<ManagedPromptsStateEventMap> {
private prompts: Prompt[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private _metadata: Record<string, string> | undefined = undefined;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onConnect = (): void => {
void this.refresh();
};
const onPromptsListChanged = (): void => {
void this.refresh();
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.prompts = [];
this.dispatchTypedEvent("promptsChange", []);
}
};
this.client.addEventListener("connect", onConnect);
this.client.addEventListener("promptsListChanged", onPromptsListChanged);
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("connect", onConnect);
this.client.removeEventListener(
"promptsListChanged",
onPromptsListChanged,
);
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getPrompts(): Prompt[] {
return [...this.prompts];
}
setMetadata(metadata?: Record<string, string>): void {
this._metadata = metadata;
}
async refresh(metadata?: Record<string, string>): Promise<Prompt[]> {
const client = this.client;
if (!client || client.getStatus() !== "connected") {
return this.getPrompts();
}
const effectiveMetadata = metadata ?? this._metadata;
this.prompts = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const result = await client.listPrompts(cursor, effectiveMetadata);
this.prompts = cursor ? [...this.prompts, ...result.prompts] : result.prompts;
cursor = result.nextCursor;
pageCount++;
if (pageCount >= MAX_PAGES) {
throw new Error(
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing prompts`,
);
}
} while (cursor);
this.dispatchTypedEvent("promptsChange", this.prompts);
return this.getPrompts();
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.prompts = [];
}
}

View file

@ -0,0 +1,136 @@
/**
* ManagedRequestorTasksState: holds full requestor task list, syncs on
* tasksListChanged. Subscribes to taskStatusChange (server) and
* requestorTaskUpdated (client) to merge per-task updates.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
import { mergeTaskIntoList } from "./mergeTaskIntoList.js";
const MAX_PAGES = 100;
export interface ManagedRequestorTasksStateEventMap {
tasksChange: Task[];
}
/**
* State manager that keeps the full requestor task list in sync with the
* server. Subscribes to connect, tasksListChanged, statusChange,
* taskStatusChange (server), requestorTaskUpdated (client), and taskCancelled.
*/
export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequestorTasksStateEventMap> {
private tasks: Task[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onConnect = (): void => {
void this.refresh();
};
const onTasksListChanged = (): void => {
void this.refresh();
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.tasks = [];
this.dispatchTypedEvent("tasksChange", []);
}
};
const onTaskStatusChange = (
e: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
): void => {
const { taskId, task } = e.detail;
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
this.dispatchTypedEvent("tasksChange", this.tasks);
};
const onRequestorTaskUpdated = (
e: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
): void => {
const { taskId, task } = e.detail;
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
this.dispatchTypedEvent("tasksChange", this.tasks);
};
const onTaskCancelled = (
e: TypedEventGeneric<InspectorClientEventMap, "taskCancelled">,
): void => {
const { taskId } = e.detail;
const idx = this.tasks.findIndex((t) => t.taskId === taskId);
if (idx >= 0) {
const next = [...this.tasks];
const prev = next[idx]!;
next[idx] = { ...prev, status: "cancelled" as const };
this.tasks = next;
this.dispatchTypedEvent("tasksChange", this.tasks);
}
};
this.client.addEventListener("connect", onConnect);
this.client.addEventListener("tasksListChanged", onTasksListChanged);
this.client.addEventListener("statusChange", onStatusChange);
this.client.addEventListener("taskStatusChange", onTaskStatusChange);
this.client.addEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
this.client.addEventListener("taskCancelled", onTaskCancelled);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("connect", onConnect);
this.client.removeEventListener("tasksListChanged", onTasksListChanged);
this.client.removeEventListener("statusChange", onStatusChange);
this.client.removeEventListener("taskStatusChange", onTaskStatusChange);
this.client.removeEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
this.client.removeEventListener("taskCancelled", onTaskCancelled);
}
this.client = null;
};
}
getTasks(): Task[] {
return [...this.tasks];
}
async refresh(): Promise<Task[]> {
const client = this.client;
if (!client || client.getStatus() !== "connected") {
return this.getTasks();
}
this.tasks = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const result = await client.listRequestorTasks(cursor);
this.tasks = cursor
? [...this.tasks, ...result.tasks]
: result.tasks;
cursor = result.nextCursor;
pageCount++;
if (pageCount >= MAX_PAGES) {
throw new Error(
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing requestor tasks`,
);
}
} while (cursor);
this.dispatchTypedEvent("tasksChange", this.tasks);
return this.getTasks();
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.tasks = [];
}
}

View file

@ -0,0 +1,108 @@
/**
* ManagedResourceTemplatesState: holds full resource template list, syncs on
* resourceTemplatesListChanged.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
const MAX_PAGES = 100;
export interface ManagedResourceTemplatesStateEventMap {
resourceTemplatesChange: ResourceTemplate[];
}
/**
* State manager that keeps a full resource template list in sync with the server.
* Subscribes to connect, resourceTemplatesListChanged, and statusChange;
* fetches all pages on refresh.
*/
export class ManagedResourceTemplatesState extends TypedEventTarget<ManagedResourceTemplatesStateEventMap> {
private resourceTemplates: ResourceTemplate[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private _metadata: Record<string, string> | undefined = undefined;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onConnect = (): void => {
void this.refresh();
};
const onResourceTemplatesListChanged = (): void => {
void this.refresh();
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.resourceTemplates = [];
this.dispatchTypedEvent("resourceTemplatesChange", []);
}
};
this.client.addEventListener("connect", onConnect);
this.client.addEventListener(
"resourceTemplatesListChanged",
onResourceTemplatesListChanged,
);
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("connect", onConnect);
this.client.removeEventListener(
"resourceTemplatesListChanged",
onResourceTemplatesListChanged,
);
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getResourceTemplates(): ResourceTemplate[] {
return [...this.resourceTemplates];
}
setMetadata(metadata?: Record<string, string>): void {
this._metadata = metadata;
}
async refresh(
metadata?: Record<string, string>,
): Promise<ResourceTemplate[]> {
const client = this.client;
if (!client || client.getStatus() !== "connected") {
return this.getResourceTemplates();
}
const effectiveMetadata = metadata ?? this._metadata;
this.resourceTemplates = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const result = await client.listResourceTemplates(
cursor,
effectiveMetadata,
);
this.resourceTemplates = cursor
? [...this.resourceTemplates, ...result.resourceTemplates]
: result.resourceTemplates;
cursor = result.nextCursor;
pageCount++;
if (pageCount >= MAX_PAGES) {
throw new Error(
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing resource templates`,
);
}
} while (cursor);
this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates);
return this.getResourceTemplates();
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.resourceTemplates = [];
}
}

View file

@ -0,0 +1,102 @@
/**
* ManagedResourcesState: holds full resource list, syncs on resourcesListChanged.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
const MAX_PAGES = 100;
export interface ManagedResourcesStateEventMap {
resourcesChange: Resource[];
}
/**
* State manager that keeps a full resource list in sync with the server.
* Subscribes to connect, resourcesListChanged, and statusChange; fetches all
* pages on refresh.
*/
export class ManagedResourcesState extends TypedEventTarget<ManagedResourcesStateEventMap> {
private resources: Resource[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private _metadata: Record<string, string> | undefined = undefined;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onConnect = (): void => {
void this.refresh();
};
const onResourcesListChanged = (): void => {
void this.refresh();
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.resources = [];
this.dispatchTypedEvent("resourcesChange", []);
}
};
this.client.addEventListener("connect", onConnect);
this.client.addEventListener(
"resourcesListChanged",
onResourcesListChanged,
);
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("connect", onConnect);
this.client.removeEventListener(
"resourcesListChanged",
onResourcesListChanged,
);
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getResources(): Resource[] {
return [...this.resources];
}
setMetadata(metadata?: Record<string, string>): void {
this._metadata = metadata;
}
async refresh(metadata?: Record<string, string>): Promise<Resource[]> {
const client = this.client;
if (!client || client.getStatus() !== "connected") {
return this.getResources();
}
const effectiveMetadata = metadata ?? this._metadata;
this.resources = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const result = await client.listResources(cursor, effectiveMetadata);
this.resources = cursor
? [...this.resources, ...result.resources]
: result.resources;
cursor = result.nextCursor;
pageCount++;
if (pageCount >= MAX_PAGES) {
throw new Error(
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing resources`,
);
}
} while (cursor);
this.dispatchTypedEvent("resourcesChange", this.resources);
return this.getResources();
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.resources = [];
}
}

View file

@ -0,0 +1,94 @@
/**
* ManagedToolsState: holds full tool list, syncs on toolsListChanged.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
const MAX_PAGES = 100;
export interface ManagedToolsStateEventMap {
toolsChange: Tool[];
}
/**
* State manager that keeps a full tool list in sync with the server.
* Subscribes to client's connect (initial load), toolsListChanged, and
* statusChange; fetches all pages on refresh.
*/
export class ManagedToolsState extends TypedEventTarget<ManagedToolsStateEventMap> {
private tools: Tool[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private _metadata: Record<string, string> | undefined = undefined;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onConnect = (): void => {
void this.refresh();
};
const onToolsListChanged = (): void => {
void this.refresh();
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.tools = [];
this.dispatchTypedEvent("toolsChange", []);
}
};
this.client.addEventListener("connect", onConnect);
this.client.addEventListener("toolsListChanged", onToolsListChanged);
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("connect", onConnect);
this.client.removeEventListener("toolsListChanged", onToolsListChanged);
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getTools(): Tool[] {
return [...this.tools];
}
setMetadata(metadata?: Record<string, string>): void {
this._metadata = metadata;
}
async refresh(metadata?: Record<string, string>): Promise<Tool[]> {
const client = this.client;
if (!client || client.getStatus() !== "connected") {
return this.getTools();
}
const effectiveMetadata = metadata ?? this._metadata;
this.tools = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const result = await client.listTools(cursor, effectiveMetadata);
this.tools = cursor ? [...this.tools, ...result.tools] : result.tools;
cursor = result.nextCursor;
pageCount++;
if (pageCount >= MAX_PAGES) {
throw new Error(
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing tools`,
);
}
} while (cursor);
this.dispatchTypedEvent("toolsChange", this.tools);
return this.getTools();
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.tools = [];
}
}

View file

@ -0,0 +1,33 @@
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import type { TaskWithOptionalCreatedAt } from "../inspectorClientEventTarget.js";
/**
* Insert or replace a task in a list keyed by `taskId`. Inserts at the front
* when the id is new (so most-recent updates surface first); replaces in place
* when the id is already present.
*
* The SDK `Task` requires `createdAt`, but client-origin task updates
* (`requestorTaskUpdated`) carry `TaskWithOptionalCreatedAt` — fall back to
* `lastUpdatedAt` so synthetic updates still satisfy the `Task` shape.
*/
export function mergeTaskIntoList(
tasks: Task[],
taskId: string,
task: Task | TaskWithOptionalCreatedAt,
): Task[] {
const normalized: Task = {
...task,
taskId,
createdAt:
(task as Task).createdAt ??
(task as TaskWithOptionalCreatedAt).lastUpdatedAt ??
"",
};
const idx = tasks.findIndex((t) => t.taskId === taskId);
if (idx < 0) {
return [normalized, ...tasks];
}
const next = [...tasks];
next[idx] = normalized;
return next;
}

View file

@ -0,0 +1,162 @@
/**
* MessageLogState: holds the message log, subscribes to the protocol "message"
* event. Protocol emits per-entry; this manager owns the list and emits both
* `message` (single entry) and `messagesChange` (full list) on append/update,
* and `messagesChange` on clear. Clears on connect (new session) and on
* disconnect.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { MessageEntry } from "../types.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
export interface MessageLogStateEventMap {
message: MessageEntry;
messagesChange: MessageEntry[];
}
export interface MessageLogStateOptions {
/**
* Maximum number of messages to store (0 = unlimited, not recommended).
* When exceeded, oldest entries are dropped. Default 1000.
*/
maxMessages?: number;
}
export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> {
private messages: MessageEntry[] = [];
/** Pending request entries by JSON-RPC message id for matching responses. */
private pendingRequestEntries = new Map<string | number, MessageEntry>();
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private readonly maxMessages: number;
constructor(
client: InspectorClientProtocol,
options: MessageLogStateOptions = {},
) {
super();
this.maxMessages = options.maxMessages ?? 1000;
this.client = client;
const pushEntry = (entry: MessageEntry): void => {
if (this.maxMessages > 0 && this.messages.length >= this.maxMessages) {
this.messages.shift();
}
this.messages.push(entry);
this.dispatchTypedEvent("message", entry);
this.dispatchTypedEvent("messagesChange", this.getMessages());
};
const onMessage = (
event: TypedEventGeneric<InspectorClientEventMap, "message">,
): void => {
const entry = event.detail;
if (entry.direction === "request") {
const reqId =
"id" in entry.message
? (entry.message as { id?: string | number }).id
: undefined;
if (reqId !== undefined) {
this.pendingRequestEntries.set(reqId, entry);
}
pushEntry(entry);
return;
}
if (entry.direction === "response") {
const messageId =
"id" in entry.message
? (entry.message as { id?: string | number }).id
: undefined;
const requestEntry =
messageId !== undefined
? this.pendingRequestEntries.get(messageId)
: undefined;
if (requestEntry) {
this.pendingRequestEntries.delete(messageId!);
requestEntry.response = entry.message as MessageEntry["response"];
requestEntry.duration =
entry.timestamp.getTime() - requestEntry.timestamp.getTime();
this.dispatchTypedEvent("message", requestEntry);
this.dispatchTypedEvent("messagesChange", this.getMessages());
return;
}
}
pushEntry(entry);
};
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.messages = [];
this.pendingRequestEntries.clear();
this.dispatchTypedEvent("messagesChange", []);
}
};
const onConnect = (): void => {
this.messages = [];
this.pendingRequestEntries.clear();
this.dispatchTypedEvent("messagesChange", []);
};
this.client.addEventListener("message", onMessage);
this.client.addEventListener("statusChange", onStatusChange);
this.client.addEventListener("connect", onConnect);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("message", onMessage);
this.client.removeEventListener("statusChange", onStatusChange);
this.client.removeEventListener("connect", onConnect);
}
this.client = null;
};
}
getMessages(predicate?: (entry: MessageEntry) => boolean): MessageEntry[] {
if (predicate) {
return this.messages.filter(predicate);
}
return [...this.messages];
}
/**
* Remove messages from history. When `predicate` is provided, removes only
* entries for which predicate returns true. When omitted, clears all messages.
* Dispatches messagesChange only if the list actually changed.
*/
clearMessages(predicate?: (entry: MessageEntry) => boolean): void {
const before = this.messages.length;
this.messages = predicate
? this.messages.filter((m) => !predicate(m))
: [];
// Drop pending-request bookkeeping for any entry that was just removed.
// Without this, a later response matching an evicted request id would
// mutate a Map-only entry that is no longer reachable from `messages`,
// silently dropping the response.
if (predicate) {
const survivors = new Set(this.messages);
for (const [id, entry] of this.pendingRequestEntries) {
if (!survivors.has(entry)) {
this.pendingRequestEntries.delete(id);
}
}
} else {
this.pendingRequestEntries.clear();
}
if (this.messages.length !== before) {
this.dispatchTypedEvent("messagesChange", this.getMessages());
}
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.messages = [];
this.pendingRequestEntries.clear();
}
}

View file

@ -0,0 +1,80 @@
/**
* PagedPromptsState: holds an aggregated list of prompts loaded via loadPage(cursor).
* Does not load on connect; caller drives loading. Clears on disconnect.
*
* Intentionally does NOT subscribe to `promptsListChanged`: cursors are tied
* to the server's prior list, so a list change mid-pagination would invalidate
* them. The caller decides how to react.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
export interface PagedPromptsStateEventMap {
promptsChange: Prompt[];
}
export interface LoadPageResult {
prompts: Prompt[];
nextCursor?: string;
}
export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMap> {
private prompts: Prompt[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.prompts = [];
this.dispatchTypedEvent("promptsChange", []);
}
};
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getPrompts(): Prompt[] {
return [...this.prompts];
}
clear(): void {
this.prompts = [];
this.dispatchTypedEvent("promptsChange", this.prompts);
}
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {
return { prompts: [], nextCursor: undefined };
}
const result = await c.listPrompts(cursor, metadata);
this.prompts =
cursor === undefined
? [...result.prompts]
: [...this.prompts, ...result.prompts];
this.dispatchTypedEvent("promptsChange", this.prompts);
return { prompts: result.prompts, nextCursor: result.nextCursor };
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.prompts = [];
}
}

View file

@ -0,0 +1,132 @@
/**
* PagedRequestorTasksState: holds an aggregated list of requestor tasks loaded
* via loadPage(cursor). Subscribes to tasksListChanged (refetch first page),
* taskStatusChange, requestorTaskUpdated, and taskCancelled. Clears on disconnect.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Task } from "@modelcontextprotocol/sdk/types.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
import { mergeTaskIntoList } from "./mergeTaskIntoList.js";
export interface PagedRequestorTasksStateEventMap {
tasksChange: Task[];
}
export interface LoadPageResult {
tasks: Task[];
nextCursor?: string;
}
export class PagedRequestorTasksState extends TypedEventTarget<PagedRequestorTasksStateEventMap> {
private tasks: Task[] = [];
private nextCursor: string | undefined = undefined;
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.tasks = [];
this.nextCursor = undefined;
this.dispatchTypedEvent("tasksChange", []);
}
};
const onTasksListChanged = (): void => {
void this.loadPage(undefined);
};
const onTaskStatusChange = (
e: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
): void => {
const { taskId, task } = e.detail;
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
this.dispatchTypedEvent("tasksChange", this.tasks);
};
const onRequestorTaskUpdated = (
e: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
): void => {
const { taskId, task } = e.detail;
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
this.dispatchTypedEvent("tasksChange", this.tasks);
};
const onTaskCancelled = (
e: TypedEventGeneric<InspectorClientEventMap, "taskCancelled">,
): void => {
const { taskId } = e.detail;
const idx = this.tasks.findIndex((t) => t.taskId === taskId);
if (idx >= 0) {
const next = [...this.tasks];
const prev = next[idx]!;
next[idx] = { ...prev, status: "cancelled" as const };
this.tasks = next;
this.dispatchTypedEvent("tasksChange", this.tasks);
}
};
this.client.addEventListener("statusChange", onStatusChange);
this.client.addEventListener("tasksListChanged", onTasksListChanged);
this.client.addEventListener("taskStatusChange", onTaskStatusChange);
this.client.addEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
this.client.addEventListener("taskCancelled", onTaskCancelled);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("statusChange", onStatusChange);
this.client.removeEventListener("tasksListChanged", onTasksListChanged);
this.client.removeEventListener("taskStatusChange", onTaskStatusChange);
this.client.removeEventListener(
"requestorTaskUpdated",
onRequestorTaskUpdated,
);
this.client.removeEventListener("taskCancelled", onTaskCancelled);
}
this.client = null;
};
}
getTasks(): Task[] {
return [...this.tasks];
}
getNextCursor(): string | undefined {
return this.nextCursor;
}
clear(): void {
this.tasks = [];
this.nextCursor = undefined;
this.dispatchTypedEvent("tasksChange", this.tasks);
}
async loadPage(cursor?: string): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {
return { tasks: [], nextCursor: undefined };
}
const result = await c.listRequestorTasks(cursor);
this.tasks =
cursor === undefined
? [...result.tasks]
: [...this.tasks, ...result.tasks];
this.nextCursor = result.nextCursor;
this.dispatchTypedEvent("tasksChange", this.tasks);
return { tasks: result.tasks, nextCursor: result.nextCursor };
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.tasks = [];
this.nextCursor = undefined;
}
}

View file

@ -0,0 +1,84 @@
/**
* PagedResourceTemplatesState: holds an aggregated list of resource templates
* loaded via loadPage(cursor). Does not load on connect; caller drives loading.
* Clears on disconnect.
*
* Intentionally does NOT subscribe to `resourceTemplatesListChanged`: cursors
* are tied to the server's prior list, so a list change mid-pagination would
* invalidate them. The caller decides how to react.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
export interface PagedResourceTemplatesStateEventMap {
resourceTemplatesChange: ResourceTemplate[];
}
export interface LoadPageResult {
resourceTemplates: ResourceTemplate[];
nextCursor?: string;
}
export class PagedResourceTemplatesState extends TypedEventTarget<PagedResourceTemplatesStateEventMap> {
private resourceTemplates: ResourceTemplate[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.resourceTemplates = [];
this.dispatchTypedEvent("resourceTemplatesChange", []);
}
};
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getResourceTemplates(): ResourceTemplate[] {
return [...this.resourceTemplates];
}
clear(): void {
this.resourceTemplates = [];
this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates);
}
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {
return { resourceTemplates: [], nextCursor: undefined };
}
const result = await c.listResourceTemplates(cursor, metadata);
this.resourceTemplates =
cursor === undefined
? [...result.resourceTemplates]
: [...this.resourceTemplates, ...result.resourceTemplates];
this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates);
return {
resourceTemplates: result.resourceTemplates,
nextCursor: result.nextCursor,
};
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.resourceTemplates = [];
}
}

View file

@ -0,0 +1,80 @@
/**
* PagedResourcesState: holds an aggregated list of resources loaded via loadPage(cursor).
* Does not load on connect; caller drives loading. Clears on disconnect.
*
* Intentionally does NOT subscribe to `resourcesListChanged`: cursors are
* tied to the server's prior list, so a list change mid-pagination would
* invalidate them. The caller decides how to react.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
export interface PagedResourcesStateEventMap {
resourcesChange: Resource[];
}
export interface LoadPageResult {
resources: Resource[];
nextCursor?: string;
}
export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEventMap> {
private resources: Resource[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.resources = [];
this.dispatchTypedEvent("resourcesChange", []);
}
};
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getResources(): Resource[] {
return [...this.resources];
}
clear(): void {
this.resources = [];
this.dispatchTypedEvent("resourcesChange", this.resources);
}
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {
return { resources: [], nextCursor: undefined };
}
const result = await c.listResources(cursor, metadata);
this.resources =
cursor === undefined
? [...result.resources]
: [...this.resources, ...result.resources];
this.dispatchTypedEvent("resourcesChange", this.resources);
return { resources: result.resources, nextCursor: result.nextCursor };
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.resources = [];
}
}

View file

@ -0,0 +1,78 @@
/**
* PagedToolsState: holds an aggregated list of tools loaded via loadPage(cursor).
* Does not load on connect; caller drives loading. Clears on disconnect.
*
* Intentionally does NOT subscribe to `toolsListChanged`: cursors are tied to
* the server's prior list, so a list change mid-pagination would invalidate
* them. The caller decides how to react (typically by `clear()` + reload
* page 1) — see the managed variant for the auto-refresh shape.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
export interface PagedToolsStateEventMap {
toolsChange: Tool[];
}
export interface LoadPageResult {
tools: Tool[];
nextCursor?: string;
}
export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
private tools: Tool[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
constructor(client: InspectorClientProtocol) {
super();
this.client = client;
const onStatusChange = (): void => {
if (this.client?.getStatus() === "disconnected") {
this.tools = [];
this.dispatchTypedEvent("toolsChange", []);
}
};
this.client.addEventListener("statusChange", onStatusChange);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("statusChange", onStatusChange);
}
this.client = null;
};
}
getTools(): Tool[] {
return [...this.tools];
}
clear(): void {
this.tools = [];
this.dispatchTypedEvent("toolsChange", this.tools);
}
async loadPage(cursor?: string): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {
return { tools: [], nextCursor: undefined };
}
const result = await c.listTools(cursor, undefined);
this.tools =
cursor === undefined
? [...result.tools]
: [...this.tools, ...result.tools];
this.dispatchTypedEvent("toolsChange", this.tools);
return { tools: result.tools, nextCursor: result.nextCursor };
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.tools = [];
}
}

View file

@ -0,0 +1,88 @@
/**
* StderrLogState: holds the stderr log, subscribes to the protocol "stderrLog"
* event. Protocol emits per-entry; this manager owns the list and emits both
* `stderrLog` (single entry) and `stderrLogsChange` (full list) on append,
* and `stderrLogsChange` on clear. Does not clear on connect/disconnect —
* pre-connect and post-connect entries both remain.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { StderrLogEntry } from "../types.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
export interface StderrLogStateEventMap {
stderrLog: StderrLogEntry;
stderrLogsChange: StderrLogEntry[];
}
export interface StderrLogStateOptions {
/**
* Maximum number of stderr log entries to store (0 = unlimited, not recommended).
* When exceeded, oldest entries are dropped. Default 1000.
*/
maxStderrLogEvents?: number;
}
export class StderrLogState extends TypedEventTarget<StderrLogStateEventMap> {
private stderrLogs: StderrLogEntry[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private readonly maxStderrLogEvents: number;
constructor(
client: InspectorClientProtocol,
options: StderrLogStateOptions = {},
) {
super();
this.maxStderrLogEvents = options.maxStderrLogEvents ?? 1000;
this.client = client;
const onStderrLog = (
event: TypedEventGeneric<InspectorClientEventMap, "stderrLog">,
): void => {
const entry = event.detail;
if (
this.maxStderrLogEvents > 0 &&
this.stderrLogs.length >= this.maxStderrLogEvents
) {
this.stderrLogs.shift();
}
this.stderrLogs.push(entry);
this.dispatchTypedEvent("stderrLog", entry);
this.dispatchTypedEvent("stderrLogsChange", this.getStderrLogs());
};
this.client.addEventListener("stderrLog", onStderrLog);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("stderrLog", onStderrLog);
}
this.client = null;
};
}
getStderrLogs(): StderrLogEntry[] {
return [...this.stderrLogs];
}
/**
* Clear all stderr log entries. Dispatches stderrLogsChange only if the
* list was non-empty.
*/
clearStderrLogs(): void {
if (this.stderrLogs.length === 0) return;
this.stderrLogs = [];
this.dispatchTypedEvent("stderrLogsChange", []);
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.stderrLogs = [];
}
}