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

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);
}
}

View file

@ -0,0 +1,134 @@
/**
* Type-safe EventTarget for InspectorClient events.
* Extends the generic TypedEventTarget with InspectorClientEventMap; TypedEvent
* is provided as a type alias for use in listener signatures.
*
* v2 note: the v1.5 event map referenced SamplingCreateMessage / ElicitationCreateMessage
* classes (with embedded promise resolvers) and OAuth events from the auth subsystem.
* The auth subsystem and the runtime promise-based pending classes are not yet ported to
* v2 — see #1243 scope. Pending requests are typed with the v2 wrapper types
* (InspectorPendingSampling / InspectorPendingElicitation); OAuth events are deliberately
* omitted here and will be added when the auth subsystem is ported.
*/
import {
TypedEventTarget,
type TypedEventGeneric,
} from "./typedEventTarget.js";
import type {
ConnectionStatus,
MessageEntry,
StderrLogEntry,
FetchRequestEntry,
PromptGetInvocation,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
} from "./types.js";
import type {
Tool,
ServerCapabilities,
Implementation,
Root,
Progress,
ProgressToken,
Task,
CallToolResult,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import type { InspectorPendingSampling } from "./samplingCreateMessage.js";
import type { InspectorPendingElicitation } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
createdAt?: string;
};
/**
* Maps event names to their detail types for CustomEvents
*/
export interface InspectorClientEventMap {
statusChange: ConnectionStatus;
toolsChange: Tool[];
capabilitiesChange: ServerCapabilities | undefined;
serverInfoChange: Implementation | undefined;
instructionsChange: string | undefined;
message: MessageEntry;
stderrLog: StderrLogEntry;
fetchRequest: FetchRequestEntry;
error: Error;
resourceUpdated: { uri: string };
progressNotification: Progress & { progressToken?: ProgressToken };
toolCallResultChange: {
toolName: string;
params: Record<string, JsonValue>;
result: CallToolResult | null;
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
};
resourceContentChange: {
uri: string;
content: ResourceReadInvocation;
timestamp: Date;
};
resourceTemplateContentChange: {
uriTemplate: string;
content: ResourceTemplateReadInvocation;
params: Record<string, string>;
timestamp: Date;
};
promptContentChange: {
name: string;
content: PromptGetInvocation;
timestamp: Date;
};
pendingSamplesChange: InspectorPendingSampling[];
newPendingSample: InspectorPendingSampling;
pendingElicitationsChange: InspectorPendingElicitation[];
newPendingElicitation: InspectorPendingElicitation;
rootsChange: Root[];
resourceSubscriptionsChange: string[];
// Task events
/** Fired only from server notification notifications/tasks/status. */
taskStatusChange: { taskId: string; task: Task };
/** Fired from callToolStream for each task update. */
toolCallTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: McpError;
};
/** Fired from getRequestorTask() and callToolStream (client-origin task updates). */
requestorTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: McpError;
};
taskCancelled: { taskId: string };
tasksChange: Task[];
// Signal events (no payload)
connect: void;
disconnect: void;
// List changed notification events
toolsListChanged: void;
resourcesListChanged: void;
resourceTemplatesListChanged: void;
promptsListChanged: void;
tasksListChanged: void;
// Session persistence (dispatched by client; FetchRequestLogState listens and saves)
saveSession: { sessionId: string };
}
/**
* Type alias for InspectorClient typed events (for listener signatures).
*/
export type TypedEvent<K extends keyof InspectorClientEventMap> =
TypedEventGeneric<InspectorClientEventMap, K>;
/**
* Type-safe EventTarget for InspectorClient events.
*/
export class InspectorClientEventTarget extends TypedEventTarget<InspectorClientEventMap> {}

View file

@ -0,0 +1,105 @@
/**
* InspectorClientProtocol: the minimal surface that state managers (core/mcp/state/)
* and React hooks (core/react/) consume.
*
* v1.5 has a single concrete `InspectorClient` class (~2k LOC) bundling the transport,
* SDK client, OAuth flow, pending-request queues, and fetch tracking. Porting the full
* class requires first porting the auth subsystem (core/auth/, ~12 files) — that's
* deferred to a follow-up issue.
*
* This interface captures just what the state+hooks layer needs to function,
* so the layer is decoupled from how (or whether) the runtime is wired up.
* The real `InspectorClient` will implement this interface in the follow-up port.
*/
import type {
ConnectionStatus,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
PromptGetInvocation,
ToolCallInvocation,
} from "./types.js";
import type {
Implementation,
LoggingLevel,
Prompt,
Resource,
ResourceTemplate,
ServerCapabilities,
Task,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
/**
* Opaque type representing the AppRendererClient surface used by @mcp-ui.
* v1.5 aliases this to the SDK `Client` type; v2 leaves it opaque until the
* real InspectorClient (or a focused App-renderer port) lands.
*/
export type AppRendererClient = unknown;
/**
* The contract every state manager and hook depends on. Anything that holds
* a reference to "an Inspector client" — including tests and fixtures —
* should depend on this protocol rather than the concrete class.
*/
export interface InspectorClientProtocol extends InspectorClientEventTarget {
// Connection state accessors
getStatus(): ConnectionStatus;
getCapabilities(): ServerCapabilities | undefined;
getServerInfo(): Implementation | undefined;
getInstructions(): string | undefined;
getAppRendererClient(): AppRendererClient | null;
// Connection control
connect(): Promise<void>;
disconnect(safeDisconnectTimeout?: number): Promise<void>;
// Paginated list methods used by managed/paged state stores
listTools(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ tools: Tool[]; nextCursor?: string }>;
listPrompts(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ prompts: Prompt[]; nextCursor?: string }>;
listResources(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ resources: Resource[]; nextCursor?: string }>;
listResourceTemplates(
cursor?: string,
metadata?: Record<string, string>,
): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }>;
listRequestorTasks(
cursor?: string,
): Promise<{ tasks: Task[]; nextCursor?: string }>;
// Invocation methods used by paged result panels
callTool(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
): Promise<ToolCallInvocation>;
readResource(
uri: string,
metadata?: Record<string, string>,
): Promise<ResourceReadInvocation>;
readResourceFromTemplate(
uriTemplate: string,
params: Record<string, string>,
metadata?: Record<string, string>,
): Promise<ResourceTemplateReadInvocation>;
getPrompt(
name: string,
params?: Record<string, string>,
metadata?: Record<string, string>,
): Promise<PromptGetInvocation>;
// Misc surface required by hooks/state
setLoggingLevel(level: LoggingLevel): Promise<void>;
getSessionId(): string | undefined;
}

View file

@ -0,0 +1,20 @@
import type {
CreateMessageRequest,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
export type { CreateMessageRequest, CreateMessageResult };
/**
* Shape of a pending sampling request tracked by the Inspector client.
* v1.5 implements this as a class with a resolver/reject closure; v2 will
* materialize the runtime when the core hook layer is wired to the
* (yet-to-be-ported) InspectorClient class. For now we keep the interface so
* screens/groups can type the pending-sampling queue.
*/
export interface InspectorPendingSampling {
id: string;
timestamp: Date;
request: CreateMessageRequest;
taskId?: string;
}

View file

@ -0,0 +1,30 @@
import type { FetchRequestEntry } from "./types.js";
/**
* Serialized session state for InspectorClient.
* Contains data that should persist across page navigations (e.g., OAuth redirects).
*/
export interface InspectorClientSessionState {
/** Fetch requests tracked during the session */
fetchRequests: FetchRequestEntry[];
/** Timestamp when session was created */
createdAt: number;
/** Timestamp when session was last updated */
updatedAt: number;
}
/**
* Storage interface for persisting InspectorClient session state.
* Used to maintain session data (e.g., fetch requests) across page navigations
* during OAuth flows.
*/
export interface InspectorClientStorage {
saveSession(
sessionId: string,
state: InspectorClientSessionState,
): Promise<void>;
loadSession(
sessionId: string,
): Promise<InspectorClientSessionState | undefined>;
deleteSession(sessionId: string): Promise<void>;
}

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 = [];
}
}

View file

@ -0,0 +1,85 @@
/**
* Generic type-safe EventTarget for any domain (InspectorClient, state managers, etc.).
* Extends EventTarget so instances are valid EventTargets; uses overloads to preserve
* base-class assignability while providing typed addEventListener/removeEventListener.
*/
/**
* Typed event class that extends CustomEvent with type-safe detail.
* For void events, detail is undefined.
*/
export class TypedEventGeneric<
EventMap extends object,
K extends keyof EventMap,
> extends CustomEvent<EventMap[K]> {
constructor(type: K, detail?: EventMap[K]) {
super(type as string, { detail });
}
}
/**
* Type-safe EventTarget parameterized by an event map (event name → detail type).
* Extends EventTarget so instances are assignable to EventTarget. Uses the same
* overload pattern as the DOM: typed overloads for our API plus a base-compatible
* implementation so the subclass remains assignable to EventTarget.
*/
export class TypedEventTarget<EventMap extends object> extends EventTarget {
dispatchTypedEvent<K extends keyof EventMap>(
type: K,
...args: EventMap[K] extends void ? [] : [detail: EventMap[K]]
): void {
const detail =
(args[0] as EventMap[K] | undefined) ?? (undefined as EventMap[K]);
this.dispatchEvent(new TypedEventGeneric<EventMap, K>(type, detail));
}
addEventListener<K extends keyof EventMap>(
type: K,
listener: (event: TypedEventGeneric<EventMap, K>) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener:
| ((event: TypedEventGeneric<EventMap, keyof EventMap>) => void)
| EventListenerOrEventListenerObject
| null,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(
type,
listener as EventListenerOrEventListenerObject | null,
options,
);
}
removeEventListener<K extends keyof EventMap>(
type: K,
listener: (event: TypedEventGeneric<EventMap, K>) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener:
| ((event: TypedEventGeneric<EventMap, keyof EventMap>) => void)
| EventListenerOrEventListenerObject
| null,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(
type,
listener as EventListenerOrEventListenerObject | null,
options,
);
}
}

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