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:
cliffhall 2026-08-08 16:48:48 -04:00
commit f201032137
16 changed files with 1080 additions and 48 deletions

View file

@ -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

View file

@ -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;

View file

@ -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. */

View file

@ -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' |

View file

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

View file

@ -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",

View file

@ -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.

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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. */

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

View file

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

View file

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

View file

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

View file

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