fix: surface list-fetch failures instead of swallowing them
A list load that failed — a transport error, or a result the SDK's era codec rejects as invalid — reported nothing. The connect-time refresh is fired with no caller to await it (`void this.refresh()`), so its rejection became an unhandled rejection: the header said "Connected", the panel rendered an empty list indistinguishable from a server that has none, and the failed exchange showed in the Protocol panel as a clean success. Two surfaces now tell the truth: - ManagedListState records the failure as observable state (`getError` + an `errorChange` event) and still re-throws, so App's auth-recovery wrapper keeps working. The four `useManaged*` hooks expose it via a shared `useManagedListError`, and the Tools/Prompts/Resources sidebars render a `ListLoadError` alert with the reason and a Retry. - A response the client refused is attributed back to its Protocol entry (`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`), which now renders an Error status and a "Rejected by the Inspector" alert rather than the green resultType badge alone. The SDK gives no request id with a decode failure, so the id is recovered by correlation; see the comment on `markResponseRejected` for why that is exact. Also stops `listAllTools` from dropping a failing excluded-tools walk on the floor — it stays non-fatal but is logged. Closes #1953 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
parent
097bfafdc1
commit
f201032137
16 changed files with 1080 additions and 48 deletions
|
|
@ -133,6 +133,10 @@ export class FakeInspectorClient
|
|||
return this.tasksExtensionNegotiated;
|
||||
}
|
||||
|
||||
// Attributes a failed load back to its Protocol entry (#1953). A `vi.fn` so
|
||||
// tests can assert the method name and reason a failing refresh reported.
|
||||
markResponseRejected = vi.fn((_method: string, _reason: string) => {});
|
||||
|
||||
// Aggregate variants used by the managed state stores on refresh: drain ALL
|
||||
// queued pages (mimicking the SDK's all-page walk) and return the flattened
|
||||
// list. The `options` (incl. `cacheMode`) is recorded by the `vi.fn` so tests
|
||||
|
|
|
|||
|
|
@ -358,6 +358,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private outputValidator: AjvJsonSchemaValidator | null = null;
|
||||
private transport: Transport | MessageTrackingTransport | null = null;
|
||||
private baseTransport: Transport | null = null;
|
||||
// Correlation for `markResponseRejected` (#1953): the method of each
|
||||
// outbound request still awaiting a response, and — once one is answered —
|
||||
// the id of the most recently answered request per method. Entries are
|
||||
// dropped as responses arrive, so this holds at most one id per method
|
||||
// rather than growing with the session.
|
||||
private outboundRequestMethods = new Map<string | number, string>();
|
||||
private lastAnsweredRequestByMethod = new Map<string, string | number>();
|
||||
/** True when the cached transport was built with an OAuth authProvider attached. */
|
||||
private transportHasAuthProvider = false;
|
||||
/** Dedupes concurrent ambient auth challenges (reason + scopes). */
|
||||
|
|
@ -786,6 +793,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private createMessageTrackingCallbacks(): MessageTrackingCallbacks {
|
||||
return {
|
||||
trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => {
|
||||
if (origin === "client") {
|
||||
this.outboundRequestMethods.set(message.id, message.method);
|
||||
}
|
||||
const entry: MessageEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
|
|
@ -799,6 +809,19 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
message: JSONRPCResultResponse | JSONRPCErrorResponse,
|
||||
origin: MessageOrigin,
|
||||
) => {
|
||||
// A response to one of OUR requests closes that id's correlation entry
|
||||
// and becomes the method's most recent answer (#1953). The transport
|
||||
// only tracks responses that carry an id, but the JSON-RPC types leave
|
||||
// it optional for an error frame the server couldn't attribute — such a
|
||||
// frame answers no specific request, so it is skipped.
|
||||
const responseId = message.id;
|
||||
if (origin === "server" && responseId !== undefined) {
|
||||
const method = this.outboundRequestMethods.get(responseId);
|
||||
this.outboundRequestMethods.delete(responseId);
|
||||
if (method !== undefined) {
|
||||
this.lastAnsweredRequestByMethod.set(method, responseId);
|
||||
}
|
||||
}
|
||||
const entry: MessageEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
|
|
@ -3080,8 +3103,17 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// reflect the current wire truth. Accepted for a debugging tool where the
|
||||
// list is small and correctness of "why did this tool vanish" matters more
|
||||
// than the extra request; it's a no-op (no round trip) on legacy/stdio.
|
||||
// Kept best-effort: an error here must never fail the tools list itself.
|
||||
await this.refreshExcludedTools(options?.metadata).catch(() => {});
|
||||
// Kept best-effort: an error here must never fail the tools list itself —
|
||||
// but it is logged rather than dropped on the floor, so a failing
|
||||
// excluded-tools walk is diagnosable instead of silently leaving the
|
||||
// "Excluded (SEP-2243)" section empty and looking like a clean server
|
||||
// (#1953).
|
||||
await this.refreshExcludedTools(options?.metadata).catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
{ err },
|
||||
"Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete",
|
||||
);
|
||||
});
|
||||
return { tools: [...response.tools] };
|
||||
}
|
||||
|
||||
|
|
@ -3095,6 +3127,27 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return this.isModernEra() && this.getServerType() !== "stdio";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the response that most recently answered `method` as rejected by the
|
||||
* client, so its Protocol entry shows why instead of rendering as a clean
|
||||
* success (#1953).
|
||||
*
|
||||
* The SDK gives no request id with a decode failure — `SdkError` carries the
|
||||
* method and nothing else — so the id is recovered by correlation: the last
|
||||
* response received for that method. That is exact rather than approximate,
|
||||
* because the SDK rejects synchronously while decoding the response (inside
|
||||
* the transport's `onmessage`) and the caller's `catch` runs in the very next
|
||||
* microtask. Delivering another response for the same method in that window
|
||||
* would take a macrotask (a socket read), which cannot interleave there.
|
||||
*
|
||||
* A no-op when nothing has answered `method` this session.
|
||||
*/
|
||||
markResponseRejected(method: string, reason: string): void {
|
||||
const id = this.lastAnsweredRequestByMethod.get(method);
|
||||
if (id === undefined) return;
|
||||
this.dispatchTypedEvent("responseRejected", { id, reason });
|
||||
}
|
||||
|
||||
/** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */
|
||||
getExcludedTools(): ExcludedTool[] {
|
||||
return this.excludedTools;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ export interface InspectorClientEventMap {
|
|||
/** `server/discover` result on a probed/pinned connect; undefined on legacy. */
|
||||
discoverResultChange: DiscoverResult | undefined;
|
||||
message: MessageEntry;
|
||||
/**
|
||||
* A response the client REJECTED after it was logged — the server answered,
|
||||
* but the SDK's era codec refused the result (e.g. a 2026-07-28 `tools/list`
|
||||
* missing `ttlMs`/`cacheScope`). The wire frame is a valid JSON-RPC result,
|
||||
* so the Protocol entry would otherwise render as a clean success; this
|
||||
* carries the reason so it can be marked instead (#1953). `id` is the
|
||||
* JSON-RPC id of the rejected response.
|
||||
*/
|
||||
responseRejected: { id: string | number; reason: string };
|
||||
stderrLog: StderrLogEntry;
|
||||
fetchRequest: FetchRequestEntry;
|
||||
/** Fired when an in-flight fetch's response body is read asynchronously. */
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
* and the modern task store's poll-based refresh. */
|
||||
isTasksExtensionNegotiated(): boolean;
|
||||
|
||||
/**
|
||||
* Mark the response that most recently answered `method` as rejected by the
|
||||
* client, so its Protocol entry shows the reason instead of rendering as a
|
||||
* clean success (#1953). Optional so existing test doubles satisfy the
|
||||
* interface without implementing it; see `InspectorClient` for how the id is
|
||||
* correlated and why that correlation is exact.
|
||||
*/
|
||||
markResponseRejected?(method: string, reason: string): void;
|
||||
|
||||
// Aggregate (all-page) list methods used by the managed state stores on
|
||||
// refresh. Unlike the single-page methods above, these route through the
|
||||
// SDK's cache-aware high-level verbs so `cacheMode` ('use' | 'refresh' |
|
||||
|
|
|
|||
|
|
@ -29,12 +29,29 @@ import { TypedEventTarget } from "../typedEventTarget.js";
|
|||
*/
|
||||
export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250;
|
||||
|
||||
/** Every managed-list event map carries the list-changed indicator event. */
|
||||
/**
|
||||
* Every managed-list event map carries the list-changed indicator event and the
|
||||
* last-fetch error (#1953).
|
||||
*/
|
||||
export interface ManagedListEventMap {
|
||||
/**
|
||||
* Fires when the "list changed since last refresh" flag flips. True when
|
||||
* this list's `list_changed` notification arrives (auto-refresh off), false
|
||||
* once the user refreshes or the connection drops. Drives the sidebar
|
||||
* list-changed indicator (#1402).
|
||||
*/
|
||||
listChangedChange: boolean;
|
||||
/** The last fetch's failure, or `null` once a fetch succeeds. */
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
export interface ManagedListConfig<T, M extends ManagedListEventMap> {
|
||||
/**
|
||||
* The JSON-RPC method this list pages (e.g. "tools/list"). Used to attribute
|
||||
* a failed load back to its Protocol entry — see
|
||||
* `InspectorClientProtocol.markResponseRejected` (#1953).
|
||||
*/
|
||||
listMethod: string;
|
||||
/** The `*Change` event this manager dispatches (e.g. "toolsChange"). */
|
||||
changeEvent: keyof M;
|
||||
/** The client notification that signals the list changed. */
|
||||
|
|
@ -81,6 +98,11 @@ export abstract class ManagedListState<
|
|||
private unsubscribe: (() => void) | null = null;
|
||||
private _metadata: Record<string, string> | undefined = undefined;
|
||||
private listChanged = false;
|
||||
// The last fetch's failure, kept as observable state so a load that fails
|
||||
// (a transport error, or a result the SDK codec rejects as invalid) is
|
||||
// rendered by the list panel instead of vanishing into an unhandled
|
||||
// rejection — which read as "this server has no tools" (#1953).
|
||||
private error: Error | null = null;
|
||||
private readonly config: ManagedListConfig<T, M>;
|
||||
// Debounce a burst of `list_changed` notifications into a single
|
||||
// refresh (or one indicator light) once it settles.
|
||||
|
|
@ -112,7 +134,11 @@ export abstract class ManagedListState<
|
|||
) {
|
||||
return;
|
||||
}
|
||||
void this.refresh();
|
||||
// The connect-time load has no caller to await it, so its rejection is
|
||||
// caught here rather than left to become an unhandled rejection. This is
|
||||
// not a swallow: `refresh` has already recorded the failure via
|
||||
// `setError`, and the list panel renders it (#1953).
|
||||
void this.refresh().catch(() => {});
|
||||
};
|
||||
const onListChanged = (): void => {
|
||||
// Debounce: collapse a burst of notifications into one settled action
|
||||
|
|
@ -133,6 +159,9 @@ export abstract class ManagedListState<
|
|||
this.items = [];
|
||||
this.dispatchChange();
|
||||
this.setListChanged(false);
|
||||
// A disconnect ends the session the error belonged to — a stale
|
||||
// "couldn't load tools" must not outlive it into the next connect.
|
||||
this.setError(null);
|
||||
}
|
||||
};
|
||||
this.client.addEventListener("connect", onConnect);
|
||||
|
|
@ -187,8 +216,10 @@ export abstract class ManagedListState<
|
|||
if (!skipAggregate && settings?.autoRefreshOnListChanged) {
|
||||
// A `list_changed` means the prior list is stale, so bypass any
|
||||
// cached entry (`cacheMode: "refresh"`) and re-store the fresh
|
||||
// aggregate.
|
||||
await this.refresh(undefined, "refresh");
|
||||
// aggregate. Like the connect-time load this has no caller to await
|
||||
// it, so a failure is caught here — already recorded by `setError`
|
||||
// and rendered by the panel (#1953).
|
||||
await this.refresh(undefined, "refresh").catch(() => {});
|
||||
} else if (this.config.supportsIndicator) {
|
||||
this.setListChanged(true);
|
||||
}
|
||||
|
|
@ -236,6 +267,20 @@ export abstract class ManagedListState<
|
|||
this.emit("listChangedChange", value);
|
||||
}
|
||||
|
||||
/** The last fetch's failure, or `null` when the last fetch succeeded. */
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
// Compared by identity rather than message: two distinct failures with the
|
||||
// same text are still two events, and a re-render on a repeat failure is
|
||||
// cheap next to silently coalescing them.
|
||||
private setError(value: Error | null): void {
|
||||
if (this.error === value) return;
|
||||
this.error = value;
|
||||
this.emit("errorChange", value);
|
||||
}
|
||||
|
||||
setMetadata(metadata?: Record<string, string>): void {
|
||||
this._metadata = metadata;
|
||||
}
|
||||
|
|
@ -245,12 +290,34 @@ export abstract class ManagedListState<
|
|||
* this fetch: `undefined` (the connect-time load) uses the default `'use'`;
|
||||
* a user-initiated or auto refresh passes `'refresh'` to force a
|
||||
* cache-bypassing round trip and re-store the fresh aggregate.
|
||||
*
|
||||
* A failure is recorded as observable state (`getError`) AND re-thrown: the
|
||||
* state drives the panel's error rendering, while the rejection is what the
|
||||
* caller's auth-recovery wrapper keys off to detect a 401 and start a
|
||||
* re-authorization. Callers with nobody to await them (the connect-time load,
|
||||
* the `list_changed` auto-refresh) catch it explicitly (#1953).
|
||||
*/
|
||||
async refresh(
|
||||
metadata?: Record<string, string>,
|
||||
cacheMode?: CacheMode,
|
||||
): Promise<T[]> {
|
||||
const next = await this.fetchItems(metadata, cacheMode);
|
||||
let next: T[] | null;
|
||||
try {
|
||||
next = await this.fetchItems(metadata, cacheMode);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.setError(error);
|
||||
// Attribute the failure to the response it came from, so the Protocol
|
||||
// entry stops rendering a rejected result as a clean success (#1953).
|
||||
// Must happen in this catch, while the correlation window the client
|
||||
// documents is still valid.
|
||||
this.client?.markResponseRejected?.(
|
||||
this.config.listMethod,
|
||||
error.message,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
this.setError(null);
|
||||
// `null` means not connected — leave the current list untouched.
|
||||
if (next === null) return this.getItems();
|
||||
this.applyItems(next);
|
||||
|
|
|
|||
|
|
@ -8,17 +8,11 @@ import type { Prompt } from "@modelcontextprotocol/client";
|
|||
import {
|
||||
ManagedListState,
|
||||
DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
type ManagedListEventMap,
|
||||
} from "./managedListState.js";
|
||||
|
||||
export interface ManagedPromptsStateEventMap {
|
||||
export interface ManagedPromptsStateEventMap extends ManagedListEventMap {
|
||||
promptsChange: Prompt[];
|
||||
/**
|
||||
* Fires when the "list changed since last refresh" flag flips. True when a
|
||||
* `prompts/list_changed` arrives (auto-refresh off), false once the user
|
||||
* refreshes or the connection drops. Drives the sidebar list-changed
|
||||
* indicator (#1402).
|
||||
*/
|
||||
listChangedChange: boolean;
|
||||
}
|
||||
|
||||
export class ManagedPromptsState extends ManagedListState<
|
||||
|
|
@ -30,6 +24,7 @@ export class ManagedPromptsState extends ManagedListState<
|
|||
debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
) {
|
||||
super(client, {
|
||||
listMethod: "prompts/list",
|
||||
changeEvent: "promptsChange",
|
||||
listChangedEvent: "promptsListChanged",
|
||||
capabilityKey: "prompts",
|
||||
|
|
|
|||
|
|
@ -15,15 +15,11 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot
|
|||
import {
|
||||
ManagedListState,
|
||||
DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
type ManagedListEventMap,
|
||||
} from "./managedListState.js";
|
||||
|
||||
export interface ManagedResourceTemplatesStateEventMap {
|
||||
export interface ManagedResourceTemplatesStateEventMap extends ManagedListEventMap {
|
||||
resourceTemplatesChange: ResourceTemplate[];
|
||||
/**
|
||||
* Carried only to satisfy the ManagedListState base; templates have no
|
||||
* indicator, so this never fires.
|
||||
*/
|
||||
listChangedChange: boolean;
|
||||
}
|
||||
|
||||
export class ManagedResourceTemplatesState extends ManagedListState<
|
||||
|
|
@ -35,6 +31,7 @@ export class ManagedResourceTemplatesState extends ManagedListState<
|
|||
debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
) {
|
||||
super(client, {
|
||||
listMethod: "resources/templates/list",
|
||||
changeEvent: "resourceTemplatesChange",
|
||||
listChangedEvent: "resourceTemplatesListChanged",
|
||||
// Templates are gated on the broader `resources` capability.
|
||||
|
|
|
|||
|
|
@ -8,17 +8,11 @@ import type { Resource } from "@modelcontextprotocol/client";
|
|||
import {
|
||||
ManagedListState,
|
||||
DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
type ManagedListEventMap,
|
||||
} from "./managedListState.js";
|
||||
|
||||
export interface ManagedResourcesStateEventMap {
|
||||
export interface ManagedResourcesStateEventMap extends ManagedListEventMap {
|
||||
resourcesChange: Resource[];
|
||||
/**
|
||||
* Fires when the "list changed since last refresh" flag flips. True when a
|
||||
* `resources/list_changed` arrives (auto-refresh off), false once the user
|
||||
* refreshes or the connection drops. Drives the list-changed indicator
|
||||
* (#1402).
|
||||
*/
|
||||
listChangedChange: boolean;
|
||||
}
|
||||
|
||||
export class ManagedResourcesState extends ManagedListState<
|
||||
|
|
@ -30,6 +24,7 @@ export class ManagedResourcesState extends ManagedListState<
|
|||
debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
) {
|
||||
super(client, {
|
||||
listMethod: "resources/list",
|
||||
changeEvent: "resourcesChange",
|
||||
listChangedEvent: "resourcesListChanged",
|
||||
capabilityKey: "resources",
|
||||
|
|
|
|||
|
|
@ -8,17 +8,11 @@ import type { Tool } from "@modelcontextprotocol/client";
|
|||
import {
|
||||
ManagedListState,
|
||||
DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
type ManagedListEventMap,
|
||||
} from "./managedListState.js";
|
||||
|
||||
export interface ManagedToolsStateEventMap {
|
||||
export interface ManagedToolsStateEventMap extends ManagedListEventMap {
|
||||
toolsChange: Tool[];
|
||||
/**
|
||||
* Fires when the "list changed since last refresh" flag flips. True when a
|
||||
* `tools/list_changed` arrives (auto-refresh off), false once the user
|
||||
* refreshes or the connection drops. Drives the sidebar list-changed
|
||||
* indicator (#1402).
|
||||
*/
|
||||
listChangedChange: boolean;
|
||||
}
|
||||
|
||||
export class ManagedToolsState extends ManagedListState<
|
||||
|
|
@ -30,6 +24,7 @@ export class ManagedToolsState extends ManagedListState<
|
|||
debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS,
|
||||
) {
|
||||
super(client, {
|
||||
listMethod: "tools/list",
|
||||
changeEvent: "toolsChange",
|
||||
listChangedEvent: "toolsListChanged",
|
||||
capabilityKey: "tools",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,31 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> {
|
|||
pushEntry(entry);
|
||||
};
|
||||
|
||||
// A response the client rejected after the fact (the wire frame was valid
|
||||
// JSON-RPC, but the SDK codec refused the result). Annotate the entry that
|
||||
// carries that JSON-RPC id so it stops rendering as a clean success — the
|
||||
// request entry when the response was folded into it, otherwise the
|
||||
// standalone response entry. Searched newest-first: ids are unique within
|
||||
// a session, and the failing exchange is by construction a recent one.
|
||||
const onResponseRejected = (
|
||||
event: TypedEventGeneric<InspectorClientEventMap, "responseRejected">,
|
||||
): void => {
|
||||
const { id, reason } = event.detail;
|
||||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||||
const entry = this.messages[i]!;
|
||||
const entryId = (entry.message as { id?: string | number }).id;
|
||||
if (entryId !== id) continue;
|
||||
if (entry.direction === "notification") continue;
|
||||
// A request entry with no response yet isn't the one being rejected —
|
||||
// keep looking for the standalone response frame.
|
||||
if (entry.direction === "request" && !entry.response) continue;
|
||||
entry.clientError = reason;
|
||||
this.dispatchTypedEvent("message", entry);
|
||||
this.dispatchTypedEvent("messagesChange", this.getMessages());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.messages = [];
|
||||
|
|
@ -106,10 +131,12 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> {
|
|||
}
|
||||
};
|
||||
this.client.addEventListener("message", onMessage);
|
||||
this.client.addEventListener("responseRejected", onResponseRejected);
|
||||
this.client.addEventListener("statusChange", onStatusChange);
|
||||
this.unsubscribe = () => {
|
||||
if (this.client) {
|
||||
this.client.removeEventListener("message", onMessage);
|
||||
this.client.removeEventListener("responseRejected", onResponseRejected);
|
||||
this.client.removeEventListener("statusChange", onStatusChange);
|
||||
}
|
||||
this.client = null;
|
||||
|
|
|
|||
|
|
@ -285,6 +285,14 @@ export interface MessageEntry {
|
|||
| JSONRPCErrorResponse;
|
||||
response?: JSONRPCResultResponse | JSONRPCErrorResponse;
|
||||
duration?: number; // Time between request and response in ms
|
||||
/**
|
||||
* Why the CLIENT rejected an otherwise well-formed response — e.g. the SDK's
|
||||
* era codec refusing a 2026-07-28 `tools/list` result that omits
|
||||
* `ttlMs`/`cacheScope`. Distinct from a JSON-RPC `error` response: the server
|
||||
* answered successfully and the wire frame is valid, so without this the
|
||||
* entry renders as a clean success even though the call failed (#1953).
|
||||
*/
|
||||
clientError?: string;
|
||||
}
|
||||
|
||||
/** Method name for any MessageEntry traffic, plus synthetic "response" for result/error entries. */
|
||||
|
|
|
|||
57
core/react/useManagedListError.ts
Normal file
57
core/react/useManagedListError.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { ManagedListEventMap } from "../mcp/state/managedListState.js";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
|
||||
/**
|
||||
* The slice of a managed list state this hook needs. Declared structurally
|
||||
* rather than as `ManagedListState<T, M>` so the four list hooks can share it
|
||||
* without threading their item type through — the error is the same shape for
|
||||
* all of them.
|
||||
*/
|
||||
export interface ManagedListErrorSource {
|
||||
getError(): Error | null;
|
||||
addEventListener(
|
||||
type: "errorChange",
|
||||
listener: (
|
||||
event: TypedEventGeneric<ManagedListEventMap, "errorChange">,
|
||||
) => void,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "errorChange",
|
||||
listener: (
|
||||
event: TypedEventGeneric<ManagedListEventMap, "errorChange">,
|
||||
) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a managed list state's last-fetch error (#1953).
|
||||
*
|
||||
* Shared by the four `useManaged*` hooks so a list load that fails — including
|
||||
* the connect-time one, which has no caller to await it — reaches the UI
|
||||
* instead of only the console. `null` means the last fetch succeeded.
|
||||
*/
|
||||
export function useManagedListError(
|
||||
state: ManagedListErrorSource | null,
|
||||
): Error | null {
|
||||
const [error, setError] = useState<Error | null>(state?.getError() ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setError(state.getError());
|
||||
const onErrorChange = (
|
||||
event: TypedEventGeneric<ManagedListEventMap, "errorChange">,
|
||||
) => {
|
||||
setError(event.detail);
|
||||
};
|
||||
state.addEventListener("errorChange", onErrorChange);
|
||||
return () => {
|
||||
state.removeEventListener("errorChange", onErrorChange);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return error;
|
||||
}
|
||||
|
|
@ -6,8 +6,15 @@ import type {
|
|||
} from "../mcp/state/managedPromptsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
|
||||
export interface UseManagedPromptsResult {
|
||||
/**
|
||||
* The last fetch's failure (transport error, or a result the SDK codec
|
||||
* rejected), or `null` when it succeeded. Includes the connect-time load,
|
||||
* whose failure has no caller to surface it (#1953).
|
||||
*/
|
||||
error: Error | null;
|
||||
prompts: Prompt[];
|
||||
/** True when a `prompts/list_changed` arrived since the last user refresh. */
|
||||
listChanged: boolean;
|
||||
|
|
@ -65,6 +72,8 @@ export function useManagedPrompts(
|
|||
};
|
||||
}, [managedPromptsState]);
|
||||
|
||||
const error = useManagedListError(managedPromptsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
||||
if (!managedPromptsState || !client) return [];
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
|
|
@ -85,5 +94,5 @@ export function useManagedPrompts(
|
|||
managedPromptsState?.clearListChanged();
|
||||
}, [managedPromptsState]);
|
||||
|
||||
return { prompts, listChanged, refresh, clearListChanged };
|
||||
return { prompts, error, listChanged, refresh, clearListChanged };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ import type {
|
|||
} from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
|
||||
export interface UseManagedResourceTemplatesResult {
|
||||
/**
|
||||
* The last fetch's failure (transport error, or a result the SDK codec
|
||||
* rejected), or `null` when it succeeded. Includes the connect-time load,
|
||||
* whose failure has no caller to surface it (#1953).
|
||||
*/
|
||||
error: Error | null;
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
refresh: () => Promise<ResourceTemplate[]>;
|
||||
}
|
||||
|
|
@ -50,6 +57,8 @@ export function useManagedResourceTemplates(
|
|||
};
|
||||
}, [managedResourceTemplatesState]);
|
||||
|
||||
const error = useManagedListError(managedResourceTemplatesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
|
||||
if (!managedResourceTemplatesState || !client) return [];
|
||||
// A user-initiated refresh forces a cache-bypassing round trip
|
||||
|
|
@ -63,5 +72,5 @@ export function useManagedResourceTemplates(
|
|||
return next;
|
||||
}, [client, managedResourceTemplatesState]);
|
||||
|
||||
return { resourceTemplates, refresh };
|
||||
return { resourceTemplates, error, refresh };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ import type {
|
|||
} from "../mcp/state/managedResourcesState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
|
||||
export interface UseManagedResourcesResult {
|
||||
/**
|
||||
* The last fetch's failure (transport error, or a result the SDK codec
|
||||
* rejected), or `null` when it succeeded. Includes the connect-time load,
|
||||
* whose failure has no caller to surface it (#1953).
|
||||
*/
|
||||
error: Error | null;
|
||||
resources: Resource[];
|
||||
/**
|
||||
* True when a `resources/list_changed` arrived since the last user refresh.
|
||||
|
|
@ -76,6 +83,8 @@ export function useManagedResources(
|
|||
};
|
||||
}, [managedResourcesState]);
|
||||
|
||||
const error = useManagedListError(managedResourcesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Resource[]> => {
|
||||
if (!managedResourcesState || !client) return [];
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
|
|
@ -96,5 +105,5 @@ export function useManagedResources(
|
|||
managedResourcesState?.clearListChanged();
|
||||
}, [managedResourcesState]);
|
||||
|
||||
return { resources, listChanged, refresh, clearListChanged };
|
||||
return { resources, error, listChanged, refresh, clearListChanged };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,15 @@ 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 { useManagedListError } from "./useManagedListError.js";
|
||||
|
||||
export interface UseManagedToolsResult {
|
||||
/**
|
||||
* The last fetch's failure (transport error, or a result the SDK codec
|
||||
* rejected), or `null` when it succeeded. Includes the connect-time load,
|
||||
* whose failure has no caller to surface it (#1953).
|
||||
*/
|
||||
error: Error | null;
|
||||
tools: Tool[];
|
||||
/** True when a `tools/list_changed` arrived since the last user refresh. */
|
||||
listChanged: boolean;
|
||||
|
|
@ -65,6 +72,8 @@ export function useManagedTools(
|
|||
};
|
||||
}, [managedToolsState]);
|
||||
|
||||
const error = useManagedListError(managedToolsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Tool[]> => {
|
||||
if (!managedToolsState || !client) return [];
|
||||
// A user-initiated refresh acknowledges the change — clear the indicator
|
||||
|
|
@ -85,5 +94,5 @@ export function useManagedTools(
|
|||
managedToolsState?.clearListChanged();
|
||||
}, [managedToolsState]);
|
||||
|
||||
return { tools, listChanged, refresh, clearListChanged };
|
||||
return { tools, error, listChanged, refresh, clearListChanged };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue