Merge branch 'v2/main' into v2/fix/2165-oauth-recovery-floating-promises

This commit is contained in:
Cliff Hall 2026-08-29 22:40:08 -04:00 • committed by GitHub
commit 26fdc48bf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1088 additions and 772 deletions

View file

@ -25,6 +25,55 @@ export class TypedEventGeneric<
* implementation so the subclass remains assignable to EventTarget.
*/
export class TypedEventTarget<EventMap extends object> extends EventTarget {
/**
* Per-event-type dispatch counter. Read through `getEventRevision` by
* `useStoreSnapshot` (core/react) to tell "nothing has happened on this
* event since I last looked" from "something has", without comparing the
* snapshot's contents (#1955).
*/
private readonly eventRevisions = new Map<string, number>();
/**
* How many times an event of this type has been dispatched from this
* target. Monotonic per type, and per type only: an `errorChange` does not
* advance `toolsChange`, so a subscriber to one is not re-read because of
* the other.
*
* The counter — not the list's contents — is what a `useSyncExternalStore`
* snapshot is cached against. That distinction is load-bearing rather than
* an optimization: these stores hand out a defensive copy, so comparing
* contents is the only alternative, and a contents comparison cannot see an
* in-place mutation of an entry the list already holds. `MessageLogState`
* does exactly that when a response is folded into its request entry
* (filling in `response`/`duration`), and it dispatches — so the counter
* moves and the fold reaches the UI, where a shallow compare would report
* "unchanged" and never re-render it.
*/
getEventRevision(type: keyof EventMap & string): number {
return this.eventRevisions.get(type) ?? 0;
}
/**
* Overridden solely to advance the counter. It sits on `dispatchEvent`
* rather than on `dispatchTypedEvent` because that is the one funnel every
* dispatch passes through — the typed helper below delegates to it, and so
* does a hand-rolled `dispatchEvent(new CustomEvent(...))`, which the tests
* use extensively. A counter bumped only in the typed helper would be
* silently stale for those, i.e. exactly where nobody would look for it.
*
* Bumped BEFORE dispatching: the state mutation always precedes the
* dispatch, so by the time a listener runs the counter must already reflect
* it, or a snapshot read from inside that listener would return the cached
* pre-change value.
*/
override dispatchEvent(event: Event): boolean {
this.eventRevisions.set(
event.type,
(this.eventRevisions.get(event.type) ?? 0) + 1,
);
return super.dispatchEvent(event);
}
dispatchTypedEvent<K extends keyof EventMap>(
type: K,
...args: EventMap[K] extends void ? [] : [detail: EventMap[K]]

15
core/react/pagination.ts Normal file
View file

@ -0,0 +1,15 @@
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
/**
* The pagination snapshot reported while no paged store is attached (no
* active server): nothing loaded, nowhere to go next.
*
* Module scope and frozen because it is a `useStoreSnapshot` fallback — a
* fresh object literal per render would make the snapshot look different on
* every read. Shared by the three `usePaged*` list hooks, which is safe
* precisely because it is immutable.
*/
export const NO_PAGINATION: PagePaginationState = Object.freeze({
nextCursor: undefined,
pageCount: 0,
});

View file

@ -1,10 +1,16 @@
import { useState, useEffect } from "react";
import type { FetchRequestEntry } from "../mcp/types.js";
import type {
FetchRequestLogState,
FetchRequestLogStateEventMap,
} from "../mcp/state/fetchRequestLogState.js";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import type { FetchRequestLogState } from "../mcp/state/fetchRequestLogState.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_FETCH_REQUESTS: FetchRequestEntry[] = [];
const readFetchRequests = (state: FetchRequestLogState): FetchRequestEntry[] =>
state.getFetchRequests();
export interface UseFetchRequestLogResult {
fetchRequests: FetchRequestEntry[];
@ -17,35 +23,12 @@ export interface UseFetchRequestLogResult {
export function useFetchRequestLog(
fetchRequestLogState: FetchRequestLogState | null,
): UseFetchRequestLogResult {
const [fetchRequests, setFetchRequests] = useState<FetchRequestEntry[]>(
fetchRequestLogState?.getFetchRequests() ?? [],
const fetchRequests = useStoreSnapshot(
fetchRequestLogState,
"fetchRequestsChange",
readFetchRequests,
NO_FETCH_REQUESTS,
);
useEffect(() => {
if (!fetchRequestLogState) {
setFetchRequests([]);
return;
}
setFetchRequests(fetchRequestLogState.getFetchRequests());
const onFetchRequestsChange = (
event: TypedEventGeneric<
FetchRequestLogStateEventMap,
"fetchRequestsChange"
>,
) => {
setFetchRequests(event.detail);
};
fetchRequestLogState.addEventListener(
"fetchRequestsChange",
onFetchRequestsChange,
);
return () => {
fetchRequestLogState.removeEventListener(
"fetchRequestsChange",
onFetchRequestsChange,
);
};
}, [fetchRequestLogState]);
return { fetchRequests };
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
@ -12,6 +12,7 @@ import type {
} from "@modelcontextprotocol/client";
import type { ExcludedTool } from "../mcp/types.js";
import type { MalformedListItem } from "../mcp/listSalvage.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
// fallback below doesn't return a fresh literal on every render —
@ -19,6 +20,40 @@ import type { MalformedListItem } from "../mcp/listSalvage.js";
// would otherwise invalidate every tick when no client is attached.
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
/**
* Stable fallbacks for the no-client case, module scope for the same reason as
* the constant above — a `useStoreSnapshot` fallback built in the component
* body would look like a new value on every read.
*/
const NO_EXCLUDED_TOOLS: ExcludedTool[] = [];
const NO_MALFORMED_LIST_ITEMS: MalformedListItem[] = [];
const readStatus = (client: InspectorClientProtocol): ConnectionStatus =>
client.getStatus();
const readCapabilities = (
client: InspectorClientProtocol,
): ServerCapabilities | undefined => client.getCapabilities();
const readServerInfo = (
client: InspectorClientProtocol,
): Implementation | undefined => client.getServerInfo();
const readInstructions = (
client: InspectorClientProtocol,
): string | undefined => client.getInstructions();
const readProtocolVersion = (
client: InspectorClientProtocol,
): string | undefined => client.getProtocolVersion();
const readProtocolEra = (
client: InspectorClientProtocol,
): ProtocolEra | undefined => client.getProtocolEra();
const readDiscoverResult = (
client: InspectorClientProtocol,
): DiscoverResult | undefined => client.getDiscoverResult();
const readExcludedTools = (client: InspectorClientProtocol): ExcludedTool[] =>
client.getExcludedTools();
const readMalformedListItems = (
client: InspectorClientProtocol,
): MalformedListItem[] => client.getMalformedListItems();
export interface UseInspectorClientResult {
status: ConnectionStatus;
capabilities?: ServerCapabilities;
@ -69,6 +104,11 @@ export interface UseInspectorClientResult {
* connection state. Log lists (message / stderr / fetch) live in dedicated
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
*
* Every value the client can be *asked* for is read through `useStoreSnapshot`
* rather than mirrored into local state by an effect (#1955): the effect ran
* after the commit, so swapping servers painted one frame of the previous
* client's status and capabilities before correcting itself.
*
* Note: `appRendererClient` is read lazily from the client on every render
* and is NOT subscribed. It changes once at connect time and is not expected
* to change again during a session, so callers will see the current value
@ -80,169 +120,99 @@ export interface UseInspectorClientResult {
export function useInspectorClient(
inspectorClient: InspectorClientProtocol | null,
): UseInspectorClientResult {
const [status, setStatus] = useState<ConnectionStatus>(
inspectorClient?.getStatus() ?? "disconnected",
const status = useStoreSnapshot(
inspectorClient,
"statusChange",
readStatus,
"disconnected" as ConnectionStatus,
);
const [capabilities, setCapabilities] = useState<
ServerCapabilities | undefined
>(inspectorClient?.getCapabilities());
const [serverInfo, setServerInfo] = useState<Implementation | undefined>(
inspectorClient?.getServerInfo(),
const capabilities = useStoreSnapshot(
inspectorClient,
"capabilitiesChange",
readCapabilities,
undefined,
);
const [instructions, setInstructions] = useState<string | undefined>(
inspectorClient?.getInstructions(),
const serverInfo = useStoreSnapshot(
inspectorClient,
"serverInfoChange",
readServerInfo,
undefined,
);
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
inspectorClient?.getProtocolVersion(),
const instructions = useStoreSnapshot(
inspectorClient,
"instructionsChange",
readInstructions,
undefined,
);
const [protocolEra, setProtocolEra] = useState<ProtocolEra | undefined>(
inspectorClient?.getProtocolEra(),
const protocolVersion = useStoreSnapshot(
inspectorClient,
"protocolVersionChange",
readProtocolVersion,
undefined,
);
const [discoverResult, setDiscoverResult] = useState<
DiscoverResult | undefined
>(inspectorClient?.getDiscoverResult());
const [excludedTools, setExcludedTools] = useState<ExcludedTool[]>(
inspectorClient?.getExcludedTools() ?? [],
const protocolEra = useStoreSnapshot(
inspectorClient,
"protocolEraChange",
readProtocolEra,
undefined,
);
const [malformedListItems, setMalformedListItems] = useState<
MalformedListItem[]
>(inspectorClient?.getMalformedListItems() ?? []);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const discoverResult = useStoreSnapshot(
inspectorClient,
"discoverResultChange",
readDiscoverResult,
undefined,
);
const excludedTools = useStoreSnapshot(
inspectorClient,
"excludedToolsChange",
readExcludedTools,
NO_EXCLUDED_TOOLS,
);
const malformedListItems = useStoreSnapshot(
inspectorClient,
"malformedListItemsChange",
readMalformedListItems,
NO_MALFORMED_LIST_ITEMS,
);
// `lastError` is the one value here that is NOT a snapshot of something the
// client stores: there is no `getLastError()`, because the client emits the
// failure and moves on. It is accumulated from the event stream instead, so
// it stays local state — but it is still tied to one client, and must reset
// when a different one is passed in.
//
// That reset is done during render (React's documented "adjusting state
// when a prop changes" pattern, the same one `useValueChange` implements for
// the web client) rather than in an effect. An effect would paint one frame
// carrying the previous client's error, which is precisely the defect this
// hook was converted to fix.
const [errorState, setErrorState] = useState<{
client: InspectorClientProtocol | null;
message?: string;
}>({ client: inspectorClient });
if (errorState.client !== inspectorClient) {
setErrorState({ client: inspectorClient });
}
const lastError =
errorState.client === inspectorClient ? errorState.message : undefined;
useEffect(() => {
if (!inspectorClient) {
setStatus("disconnected");
setCapabilities(undefined);
setServerInfo(undefined);
setInstructions(undefined);
setProtocolVersion(undefined);
setProtocolEra(undefined);
setDiscoverResult(undefined);
setExcludedTools([]);
setMalformedListItems([]);
setLastError(undefined);
return;
}
setStatus(inspectorClient.getStatus());
setCapabilities(inspectorClient.getCapabilities());
setServerInfo(inspectorClient.getServerInfo());
setInstructions(inspectorClient.getInstructions());
setProtocolVersion(inspectorClient.getProtocolVersion());
setProtocolEra(inspectorClient.getProtocolEra());
setDiscoverResult(inspectorClient.getDiscoverResult());
setExcludedTools(inspectorClient.getExcludedTools());
setMalformedListItems(inspectorClient.getMalformedListItems());
setLastError(undefined);
if (!inspectorClient) return;
const onStatusChange = (event: TypedEvent<"statusChange">) => {
setStatus(event.detail);
// A fresh connection attempt clears any stale error from the prior
// session so the UI doesn't keep showing why the last transport died.
if (event.detail === "connecting") {
setLastError(undefined);
setErrorState({ client: inspectorClient });
}
};
const onError = (event: TypedEvent<"error">) => {
setLastError(event.detail.message);
setErrorState({ client: inspectorClient, message: event.detail.message });
};
const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => {
setCapabilities(event.detail);
};
const onServerInfoChange = (event: TypedEvent<"serverInfoChange">) => {
setServerInfo(event.detail);
};
const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => {
setInstructions(event.detail);
};
const onProtocolVersionChange = (
event: TypedEvent<"protocolVersionChange">,
) => {
setProtocolVersion(event.detail);
};
const onProtocolEraChange = (event: TypedEvent<"protocolEraChange">) => {
setProtocolEra(event.detail);
};
const onDiscoverResultChange = (
event: TypedEvent<"discoverResultChange">,
) => {
setDiscoverResult(event.detail);
};
const onMalformedListItemsChange = (
event: TypedEvent<"malformedListItemsChange">,
) => {
setMalformedListItems(event.detail);
};
const onExcludedToolsChange = (
event: TypedEvent<"excludedToolsChange">,
) => {
setExcludedTools(event.detail);
};
inspectorClient.addEventListener("statusChange", onStatusChange);
inspectorClient.addEventListener("error", onError);
inspectorClient.addEventListener(
"capabilitiesChange",
onCapabilitiesChange,
);
inspectorClient.addEventListener("serverInfoChange", onServerInfoChange);
inspectorClient.addEventListener(
"instructionsChange",
onInstructionsChange,
);
inspectorClient.addEventListener(
"protocolVersionChange",
onProtocolVersionChange,
);
inspectorClient.addEventListener("protocolEraChange", onProtocolEraChange);
inspectorClient.addEventListener(
"discoverResultChange",
onDiscoverResultChange,
);
inspectorClient.addEventListener(
"excludedToolsChange",
onExcludedToolsChange,
);
inspectorClient.addEventListener(
"malformedListItemsChange",
onMalformedListItemsChange,
);
return () => {
inspectorClient.removeEventListener("statusChange", onStatusChange);
inspectorClient.removeEventListener("error", onError);
inspectorClient.removeEventListener(
"capabilitiesChange",
onCapabilitiesChange,
);
inspectorClient.removeEventListener(
"serverInfoChange",
onServerInfoChange,
);
inspectorClient.removeEventListener(
"instructionsChange",
onInstructionsChange,
);
inspectorClient.removeEventListener(
"protocolVersionChange",
onProtocolVersionChange,
);
inspectorClient.removeEventListener(
"protocolEraChange",
onProtocolEraChange,
);
inspectorClient.removeEventListener(
"discoverResultChange",
onDiscoverResultChange,
);
inspectorClient.removeEventListener(
"excludedToolsChange",
onExcludedToolsChange,
);
inspectorClient.removeEventListener(
"malformedListItemsChange",
onMalformedListItemsChange,
);
};
}, [inspectorClient]);

View file

@ -1,12 +1,21 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
ManagedPromptsState,
ManagedPromptsStateEventMap,
} from "../mcp/state/managedPromptsState.js";
import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js";
import type { Prompt } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_PROMPTS: Prompt[] = [];
const readPrompts = (state: ManagedPromptsState): Prompt[] =>
state.getPrompts();
const readListChanged = (state: ManagedPromptsState): boolean =>
state.getListChanged();
export interface UseManagedPromptsResult {
/**
@ -30,52 +39,23 @@ export function useManagedPrompts(
client: InspectorClientProtocol | null,
managedPromptsState: ManagedPromptsState | null,
): UseManagedPromptsResult {
const [prompts, setPrompts] = useState<Prompt[]>(
managedPromptsState?.getPrompts() ?? [],
const prompts = useStoreSnapshot(
managedPromptsState,
"promptsChange",
readPrompts,
NO_PROMPTS,
);
const [listChanged, setListChanged] = useState<boolean>(
managedPromptsState?.getListChanged() ?? false,
const listChanged = useStoreSnapshot(
managedPromptsState,
"listChangedChange",
readListChanged,
false,
);
useEffect(() => {
if (!managedPromptsState) {
setPrompts([]);
setListChanged(false);
return;
}
setPrompts(managedPromptsState.getPrompts());
setListChanged(managedPromptsState.getListChanged());
const onPromptsChange = (
event: TypedEventGeneric<ManagedPromptsStateEventMap, "promptsChange">,
) => {
setPrompts(event.detail);
};
const onListChangedChange = (
event: TypedEventGeneric<
ManagedPromptsStateEventMap,
"listChangedChange"
>,
) => {
setListChanged(event.detail);
};
managedPromptsState.addEventListener("promptsChange", onPromptsChange);
managedPromptsState.addEventListener(
"listChangedChange",
onListChangedChange,
);
return () => {
managedPromptsState.removeEventListener("promptsChange", onPromptsChange);
managedPromptsState.removeEventListener(
"listChangedChange",
onListChangedChange,
);
};
}, [managedPromptsState]);
const error = useListError(managedPromptsState);
const refresh = useCallback(async (): Promise<Prompt[]> => {
if (!managedPromptsState || !client) return [];
if (!managedPromptsState || !client) return NO_PROMPTS;
// A user-initiated refresh acknowledges the change — clear the indicator
// BEFORE awaiting the fetch, not after. If a `prompts/list_changed` arrives
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
@ -84,10 +64,9 @@ export function useManagedPrompts(
managedPromptsState.clearListChanged();
// A user-initiated refresh forces a cache-bypassing round trip
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
// return stale — and re-stores the fresh aggregate.
const next = await managedPromptsState.refresh(undefined, "refresh");
setPrompts(next);
return next;
// return stale — and re-stores the fresh aggregate. The store dispatches
// `promptsChange` as it commits, so the snapshot above updates on its own.
return managedPromptsState.refresh(undefined, "refresh");
}, [client, managedPromptsState]);
const clearListChanged = useCallback(() => {

View file

@ -1,11 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
ManagedRequestorTasksState,
ManagedRequestorTasksStateEventMap,
} from "../mcp/state/managedRequestorTasksState.js";
import type { ManagedRequestorTasksState } from "../mcp/state/managedRequestorTasksState.js";
import type { Task } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_TASKS: Task[] = [];
const readTasks = (state: ManagedRequestorTasksState): Task[] =>
state.getTasks();
export interface UseManagedRequestorTasksResult {
tasks: Task[];
@ -21,38 +28,18 @@ export function useManagedRequestorTasks(
client: InspectorClientProtocol | null,
managedRequestorTasksState: ManagedRequestorTasksState | null,
): UseManagedRequestorTasksResult {
const [tasks, setTasks] = useState<Task[]>(
managedRequestorTasksState?.getTasks() ?? [],
const tasks = useStoreSnapshot(
managedRequestorTasksState,
"tasksChange",
readTasks,
NO_TASKS,
);
useEffect(() => {
if (!managedRequestorTasksState) {
setTasks([]);
return;
}
setTasks(managedRequestorTasksState.getTasks());
const onTasksChange = (
event: TypedEventGeneric<
ManagedRequestorTasksStateEventMap,
"tasksChange"
>,
) => {
setTasks(event.detail);
};
managedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
return () => {
managedRequestorTasksState.removeEventListener(
"tasksChange",
onTasksChange,
);
};
}, [managedRequestorTasksState]);
const refresh = useCallback(async (): Promise<Task[]> => {
if (!managedRequestorTasksState || !client) return [];
const next = await managedRequestorTasksState.refresh();
setTasks(next);
return next;
if (!managedRequestorTasksState || !client) return NO_TASKS;
// The store dispatches `tasksChange` as it commits the new list, so the
// snapshot above updates on its own — nothing local to push it into.
return managedRequestorTasksState.refresh();
}, [client, managedRequestorTasksState]);
const clearCompleted = useCallback((): void => {

View file

@ -1,12 +1,20 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
ManagedResourceTemplatesState,
ManagedResourceTemplatesStateEventMap,
} from "../mcp/state/managedResourceTemplatesState.js";
import type { ManagedResourceTemplatesState } from "../mcp/state/managedResourceTemplatesState.js";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
const readResourceTemplates = (
state: ManagedResourceTemplatesState,
): ResourceTemplate[] => state.getResourceTemplates();
export interface UseManagedResourceTemplatesResult {
/**
@ -27,49 +35,25 @@ export function useManagedResourceTemplates(
client: InspectorClientProtocol | null,
managedResourceTemplatesState: ManagedResourceTemplatesState | null,
): UseManagedResourceTemplatesResult {
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
>(managedResourceTemplatesState?.getResourceTemplates() ?? []);
useEffect(() => {
if (!managedResourceTemplatesState) {
setResourceTemplates([]);
return;
}
setResourceTemplates(managedResourceTemplatesState.getResourceTemplates());
const onResourceTemplatesChange = (
event: TypedEventGeneric<
ManagedResourceTemplatesStateEventMap,
"resourceTemplatesChange"
>,
) => {
setResourceTemplates(event.detail);
};
managedResourceTemplatesState.addEventListener(
"resourceTemplatesChange",
onResourceTemplatesChange,
);
return () => {
managedResourceTemplatesState.removeEventListener(
"resourceTemplatesChange",
onResourceTemplatesChange,
);
};
}, [managedResourceTemplatesState]);
const resourceTemplates = useStoreSnapshot(
managedResourceTemplatesState,
"resourceTemplatesChange",
readResourceTemplates,
NO_RESOURCE_TEMPLATES,
);
const error = useListError(managedResourceTemplatesState);
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
if (!managedResourceTemplatesState || !client) return [];
if (!managedResourceTemplatesState || !client) {
return NO_RESOURCE_TEMPLATES;
}
// A user-initiated refresh forces a cache-bypassing round trip
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
// return stale — and re-stores the fresh aggregate.
const next = await managedResourceTemplatesState.refresh(
undefined,
"refresh",
);
setResourceTemplates(next);
return next;
// return stale — and re-stores the fresh aggregate. The store dispatches
// `resourceTemplatesChange` as it commits, so the snapshot updates on its
// own.
return managedResourceTemplatesState.refresh(undefined, "refresh");
}, [client, managedResourceTemplatesState]);
return { resourceTemplates, error, refresh };

View file

@ -1,12 +1,21 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
ManagedResourcesState,
ManagedResourcesStateEventMap,
} from "../mcp/state/managedResourcesState.js";
import type { ManagedResourcesState } from "../mcp/state/managedResourcesState.js";
import type { Resource } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_RESOURCES: Resource[] = [];
const readResources = (state: ManagedResourcesState): Resource[] =>
state.getResources();
const readListChanged = (state: ManagedResourcesState): boolean =>
state.getListChanged();
export interface UseManagedResourcesResult {
/**
@ -32,61 +41,23 @@ export function useManagedResources(
client: InspectorClientProtocol | null,
managedResourcesState: ManagedResourcesState | null,
): UseManagedResourcesResult {
const [resources, setResources] = useState<Resource[]>(
managedResourcesState?.getResources() ?? [],
const resources = useStoreSnapshot(
managedResourcesState,
"resourcesChange",
readResources,
NO_RESOURCES,
);
const [listChanged, setListChanged] = useState<boolean>(
managedResourcesState?.getListChanged() ?? false,
const listChanged = useStoreSnapshot(
managedResourcesState,
"listChangedChange",
readListChanged,
false,
);
useEffect(() => {
if (!managedResourcesState) {
setResources([]);
setListChanged(false);
return;
}
setResources(managedResourcesState.getResources());
setListChanged(managedResourcesState.getListChanged());
const onResourcesChange = (
event: TypedEventGeneric<
ManagedResourcesStateEventMap,
"resourcesChange"
>,
) => {
setResources(event.detail);
};
const onListChangedChange = (
event: TypedEventGeneric<
ManagedResourcesStateEventMap,
"listChangedChange"
>,
) => {
setListChanged(event.detail);
};
managedResourcesState.addEventListener(
"resourcesChange",
onResourcesChange,
);
managedResourcesState.addEventListener(
"listChangedChange",
onListChangedChange,
);
return () => {
managedResourcesState.removeEventListener(
"resourcesChange",
onResourcesChange,
);
managedResourcesState.removeEventListener(
"listChangedChange",
onListChangedChange,
);
};
}, [managedResourcesState]);
const error = useListError(managedResourcesState);
const refresh = useCallback(async (): Promise<Resource[]> => {
if (!managedResourcesState || !client) return [];
if (!managedResourcesState || !client) return NO_RESOURCES;
// A user-initiated refresh acknowledges the change — clear the indicator
// BEFORE awaiting the fetch, not after. If a `resources/list_changed`
// arrives mid-fetch, the state re-sets the flag (and auto-refreshes);
@ -95,10 +66,9 @@ export function useManagedResources(
managedResourcesState.clearListChanged();
// A user-initiated refresh forces a cache-bypassing round trip
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
// return stale — and re-stores the fresh aggregate.
const next = await managedResourcesState.refresh(undefined, "refresh");
setResources(next);
return next;
// return stale — and re-stores the fresh aggregate. The store dispatches
// `resourcesChange` as it commits, so the snapshot updates on its own.
return managedResourcesState.refresh(undefined, "refresh");
}, [client, managedResourcesState]);
const clearListChanged = useCallback(() => {

View file

@ -1,10 +1,20 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { ManagedToolsState } from "../mcp/state/managedToolsState.js";
import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js";
import type { Tool } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_TOOLS: Tool[] = [];
const readTools = (state: ManagedToolsState): Tool[] => state.getTools();
const readListChanged = (state: ManagedToolsState): boolean =>
state.getListChanged();
export interface UseManagedToolsResult {
/**
@ -33,49 +43,23 @@ export function useManagedTools(
client: InspectorClientProtocol | null,
managedToolsState: ManagedToolsState | null,
): UseManagedToolsResult {
const [tools, setTools] = useState<Tool[]>(
managedToolsState?.getTools() ?? [],
const tools = useStoreSnapshot(
managedToolsState,
"toolsChange",
readTools,
NO_TOOLS,
);
const [listChanged, setListChanged] = useState<boolean>(
managedToolsState?.getListChanged() ?? false,
const listChanged = useStoreSnapshot(
managedToolsState,
"listChangedChange",
readListChanged,
false,
);
useEffect(() => {
if (!managedToolsState) {
setTools([]);
setListChanged(false);
return;
}
setTools(managedToolsState.getTools());
setListChanged(managedToolsState.getListChanged());
const onToolsChange = (
event: TypedEventGeneric<ManagedToolsStateEventMap, "toolsChange">,
) => {
setTools(event.detail);
};
const onListChangedChange = (
event: TypedEventGeneric<ManagedToolsStateEventMap, "listChangedChange">,
) => {
setListChanged(event.detail);
};
managedToolsState.addEventListener("toolsChange", onToolsChange);
managedToolsState.addEventListener(
"listChangedChange",
onListChangedChange,
);
return () => {
managedToolsState.removeEventListener("toolsChange", onToolsChange);
managedToolsState.removeEventListener(
"listChangedChange",
onListChangedChange,
);
};
}, [managedToolsState]);
const error = useListError(managedToolsState);
const refresh = useCallback(async (): Promise<Tool[]> => {
if (!managedToolsState || !client) return [];
if (!managedToolsState || !client) return NO_TOOLS;
// A user-initiated refresh acknowledges the change — clear the indicator
// BEFORE awaiting the fetch, not after. If a `tools/list_changed` arrives
// mid-fetch, the state re-sets the flag (and auto-refreshes); clearing
@ -84,10 +68,10 @@ export function useManagedTools(
managedToolsState.clearListChanged();
// A user-initiated refresh forces a cache-bypassing round trip
// (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't
// return stale — and re-stores the fresh aggregate.
const next = await managedToolsState.refresh(undefined, "refresh");
setTools(next);
return next;
// return stale — and re-stores the fresh aggregate. The store dispatches
// `toolsChange` as it commits, so the snapshot above updates on its own;
// there is no local state left to push the result into.
return managedToolsState.refresh(undefined, "refresh");
}, [client, managedToolsState]);
const clearListChanged = useCallback(() => {

View file

@ -1,10 +1,16 @@
import { useState, useEffect } from "react";
import type { MessageEntry } from "../mcp/types.js";
import type {
MessageLogState,
MessageLogStateEventMap,
} from "../mcp/state/messageLogState.js";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import type { MessageLogState } from "../mcp/state/messageLogState.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_MESSAGES: MessageEntry[] = [];
const readMessages = (state: MessageLogState): MessageEntry[] =>
state.getMessages();
export interface UseMessageLogResult {
messages: MessageEntry[];
@ -12,30 +18,22 @@ export interface UseMessageLogResult {
/**
* React hook that subscribes to MessageLogState and returns the message list.
*
* Note this log's changes are not all appends: folding a response into its
* request entry mutates that entry in place and dispatches. The snapshot is
* cached against the store's dispatch count rather than the list's contents
* precisely so that case still produces a new list identity and re-renders —
* see `TypedEventTarget.getEventRevision`.
*/
export function useMessageLog(
messageLogState: MessageLogState | null,
): UseMessageLogResult {
const [messages, setMessages] = useState<MessageEntry[]>(
messageLogState?.getMessages() ?? [],
const messages = useStoreSnapshot(
messageLogState,
"messagesChange",
readMessages,
NO_MESSAGES,
);
useEffect(() => {
if (!messageLogState) {
setMessages([]);
return;
}
setMessages(messageLogState.getMessages());
const onMessagesChange = (
event: TypedEventGeneric<MessageLogStateEventMap, "messagesChange">,
) => {
setMessages(event.detail);
};
messageLogState.addEventListener("messagesChange", onMessagesChange);
return () => {
messageLogState.removeEventListener("messagesChange", onMessagesChange);
};
}, [messageLogState]);
return { messages };
}

View file

@ -1,14 +1,26 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { RequestMetadata } from "../mcp/types.js";
import type {
PagedPromptsState,
PagedPromptsStateEventMap,
LoadPageResult,
} from "../mcp/state/pagedPromptsState.js";
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
import type { Prompt } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
import { NO_PAGINATION } from "./pagination.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_PROMPTS: Prompt[] = [];
const readPrompts = (state: PagedPromptsState): Prompt[] => state.getPrompts();
const readPagination = (state: PagedPromptsState): PagePaginationState =>
state.getPagination();
export interface UsePagedPromptsResult {
prompts: Prompt[];
@ -38,47 +50,18 @@ export function usePagedPrompts(
client: InspectorClientProtocol | null,
pagedPromptsState: PagedPromptsState | null,
): UsePagedPromptsResult {
const [prompts, setPrompts] = useState<Prompt[]>(
pagedPromptsState?.getPrompts() ?? [],
const prompts = useStoreSnapshot(
pagedPromptsState,
"promptsChange",
readPrompts,
NO_PROMPTS,
);
const [nextCursor, setNextCursor] = useState<string | undefined>(
pagedPromptsState?.getPagination().nextCursor,
const { nextCursor, pageCount } = useStoreSnapshot(
pagedPromptsState,
"paginationChange",
readPagination,
NO_PAGINATION,
);
const [pageCount, setPageCount] = useState<number>(
pagedPromptsState?.getPagination().pageCount ?? 0,
);
useEffect(() => {
if (!pagedPromptsState) {
setPrompts([]);
setNextCursor(undefined);
setPageCount(0);
return;
}
setPrompts(pagedPromptsState.getPrompts());
setNextCursor(pagedPromptsState.getPagination().nextCursor);
setPageCount(pagedPromptsState.getPagination().pageCount);
const onPromptsChange = (
event: TypedEventGeneric<PagedPromptsStateEventMap, "promptsChange">,
) => {
setPrompts(event.detail);
};
const onPaginationChange = (
event: TypedEventGeneric<PagedPromptsStateEventMap, "paginationChange">,
) => {
setNextCursor(event.detail.nextCursor);
setPageCount(event.detail.pageCount);
};
pagedPromptsState.addEventListener("promptsChange", onPromptsChange);
pagedPromptsState.addEventListener("paginationChange", onPaginationChange);
return () => {
pagedPromptsState.removeEventListener("promptsChange", onPromptsChange);
pagedPromptsState.removeEventListener(
"paginationChange",
onPaginationChange,
);
};
}, [pagedPromptsState]);
const error = useListError(pagedPromptsState);
@ -88,7 +71,7 @@ export function usePagedPrompts(
metadata?: RequestMetadata,
): Promise<LoadPageResult> => {
if (!pagedPromptsState || !client) {
return { prompts: [], nextCursor: undefined };
return { prompts: NO_PROMPTS, nextCursor: undefined };
}
return pagedPromptsState.loadPage(cursor, metadata);
},

View file

@ -1,12 +1,24 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
PagedRequestorTasksState,
PagedRequestorTasksStateEventMap,
LoadPageResult,
} from "../mcp/state/pagedRequestorTasksState.js";
import type { Task } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_TASKS: Task[] = [];
const readTasks = (state: PagedRequestorTasksState): Task[] => state.getTasks();
// This store has no dedicated pagination event — the cursor advances with the
// page, so it rides `tasksChange` like the list does.
const readNextCursor = (state: PagedRequestorTasksState): string | undefined =>
state.getNextCursor() ?? undefined;
export interface UsePagedRequestorTasksResult {
tasks: Task[];
@ -23,45 +35,27 @@ export function usePagedRequestorTasks(
client: InspectorClientProtocol | null,
pagedRequestorTasksState: PagedRequestorTasksState | null,
): UsePagedRequestorTasksResult {
const [tasks, setTasks] = useState<Task[]>(
pagedRequestorTasksState?.getTasks() ?? [],
const tasks = useStoreSnapshot(
pagedRequestorTasksState,
"tasksChange",
readTasks,
NO_TASKS,
);
const [nextCursor, setNextCursor] = useState<string | undefined>(
pagedRequestorTasksState?.getNextCursor() ?? undefined,
const nextCursor = useStoreSnapshot(
pagedRequestorTasksState,
"tasksChange",
readNextCursor,
undefined,
);
useEffect(() => {
if (!pagedRequestorTasksState) {
setTasks([]);
setNextCursor(undefined);
return;
}
setTasks(pagedRequestorTasksState.getTasks());
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
const onTasksChange = (
event: TypedEventGeneric<PagedRequestorTasksStateEventMap, "tasksChange">,
) => {
setTasks(event.detail);
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
};
pagedRequestorTasksState.addEventListener("tasksChange", onTasksChange);
return () => {
pagedRequestorTasksState.removeEventListener(
"tasksChange",
onTasksChange,
);
};
}, [pagedRequestorTasksState]);
const loadPage = useCallback(
async (cursor?: string): Promise<LoadPageResult> => {
if (!pagedRequestorTasksState || !client) {
return { tasks: [], nextCursor: undefined };
return { tasks: NO_TASKS, nextCursor: undefined };
}
const result = await pagedRequestorTasksState.loadPage(cursor);
setTasks(pagedRequestorTasksState.getTasks());
setNextCursor(pagedRequestorTasksState.getNextCursor() ?? undefined);
return result;
// The store dispatches `tasksChange` as it commits the page, so both
// snapshots above update on their own.
return pagedRequestorTasksState.loadPage(cursor);
},
[client, pagedRequestorTasksState],
);

View file

@ -1,13 +1,23 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { RequestMetadata } from "../mcp/types.js";
import type {
PagedResourceTemplatesState,
PagedResourceTemplatesStateEventMap,
LoadPageResult,
} from "../mcp/state/pagedResourceTemplatesState.js";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_RESOURCE_TEMPLATES: ResourceTemplate[] = [];
const readResourceTemplates = (
state: PagedResourceTemplatesState,
): ResourceTemplate[] => state.getResourceTemplates();
export interface UsePagedResourceTemplatesResult {
resourceTemplates: ResourceTemplate[];
@ -26,35 +36,12 @@ export function usePagedResourceTemplates(
client: InspectorClientProtocol | null,
pagedResourceTemplatesState: PagedResourceTemplatesState | null,
): UsePagedResourceTemplatesResult {
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
>(pagedResourceTemplatesState?.getResourceTemplates() ?? []);
useEffect(() => {
if (!pagedResourceTemplatesState) {
setResourceTemplates([]);
return;
}
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
const onResourceTemplatesChange = (
event: TypedEventGeneric<
PagedResourceTemplatesStateEventMap,
"resourceTemplatesChange"
>,
) => {
setResourceTemplates(event.detail);
};
pagedResourceTemplatesState.addEventListener(
"resourceTemplatesChange",
onResourceTemplatesChange,
);
return () => {
pagedResourceTemplatesState.removeEventListener(
"resourceTemplatesChange",
onResourceTemplatesChange,
);
};
}, [pagedResourceTemplatesState]);
const resourceTemplates = useStoreSnapshot(
pagedResourceTemplatesState,
"resourceTemplatesChange",
readResourceTemplates,
NO_RESOURCE_TEMPLATES,
);
const loadPage = useCallback(
async (
@ -62,14 +49,14 @@ export function usePagedResourceTemplates(
metadata?: RequestMetadata,
): Promise<LoadPageResult> => {
if (!pagedResourceTemplatesState || !client) {
return { resourceTemplates: [], nextCursor: undefined };
return {
resourceTemplates: NO_RESOURCE_TEMPLATES,
nextCursor: undefined,
};
}
const result = await pagedResourceTemplatesState.loadPage(
cursor,
metadata,
);
setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates());
return result;
// The store dispatches `resourceTemplatesChange` as it commits the page,
// so the snapshot above updates on its own.
return pagedResourceTemplatesState.loadPage(cursor, metadata);
},
[client, pagedResourceTemplatesState],
);

View file

@ -1,14 +1,27 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { RequestMetadata } from "../mcp/types.js";
import type {
PagedResourcesState,
PagedResourcesStateEventMap,
LoadPageResult,
} from "../mcp/state/pagedResourcesState.js";
import type { PagePaginationState } from "../mcp/state/pagedToolsState.js";
import type { Resource } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
import { NO_PAGINATION } from "./pagination.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_RESOURCES: Resource[] = [];
const readResources = (state: PagedResourcesState): Resource[] =>
state.getResources();
const readPagination = (state: PagedResourcesState): PagePaginationState =>
state.getPagination();
export interface UsePagedResourcesResult {
resources: Resource[];
@ -38,53 +51,18 @@ export function usePagedResources(
client: InspectorClientProtocol | null,
pagedResourcesState: PagedResourcesState | null,
): UsePagedResourcesResult {
const [resources, setResources] = useState<Resource[]>(
pagedResourcesState?.getResources() ?? [],
const resources = useStoreSnapshot(
pagedResourcesState,
"resourcesChange",
readResources,
NO_RESOURCES,
);
const [nextCursor, setNextCursor] = useState<string | undefined>(
pagedResourcesState?.getPagination().nextCursor,
const { nextCursor, pageCount } = useStoreSnapshot(
pagedResourcesState,
"paginationChange",
readPagination,
NO_PAGINATION,
);
const [pageCount, setPageCount] = useState<number>(
pagedResourcesState?.getPagination().pageCount ?? 0,
);
useEffect(() => {
if (!pagedResourcesState) {
setResources([]);
setNextCursor(undefined);
setPageCount(0);
return;
}
setResources(pagedResourcesState.getResources());
setNextCursor(pagedResourcesState.getPagination().nextCursor);
setPageCount(pagedResourcesState.getPagination().pageCount);
const onResourcesChange = (
event: TypedEventGeneric<PagedResourcesStateEventMap, "resourcesChange">,
) => {
setResources(event.detail);
};
const onPaginationChange = (
event: TypedEventGeneric<PagedResourcesStateEventMap, "paginationChange">,
) => {
setNextCursor(event.detail.nextCursor);
setPageCount(event.detail.pageCount);
};
pagedResourcesState.addEventListener("resourcesChange", onResourcesChange);
pagedResourcesState.addEventListener(
"paginationChange",
onPaginationChange,
);
return () => {
pagedResourcesState.removeEventListener(
"resourcesChange",
onResourcesChange,
);
pagedResourcesState.removeEventListener(
"paginationChange",
onPaginationChange,
);
};
}, [pagedResourcesState]);
const error = useListError(pagedResourcesState);
@ -94,7 +72,7 @@ export function usePagedResources(
metadata?: RequestMetadata,
): Promise<LoadPageResult> => {
if (!pagedResourcesState || !client) {
return { resources: [], nextCursor: undefined };
return { resources: NO_RESOURCES, nextCursor: undefined };
}
return pagedResourcesState.loadPage(cursor, metadata);
},

View file

@ -1,13 +1,25 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type {
PagedToolsState,
PagedToolsStateEventMap,
PagePaginationState,
LoadPageResult,
} from "../mcp/state/pagedToolsState.js";
import type { Tool } from "@modelcontextprotocol/client";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useListError } from "./useListError.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
import { NO_PAGINATION } from "./pagination.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_TOOLS: Tool[] = [];
const readTools = (state: PagedToolsState): Tool[] => state.getTools();
const readPagination = (state: PagedToolsState): PagePaginationState =>
state.getPagination();
export interface UsePagedToolsResult {
tools: Tool[];
@ -35,52 +47,25 @@ export function usePagedTools(
client: InspectorClientProtocol | null,
pagedToolsState: PagedToolsState | null,
): UsePagedToolsResult {
const [tools, setTools] = useState<Tool[]>(pagedToolsState?.getTools() ?? []);
const [nextCursor, setNextCursor] = useState<string | undefined>(
pagedToolsState?.getPagination().nextCursor,
const tools = useStoreSnapshot(
pagedToolsState,
"toolsChange",
readTools,
NO_TOOLS,
);
const [pageCount, setPageCount] = useState<number>(
pagedToolsState?.getPagination().pageCount ?? 0,
const { nextCursor, pageCount } = useStoreSnapshot(
pagedToolsState,
"paginationChange",
readPagination,
NO_PAGINATION,
);
useEffect(() => {
if (!pagedToolsState) {
setTools([]);
setNextCursor(undefined);
setPageCount(0);
return;
}
setTools(pagedToolsState.getTools());
setNextCursor(pagedToolsState.getPagination().nextCursor);
setPageCount(pagedToolsState.getPagination().pageCount);
const onToolsChange = (
event: TypedEventGeneric<PagedToolsStateEventMap, "toolsChange">,
) => {
setTools(event.detail);
};
const onPaginationChange = (
event: TypedEventGeneric<PagedToolsStateEventMap, "paginationChange">,
) => {
setNextCursor(event.detail.nextCursor);
setPageCount(event.detail.pageCount);
};
pagedToolsState.addEventListener("toolsChange", onToolsChange);
pagedToolsState.addEventListener("paginationChange", onPaginationChange);
return () => {
pagedToolsState.removeEventListener("toolsChange", onToolsChange);
pagedToolsState.removeEventListener(
"paginationChange",
onPaginationChange,
);
};
}, [pagedToolsState]);
const error = useListError(pagedToolsState);
const loadPage = useCallback(
async (cursor?: string): Promise<LoadPageResult> => {
if (!pagedToolsState || !client) {
return { tools: [], nextCursor: undefined };
return { tools: NO_TOOLS, nextCursor: undefined };
}
return pagedToolsState.loadPage(cursor);
},

View file

@ -1,8 +1,25 @@
import { useState, useEffect } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { SamplingCreateMessage } from "../mcp/samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "../mcp/elicitationCreateMessage.js";
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty lists for the no-client case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_SAMPLES: SamplingCreateMessage[] = [];
const NO_ELICITATIONS: ElicitationCreateMessage[] = [];
// Both getters already hand back a defensive copy of a queue the client
// mutates in place (`push`/`splice`), which is what makes them safe to read as
// a snapshot: the returned array can't change underneath React afterwards.
const readPendingSamples = (
client: InspectorClientProtocol,
): SamplingCreateMessage[] => client.getPendingSamples();
const readPendingElicitations = (
client: InspectorClientProtocol,
): ElicitationCreateMessage[] => client.getPendingElicitations();
export interface UsePendingClientRequestsResult {
pendingSamples: SamplingCreateMessage[];
@ -21,47 +38,18 @@ export interface UsePendingClientRequestsResult {
export function usePendingClientRequests(
inspectorClient: InspectorClientProtocol | null,
): UsePendingClientRequestsResult {
const [pendingSamples, setPendingSamples] = useState<SamplingCreateMessage[]>(
inspectorClient?.getPendingSamples() ?? [],
const pendingSamples = useStoreSnapshot(
inspectorClient,
"pendingSamplesChange",
readPendingSamples,
NO_SAMPLES,
);
const pendingElicitations = useStoreSnapshot(
inspectorClient,
"pendingElicitationsChange",
readPendingElicitations,
NO_ELICITATIONS,
);
const [pendingElicitations, setPendingElicitations] = useState<
ElicitationCreateMessage[]
>(inspectorClient?.getPendingElicitations() ?? []);
useEffect(() => {
if (!inspectorClient) {
setPendingSamples([]);
setPendingElicitations([]);
return;
}
setPendingSamples(inspectorClient.getPendingSamples());
setPendingElicitations(inspectorClient.getPendingElicitations());
const onSamplesChange = (event: TypedEvent<"pendingSamplesChange">) => {
setPendingSamples([...event.detail]);
};
const onElicitationsChange = (
event: TypedEvent<"pendingElicitationsChange">,
) => {
setPendingElicitations([...event.detail]);
};
inspectorClient.addEventListener("pendingSamplesChange", onSamplesChange);
inspectorClient.addEventListener(
"pendingElicitationsChange",
onElicitationsChange,
);
return () => {
inspectorClient.removeEventListener(
"pendingSamplesChange",
onSamplesChange,
);
inspectorClient.removeEventListener(
"pendingElicitationsChange",
onElicitationsChange,
);
};
}, [inspectorClient]);
return { pendingSamples, pendingElicitations };
}

View file

@ -1,14 +1,24 @@
import { useState, useEffect } from "react";
import type {
ResourceSubscriptionsState,
ResourceSubscriptionsStateEventMap,
} from "../mcp/state/resourceSubscriptionsState.js";
import type { ResourceSubscriptionsState } from "../mcp/state/resourceSubscriptionsState.js";
import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../mcp/types.js";
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../mcp/types.js";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_SUBSCRIPTIONS: InspectorResourceSubscription[] = [];
const readSubscriptions = (
state: ResourceSubscriptionsState,
): InspectorResourceSubscription[] => state.getSubscriptions();
const readStreamState = (
state: ResourceSubscriptionsState,
): ResourceSubscriptionStreamState => state.getStreamState();
export interface UseResourceSubscriptionsResult {
subscriptions: InspectorResourceSubscription[];
@ -29,45 +39,18 @@ export interface UseResourceSubscriptionsResult {
export function useResourceSubscriptions(
state: ResourceSubscriptionsState | null,
): UseResourceSubscriptionsResult {
const [subscriptions, setSubscriptions] = useState<
InspectorResourceSubscription[]
>(state?.getSubscriptions() ?? []);
const [streamState, setStreamState] =
useState<ResourceSubscriptionStreamState>(
state?.getStreamState() ?? INACTIVE_SUBSCRIPTION_STREAM_STATE,
);
useEffect(() => {
if (!state) {
setSubscriptions([]);
setStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
return;
}
setSubscriptions(state.getSubscriptions());
setStreamState(state.getStreamState());
const onSubscriptionsChange = (
event: TypedEventGeneric<
ResourceSubscriptionsStateEventMap,
"subscriptionsChange"
>,
) => {
setSubscriptions(event.detail);
};
const onStreamStateChange = (
event: TypedEventGeneric<
ResourceSubscriptionsStateEventMap,
"streamStateChange"
>,
) => {
setStreamState(event.detail);
};
state.addEventListener("subscriptionsChange", onSubscriptionsChange);
state.addEventListener("streamStateChange", onStreamStateChange);
return () => {
state.removeEventListener("subscriptionsChange", onSubscriptionsChange);
state.removeEventListener("streamStateChange", onStreamStateChange);
};
}, [state]);
const subscriptions = useStoreSnapshot(
state,
"subscriptionsChange",
readSubscriptions,
NO_SUBSCRIPTIONS,
);
const streamState = useStoreSnapshot(
state,
"streamStateChange",
readStreamState,
INACTIVE_SUBSCRIPTION_STREAM_STATE,
);
return { subscriptions, streamState };
}

View file

@ -1,10 +1,16 @@
import { useState, useEffect } from "react";
import type { StderrLogEntry } from "../mcp/types.js";
import type {
StderrLogState,
StderrLogStateEventMap,
} from "../mcp/state/stderrLogState.js";
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
import type { StderrLogState } from "../mcp/state/stderrLogState.js";
import { useStoreSnapshot } from "./useStoreSnapshot.js";
/**
* Shared stable empty list for the no-server case. Module scope so the
* snapshot doesn't change identity every render — see `useStoreSnapshot`.
* Read-only by contract: nothing mutates a list this hook returns.
*/
const NO_STDERR_LOGS: StderrLogEntry[] = [];
const readStderrLogs = (state: StderrLogState): StderrLogEntry[] =>
state.getStderrLogs();
export interface UseStderrLogResult {
stderrLogs: StderrLogEntry[];
@ -16,29 +22,12 @@ export interface UseStderrLogResult {
export function useStderrLog(
stderrLogState: StderrLogState | null,
): UseStderrLogResult {
const [stderrLogs, setStderrLogs] = useState<StderrLogEntry[]>(
stderrLogState?.getStderrLogs() ?? [],
const stderrLogs = useStoreSnapshot(
stderrLogState,
"stderrLogsChange",
readStderrLogs,
NO_STDERR_LOGS,
);
useEffect(() => {
if (!stderrLogState) {
setStderrLogs([]);
return;
}
setStderrLogs(stderrLogState.getStderrLogs());
const onStderrLogsChange = (
event: TypedEventGeneric<StderrLogStateEventMap, "stderrLogsChange">,
) => {
setStderrLogs(event.detail);
};
stderrLogState.addEventListener("stderrLogsChange", onStderrLogsChange);
return () => {
stderrLogState.removeEventListener(
"stderrLogsChange",
onStderrLogsChange,
);
};
}, [stderrLogState]);
return { stderrLogs };
}

View file

@ -0,0 +1,138 @@
import { useCallback, useRef, useSyncExternalStore } from "react";
/**
* The slice of a state store `useStoreSnapshot` needs, declared structurally
* rather than as a concrete class so every list/log hook can share it without
* threading its item type through. `TypedEventTarget` satisfies it, and the
* `E` parameter is what makes a mistyped event name a compile error: the
* concrete stores declare `getEventRevision(type: keyof EventMap & string)`,
* so a store is assignable to `SnapshotStore<"toolsChange">` only when that
* name is really in its event map.
*/
export interface SnapshotStore<E extends string> {
getEventRevision(type: E): number;
addEventListener(type: E, listener: (event: Event) => void): void;
removeEventListener(type: E, listener: (event: Event) => void): void;
}
/**
* Subscribe to one event on one state store and read a value from it (#1955).
*
* This replaces the `useState(read(prop)) + useEffect(re-read; subscribe)`
* shape every list/log hook in this directory used to carry. That shape has
* two defects, both invisible in the common case and both fixed here:
*
* 1. **A stale frame when the store prop changes.** Switching servers hands
* the hook a different store, but the effect that re-reads it runs *after*
* the render commits — so React paints one frame of the previous server's
* tools, then corrects itself. `useSyncExternalStore` reads during render,
* so the swap lands in the same frame.
* 2. **A missed update between render and subscribe.** An event dispatched in
* that gap was lost, because the snapshot was taken before the listener
* was attached. React re-checks the snapshot immediately after subscribing,
* which closes the window — provided the snapshot is read from the live
* store rather than from something the (not-yet-attached) listener would
* have had to invalidate. That is why the cache below keys off the store's
* own dispatch counter and not off a dirty flag set by our listener.
*
* It is the same reasoning `useListError` documents; that hook needs no cache
* because its snapshot is the stored `Error` instance itself.
*
* ## Why a revision counter
*
* `useSyncExternalStore` requires `getSnapshot` to return a referentially
* stable value across reads that mean "no change" — otherwise every read looks
* like a new value and React re-renders forever. These stores return a
* defensive copy (`getTools()` is `[...this.items]`), so the raw getter cannot
* be used directly. Caching against `getEventRevision(event)` gives an O(1)
* check that is exact in both directions: unchanged revision returns the
* identical value, and any dispatch produces a fresh one — including a
* dispatch that mutated an entry *inside* the list, which a contents
* comparison could not detect (see `TypedEventTarget.getEventRevision`).
*
* ## Caller contract
*
* `read` and `whenAbsent` must be **referentially stable across renders**, and
* they are part of the cache key below (along with `event`) so that a changed
* argument is never answered with a value computed from the previous one.
*
* What "stable" requires depends on the value. A `read` is always a function,
* so declare it at module scope — every caller in this directory does. A
* `whenAbsent` that is a **primitive** (`false`, `undefined`, `"disconnected"`)
* is stable under `Object.is` already and is passed inline throughout these
* hooks; only an object or array fallback needs a module-scope constant, which
* is what `NO_TOOLS` and `NO_PAGINATION` are for.
*
* Getting that wrong is a quiet cost rather than a failure, so it will not
* announce itself. Measured (React 19, #1955): an inline `read` closure throws
* nothing, logs nothing — not even React's "The result of getSnapshot should be
* cached" dev warning, since that check calls `getSnapshot` twice within one
* render and the closure is the same both times, so the cache answers both
* identically — and forces no extra render pass. What it does is hand back a
* fresh value on every render, which defeats every downstream `useMemo`,
* `React.memo` and effect dependency that keys on it. That is the whole reason
* the contract is stated rather than merely implied.
*
* @param store the state store, or `null` when no server is active
* @param event the store event that signals this value changed
* @param read reads the value out of a non-null store
* @param whenAbsent the value to report while `store` is `null`
*/
export function useStoreSnapshot<
E extends string,
S extends SnapshotStore<E>,
V,
>(store: S | null, event: E, read: (store: S) => V, whenAbsent: V): V {
// One cache cell per hook instance. Written from `getSnapshot`, which React
// calls during render — that is the documented shape for a cached
// `getSnapshot`, and it is safe here because the cell is a pure memo of
// (store, revision) that any render can recompute identically.
const cache = useRef<{
store: S | null;
event: E;
read: (store: S) => V;
whenAbsent: V;
revision: number;
value: V;
} | null>(null);
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (!store) return () => {};
store.addEventListener(event, onStoreChange);
return () => {
store.removeEventListener(event, onStoreChange);
};
},
[store, event],
);
const getSnapshot = useCallback((): V => {
// Read the revision from the live store on every call, so an event that
// arrived while nothing was listening is still visible here.
const revision = store ? store.getEventRevision(event) : 0;
const cached = cache.current;
// Every input the value was derived from is part of the key, not just the
// store and its revision: a changed `read` (or `event`, or — with a null
// store, where the revision is pinned at 0 — a changed `whenAbsent`) means
// the cached value answers a question nobody asked any more.
if (
cached &&
Object.is(cached.store, store) &&
cached.event === event &&
cached.read === read &&
Object.is(cached.whenAbsent, whenAbsent) &&
cached.revision === revision
) {
return cached.value;
}
const value = store ? read(store) : whenAbsent;
cache.current = { store, event, read, whenAbsent, revision, value };
return value;
}, [store, event, read, whenAbsent]);
// Server snapshot: the same read. These stores are browser/Node runtime
// objects with no SSR path, and passing the same getter keeps hydration
// consistent rather than throwing on a server render.
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}