* 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:
parent
a4215e57b9
commit
e3e70470b5
35 changed files with 6404 additions and 5 deletions
219
core/mcp/__tests__/fakeInspectorClient.ts
Normal file
219
core/mcp/__tests__/fakeInspectorClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
134
core/mcp/inspectorClientEventTarget.ts
Normal file
134
core/mcp/inspectorClientEventTarget.ts
Normal 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> {}
|
||||
105
core/mcp/inspectorClientProtocol.ts
Normal file
105
core/mcp/inspectorClientProtocol.ts
Normal 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;
|
||||
}
|
||||
20
core/mcp/samplingCreateMessage.ts
Normal file
20
core/mcp/samplingCreateMessage.ts
Normal 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;
|
||||
}
|
||||
30
core/mcp/sessionStorage.ts
Normal file
30
core/mcp/sessionStorage.ts
Normal 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>;
|
||||
}
|
||||
156
core/mcp/state/fetchRequestLogState.ts
Normal file
156
core/mcp/state/fetchRequestLogState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
97
core/mcp/state/managedPromptsState.ts
Normal file
97
core/mcp/state/managedPromptsState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
136
core/mcp/state/managedRequestorTasksState.ts
Normal file
136
core/mcp/state/managedRequestorTasksState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
108
core/mcp/state/managedResourceTemplatesState.ts
Normal file
108
core/mcp/state/managedResourceTemplatesState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
102
core/mcp/state/managedResourcesState.ts
Normal file
102
core/mcp/state/managedResourcesState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
94
core/mcp/state/managedToolsState.ts
Normal file
94
core/mcp/state/managedToolsState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
33
core/mcp/state/mergeTaskIntoList.ts
Normal file
33
core/mcp/state/mergeTaskIntoList.ts
Normal 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;
|
||||
}
|
||||
162
core/mcp/state/messageLogState.ts
Normal file
162
core/mcp/state/messageLogState.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
80
core/mcp/state/pagedPromptsState.ts
Normal file
80
core/mcp/state/pagedPromptsState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
132
core/mcp/state/pagedRequestorTasksState.ts
Normal file
132
core/mcp/state/pagedRequestorTasksState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
core/mcp/state/pagedResourceTemplatesState.ts
Normal file
84
core/mcp/state/pagedResourceTemplatesState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
80
core/mcp/state/pagedResourcesState.ts
Normal file
80
core/mcp/state/pagedResourcesState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
78
core/mcp/state/pagedToolsState.ts
Normal file
78
core/mcp/state/pagedToolsState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
88
core/mcp/state/stderrLogState.ts
Normal file
88
core/mcp/state/stderrLogState.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
85
core/mcp/typedEventTarget.ts
Normal file
85
core/mcp/typedEventTarget.ts
Normal 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
Loading…
Reference in a new issue