fix: surface list-fetch failures instead of swallowing them - #1954
Conversation
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
There was a problem hiding this comment.
Pull request overview
This PR fixes a UX/correctness gap where connect-time (and other unawaited) list loads could fail silently: the sidebar looked like an empty-but-valid list, and the Protocol entry looked successful even when the SDK rejected the response payload.
Changes:
- Add observable “last list-load error” state to
ManagedListState(plus a shared React hook) so list failures surface to the UI while still re-throwing for awaited callers. - Introduce
responseRejectedplumbing to attribute SDK decode/validation rejections back onto the correct Protocol entry as a client-side error. - Render list-load failures in the Tools/Prompts/Resources sidebars via a new
ListLoadErrorelement with a Retry action; log excluded-tools walk failures instead of dropping them.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| core/react/useManagedTools.ts | Exposes managed tools list-load error via useManagedListError. |
| core/react/useManagedResourceTemplates.ts | Exposes templates list-load error via useManagedListError. |
| core/react/useManagedResources.ts | Exposes resources list-load error via useManagedListError. |
| core/react/useManagedPrompts.ts | Exposes prompts list-load error via useManagedListError. |
| core/react/useManagedListError.ts | New shared hook subscribing to errorChange from managed list states. |
| core/mcp/types.ts | Adds MessageEntry.clientError to represent client-side rejection reasons. |
| core/mcp/state/messageLogState.ts | Listens for responseRejected to annotate the matching message entry with clientError. |
| core/mcp/state/managedToolsState.ts | Pins listMethod: "tools/list" and inherits ManagedListEventMap. |
| core/mcp/state/managedResourceTemplatesState.ts | Pins listMethod: "resources/templates/list" and inherits ManagedListEventMap. |
| core/mcp/state/managedResourcesState.ts | Pins listMethod: "resources/list" and inherits ManagedListEventMap. |
| core/mcp/state/managedPromptsState.ts | Pins listMethod: "prompts/list" and inherits ManagedListEventMap. |
| core/mcp/state/managedListState.ts | Adds error state + errorChange; catches unawaited refresh rejections; marks rejected Protocol responses. |
| core/mcp/inspectorClientProtocol.ts | Adds optional markResponseRejected() API to the protocol interface. |
| core/mcp/inspectorClientEventTarget.ts | Adds responseRejected event to propagate rejection reason + correlated id. |
| core/mcp/inspectorClient.ts | Tracks last-answered id per method and emits responseRejected; logs excluded-tools refresh failures. |
| core/mcp/tests/fakeInspectorClient.ts | Adds markResponseRejected spy for managed-state tests. |
| clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts | Integration coverage for id correlation and clientError attribution on real stdio connection. |
| clients/web/src/test/core/react/useManagedListError.test.tsx | Unit coverage for the shared error subscription hook. |
| clients/web/src/test/core/mcp/state/messageLogState.test.ts | Tests responseRejected annotates the right entry and ignores invalid targets. |
| clients/web/src/test/core/mcp/state/managedToolsState.test.ts | Tests error lifecycle: record + rethrow, dispatch, clear, connect-time + auto-refresh handling. |
| clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts | Pins method string + attribution behavior for templates. |
| clients/web/src/test/core/mcp/state/managedResourcesState.test.ts | Pins method string + attribution behavior for resources. |
| clients/web/src/test/core/mcp/state/managedPromptsState.test.ts | Pins method string + attribution behavior for prompts. |
| clients/web/src/components/views/InspectorView/InspectorView.tsx | Wires list-load error props through to screens. |
| clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx | Threads loadError to sidebar controls. |
| clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx | Threads loadError to sidebar controls. |
| clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx | Threads loadError to sidebar controls. |
| clients/web/src/components/groups/ToolControls/ToolControls.tsx | Renders ListLoadError above the tools sidebar list. |
| clients/web/src/components/groups/ToolControls/ToolControls.test.tsx | Adds coverage for sidebar error rendering + Retry wiring. |
| clients/web/src/components/groups/ResourceControls/ResourceControls.tsx | Renders ListLoadError above the resources sidebar list. |
| clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx | Adds coverage for sidebar error rendering + Retry wiring. |
| clients/web/src/components/groups/PromptControls/PromptControls.tsx | Renders ListLoadError above the prompts sidebar list. |
| clients/web/src/components/groups/PromptControls/PromptControls.test.tsx | Adds coverage for sidebar error rendering + Retry wiring. |
| clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx | Renders client-rejected results as Error and shows “Rejected by the Inspector” detail. |
| clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx | Adds coverage for rendering a client-rejected response. |
| clients/web/src/components/elements/ListLoadError/ListLoadError.tsx | New element: “couldn’t load” alert + capped diagnostic + Retry button. |
| clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx | Unit tests for ListLoadError. |
| clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx | Storybook stories for common error scenarios. |
| clients/web/src/App.tsx | Plumbs error out of managed hooks into InspectorView. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
extractStatus returned none for any non-request entry, so a clientError annotation that landed on a standalone response frame — the fallback messageLogState uses when no request entry was there to fold into, e.g. a trimmed log or a reconnect boundary — rendered with no status badge at all. The clientError check now runs before the request-only lifecycle. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/mcp/state/managedListState.ts:317
ManagedListState.refresh()callsthis.client?.markResponseRejected?.(...)for any failure fromfetchItems()(transport failures, timeouts, server JSON-RPC errors, etc.), butmarkResponseRejectedis documented/implemented as “a response the client rejected after the fact” (i.e., a wire response existed and was logged). For failures that occur without a corresponding response frame, this can misattribute the error to the last successful response for the same method and render a misleading “Rejected by the Inspector” Protocol entry.
Consider only calling markResponseRejected when the thrown error is specifically an SDK decode/validation rejection (and otherwise skipping it), or moving the rejection marking into the layer that actually observes the decode failure + the correlated id.
} 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,
);
markResponseRejected was called for ANY refresh failure. The id it recovers is "the last response received for this method", which is the failing exchange only when a response actually arrived and was refused while decoding. A transport drop, a timeout, or an abort produces no response frame at all — the last-answered id then still points at an EARLIER successful call, so marking it stamped "Rejected by the Inspector" onto an exchange that worked. That is the same class of lie this issue exists to remove. A server-sent JSON-RPC error is excluded for a different reason: the id would be right, but the failure is the server's and its entry already renders as an error from the error frame. Gated on SdkErrorCode.InvalidResult / UnsupportedResultType — decisions the client made about a frame in hand. The failure is still recorded as list state either way; only the Protocol attribution is gated. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
|
Picking up the suppressed comment on It's right, and it's the same bug this PR exists to remove, just pointed the other way. The correlation recovers the id as "the last response received for this method", which is the failing exchange only when a response actually arrived and was refused while decoding. On a transport drop, a timeout, or an abort, no response frame arrives at all — so The call is now gated: function isClientDecodeRejection(err: unknown): boolean {
return (
SdkError.isInstance(err) &&
(err.code === SdkErrorCode.InvalidResult ||
err.code === SdkErrorCode.UnsupportedResultType)
);
}Both codes mean "a result arrived and the client refused it" — a decision made about a frame in hand. I took the first of your two options; the second (moving the marking into the layer that observes the decode failure with the id) is the better design, but that layer is inside the SDK's Two things worth noting: A server-sent JSON-RPC error is excluded too, for a different reason than transport failures. There the id would be correct — the error frame is a real response and does update the correlation map — but the failure is the server's, and the entry already renders as an error from the error frame itself. Labeling it "Rejected by the Inspector" would misattribute the cause. Different reasoning, same exclusion, and the guard's doc comment separates the two so a future reader doesn't collapse them. The failure is still recorded as list state regardless. Only the Protocol attribution is gated — a transport drop still shows "Couldn't load tools" with the reason and a Retry in the sidebar. There's an explicit test for that ( Tests added: Also re-verified live against the repro (modern server behind a proxy stripping
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/react/useManagedListError.ts:57
useManagedListErrorre-syncs local React state from thestateprop inside auseEffect(setError(state.getError())andsetError(null)), which can briefly render the previous state's error afterstatechanges (e.g. when switching servers in the TUI) until the effect runs. This pattern is also explicitly discouraged in this repo’s React guidelines (state derived from props shouldn’t be re-synced in an effect).
core/mcp/inspectorClient.ts:367- The new
outboundRequestMethods/lastAnsweredRequestByMethodcorrelation maps are never cleared on disconnect or transport teardown. If a session ends with in-flight requests (timeouts, connection drops),outboundRequestMethodscan retain abandoned ids and grow across reconnects; clearing both maps on disconnect/onclose would avoid a small memory leak and remove any possibility of stale correlation data persisting across sessions.
// 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>();
…maps Two fixes from Copilot's review of #1953. useManagedListError re-synced React state from the `state` prop inside a useEffect, which renders one frame carrying the PREVIOUS store's error after `state` changes (switching servers) before the effect corrects it — the pattern AGENTS.md forbids. useSyncExternalStore reads the snapshot during render, so a store swap lands in the same frame, and it also closes the window where an error recorded between render and subscribe was missed. The snapshot returns the stored Error instance (or null), so it is referentially stable as the API requires. The `markResponseRejected` correlation maps were never cleared. Ids of requests that never got a response — a timeout, a dropped connection — are the only entries trackResponse can't remove, so they accumulated across reconnects. Cleared on the start-clean connect path, per the convention documented on resetSessionState (one route out, `onerror` with no `onclose`, tears down nothing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
|
Both suppressed comments this round were valid — surfacing them here since they don't appear inline. Fixed in a6f233e. 1.
|
|
Filed the sibling-hook cleanup as #1955 (the other |
Closes #1953
A list load that failed reported nothing.
ManagedListStatefires the connect-time refresh with no caller to await it (void this.refresh()), so a rejection became an unhandled rejection — the header said Connected, the panel rendered an empty list indistinguishable from a server that legitimately has none, and the failed exchange appeared in the Protocol panel as a clean success. The only trace was a console error.That is the bug misreported in #1893 as "replay validates more strictly than the initial call": the two are validated identically, but only the replay path surfaced the error (as a toast).
What changed
The panel says what happened.
ManagedListStaterecords the failure as observable state (getError()+ anerrorChangeevent) and still re-throws — the rejection is what App's auth-recovery wrapper keys off to detect a 401 and start a re-authorization, so swallowing it would have broken OAuth step-up. The callers with nobody to await them (connect-time load,list_changedauto-refresh) catch it explicitly now that the state holds it. The fouruseManaged*hooks expose it through a shareduseManagedListError, and the Tools/Prompts/Resources sidebars render a newListLoadErrorelement with the reason and a Retry.The Protocol entry stops lying. A response the client refused is attributed back to its entry —
markResponseRejected→responseRejected→MessageEntry.clientError— which now renders an Error status and a "Rejected by the Inspector" alert in the expanded detail, instead of the greenresultTypebadge alone (that badge was suppressing the status badge, which is why the failed call looked green).The SDK gives no request id with a decode failure —
SdkErrorcarries the method and nothing else — so the id is recovered by correlation with the last response received for that method. That's exact rather than approximate: the SDK rejects synchronously while decoding, inside the transport'sonmessage, and the caller'scatchruns 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. The reasoning is on the method.No more floor-dropping in
listAllTools. The excluded-tools walk stays non-fatal but is now logged instead of.catch(() => {}), so a failing walk doesn't leave the "Excluded (SEP-2243)" section silently empty.Not changed, deliberately
The strictness itself is correct — the 2026-07-28 normative schema declares
ttlMs: numberandcacheScope: "public" | "private"as required onListToolsResultand its siblings, so the SDK is conformant and nothing here loosens it. (The spec's caching page contradicts its own schema by telling clients to tolerate an absentttlMs; that belongs upstream.)Verification
Reproduced with the repo's modern test server behind a proxy that strips
ttlMs/cacheScopefrom*/listresults, connected withprotocolEra: "modern".Before — "Connected", empty Tools panel, Protocol entry showing a clean
COMPLETEround-trip:After — the panel names the failure with the reason and a Retry; the Protocol entry carries an
ERRORbadge alongside the wire'sCOMPLETE, and expands to "Rejected by the Inspector":Retry was exercised live: with the proxy switched back to pass-through, clicking Retry clears the alert and loads the tools.
Tests
managedToolsState— 10 cases covering the error lifecycle: recorded and re-thrown,errorChangedispatch, non-Errorrejection wrapping, cleared on success, cleared on disconnect, no re-dispatch for the same error, and the two unawaited paths (connect, auto-refresh) landing in state rather than rejecting unobserved.listMethodstring, which is what attributes a failure to the right entry.useManagedListError— 7 cases (seed, update, clear, null state, unsubscribe).ListLoadError— 6 cases; plus 4 stories.messageLogState— 5 cases forresponseRejected, including the standalone-response and reused-id paths.inspectorClient-response-rejected.test.ts— a new integration file driving a real stdio connection so the id correlation runs against real SDK-assigned ids: latest-response attribution, no cross-method bleed, no-op for an unanswered method.ProtocolEntry— 4 cases for the rejected-response rendering.npm run cipasses:validate→coverage(per-file ≥90 held) →verify:build-gate→smoke(incl.smoke:web:app) → Storybook (110 files, 466 tests).🤖 Generated with Claude Code
https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt