Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).
Closes#1246.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): trace dropped fetch body update when entry rotates out (#1390)
FetchRequestLogState.onFetchRequestBodyUpdate silently dropped a deferred
response-body update when the matching entry had already been evicted by
log rotation (>= maxFetchRequests newer requests appended between the
append and the fire-and-forget body read). The user saw an empty Response
Body with no way to distinguish "body never came back" from "body dropped
after eviction".
Plumb an optional pino logger into FetchRequestLogState (defaulting to the
silent logger) and emit a logger.debug at the early-return site so the drop
is traceable. The web client wires the existing remote logger from
createWebEnvironment into the state manager.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): emit dropped-body trace at warn level so it is observable (#1390)
Review feedback: the web remote logger (createRemoteLogger) defaults to
level 'info' with no override at the environmentFactory call site, so a
logger.debug at the drop site is filtered out by both the pino level gate
and transmit.level — the diagnostic never reaches /api/log and the drop
stays invisible in the shipped web app, defeating the issue's goal.
Promote the trace to logger.warn (>= info, so it transmits by default). A
response body lost to log rotation is unexpected data loss, not routine
diagnostic noise, so warn is the appropriate severity. Update the comment
to explain the level choice and switch the test stub from debug to warn,
extracting a small typed makeWarnLogger helper per review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): configurable Network Log Size per server + body-dropped toast (#1390)
Turn the otherwise-invisible "response body dropped after rotation" case into
an actionable signal:
1. Per-server `maxFetchRequests` setting (default 1000, 0 = unlimited),
editable as "Network Log Size" in the first (Options) accordion section of
the Server Settings form. Plumbed through the full settings pipeline:
InspectorServerSettings + StoredMCPServer types, the serverList disk
converters (read defaults to 1000, write omits the default to keep diffs
minimal — 0 is a meaningful non-default), and the node server's GET-sanitize
+ write-side validateSettings (non-negative number). Applied live to the
active connection on settings-modal close via a new
FetchRequestLogState.setMaxFetchRequests (shrinking trims immediately), and
at connect time for the next session.
2. FetchRequestLogState now emits a `fetchRequestBodyDropped` event, but only
for genuine rotation drops (log at capacity with a finite cap) — not for
benign post-clear stragglers or unlimited logs, which previously also hit
the warn path. App shows a deduped, persistent toast (stable per-server id,
no auto-close) explaining what happened and the likely causes, with a link
that opens this server's settings on the Options section (modal remounts via
key) so the user can raise the limit if it's just a high-traffic server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): don't treat a cleared Network Log Size field as unlimited (#1390)
Review feedback: clearing the NumberInput emits "", and `parseInt("") || 0`
coerced that to 0 (unlimited). Since the value applies live on modal close, a
user who cleared the field to retype and then closed the modal would silently
switch the log to unlimited. Fall back to the current value on an empty/NaN
parse and reserve 0 for an explicit numeric entry. Adds a cleared-input test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): hide resource subscription UI when server lacks subscribe capability (#1478)
Thread a `subscriptionsSupported` flag derived from
`capabilities?.resources?.subscribe === true` from App through
InspectorView → ResourcesScreen to the leaf components. When false:
- ResourcePreviewPanel hides the Subscribe/Unsubscribe button (Refresh
stays).
- ResourceControls omits the Subscriptions accordion section entirely,
dropping it from `allSections` so the expand/collapse-all toggle and
open-section bookkeeping stay correct even if a stale "subscriptions"
entry persists from a prior subscription-capable session.
The flag defaults to true at the leaf components so existing callers,
tests, and stories keep rendering the subscription controls unless a
server is explicitly marked unsupported.
Adds unit tests and Storybook stories covering the hidden and shown
states across all three components.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): give the Servers screen scroll the same gutter/idle-hidden treatment (#1474)
The server grid's ScrollArea.Autosize used the default type="hover" with no
offsetScrollbars, so the scrollbar popped in on hover and overlaid the right
edge of the server cards (occluding their action icons / status badges) — the
same problem #1474 fixed for the History/Network/Logging list panels.
Apply the same `type="scroll"` + `offsetScrollbars` treatment, and reuse the
shared `expectScrollbarGutterIdleHidden` story assertion to regression-protect
the gutter + idle-hidden behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): stop the InspectorView shell from scrolling as a whole
AppShell.Main carried Mantine's default `padding="md"` on top of the
fixed-header offset, but every screen already sizes itself to
`calc(100vh - header)` and supplies its own `xl` padding (with no
allowance for Main's padding). That extra inset pushed screen content
past the viewport, so the whole InspectorView gained a page-level
scrollbar instead of confining overflow to each screen's inner
ScrollArea.
Set the shell `padding` to 0 (Main now contributes only the header
offset, matching what the screens were built for) and clamp the Main
slot to `height: 100dvh` with `overflow: hidden` in the theme, so the
view can never scroll regardless of content — only the inner ScrollArea
regions do.
Adds an InspectorView story play assertion guarding the invariant
(Main clips + is viewport-clamped) and a ManyServers fixture that
confirms it holds under enough content to overflow, with the inner
server-grid ScrollArea absorbing the overflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): address review — align viewport units + drop stale subscriptions intent
Responding to the @claude review on #1479:
- Align all screen height calcs from `100vh` to `100dvh` so they match the
Main slot's `100dvh` clamp. On a mobile viewport where `dvh < vh` a `100vh`
child would have exceeded Main's `100dvh` content box and been clipped by
`overflow: hidden`; the units are now in lockstep.
- Restrict `intendedButEmptySections` in ResourceControls to `allSections` so a
stale "subscriptions" entry persisted from a prior subscription-capable
session is dropped from persisted open-sections instead of being perpetually
re-appended once the section is no longer rendered. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): give History/Network/Logging list scrollbars a gutter, hide when idle (#1474)
The three main-content list panels rendered their ScrollArea.Autosize with
the default type="hover" and no offsetScrollbars, so the scrollbar popped in
on hover and overlaid the right edge of the cards (occluding the action
icons / status badges).
Add `offsetScrollbars` (reserves a 12px gutter so the bar never overlaps the
cards) and `type="scroll"` (the bar shows only during active scrolling, then
fades — no hover-pop) to HistoryListPanel, NetworkStreamPanel, and
LogStreamPanel. Verified in a real browser; adds a story assertion guarding
the gutter + idle-hidden behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): assert scrollbar gutter on all three list panels (#1475 review)
Per review, regression-protect the gutter + idle-hidden scrollbar in every
panel, not just History. Extract the assertion into a shared
`expectScrollbarGutterIdleHidden` helper and use it from the History,
Network, and Logging panel stories.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): assert a scrollbar exists so idle-hidden check isn't vacuous (#1475 review)
Add `expect(scrollbars.length).toBeGreaterThan(0)` before the loop so the
idle-hidden assertion is meaningful rather than trivially-true if a future
change stops rendering the scrollbar element.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): style Resources accordion native scrollbars to match Mantine (#1466)
The per-section accordion panels (from #1462) scroll with a native
scrollbar, which looked inconsistent with the Mantine ScrollArea used
elsewhere. Style it to match: thin, rounded, translucent thumb on a
transparent track, via a new `--inspector-scrollbar-thumb` token
(rgba(0,0,0,.4) light / rgba(255,255,255,.4) dark, mirroring Mantine's
thumb), `scrollbar-width: thin` + `scrollbar-color` (Firefox) and
`::-webkit-scrollbar` rules (Chromium/WebKit).
A Mantine ScrollArea *component* can't be used here: its viewport has no
intrinsic height, which would collapse the panel's content-based
flex-basis that #1462's per-section sizing depends on (the sections would
stop sizing to their content). Documented inline. Verified in a real
browser that the per-section sizing/pinned-headers behavior is unchanged
and the scrollbar is now thin + themed in both color schemes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): assert themed scrollbar-color on the accordion panel (#1473 review)
Guard the per-section scrollbar's themed thumb against regressions, per
review — `scrollbar-color` resolves to an rgba value (the
--inspector-scrollbar-thumb token) vs a transparent track. WebKit
pseudo-element styles aren't queryable, so this is the assertable proxy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): ResourceControls single-scroll + right/down disclosure chevrons (#1462)
Two fixes to the Resources controls panel:
1. Sections scrolled prematurely. `panelMaxHeight` divided the available
height equally across open sections (`… / n`), so a section with many
items scrolled while sparse siblings left their equal share unused.
Replace the per-section ScrollAreas with a single viewport-bounded
ScrollArea.Autosize around the whole accordion (matching ToolControls /
PromptControls). Sections size to content; nothing scrolls until the
open sections' combined height exceeds the cap.
2. Accordion chevrons pointed down/up. Add a `disclosure` Accordion theme
variant + `.disclosure-chevron[data-rotate]` rule that rotates a
right-pointing RiArrowRightSLine 90° (not Mantine's default 180°), so
closed sections point right and open sections point down — matching the
ResourceLink chevron convention.
Also keep empty (disabled) sections out of the accordion's open set so
they render collapsed with a right chevron instead of an open-but-empty
panel. Verified all states in a real browser via Playwright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): cover empty-section-collapsed behavior in ResourceControls (#1462)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): make Resources sidebar fill panel height before scrolling (#1462)
The first pass capped the accordion scroll region at a fixed sub-viewport
height (`100vh - … - 160px`), so the sidebar scrolled before reaching the
height of the detail panel to its right.
Make the sidebar card full-height instead: add a `sidebar` Card variant
(height:100%, flex column, overflow hidden), give it `flex: 1`, and let
ResourceControls' root Stack (`flex: 1; min-height: 0`) and a plain
flex-filling ScrollArea claim the space below the title/search. The card
now matches the detail panel's height exactly and the single inner scroll
region scrolls only once the open sections overflow the full panel.
Adds a ResourcesScreen ManyResources story asserting the sidebar/detail
height match and that overflow scrolls, plus a unit test for the
empty-section-collapsed behavior. Verified heights + scroll in a real
browser via Playwright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): pin Resources section headers, scroll each section in place (#1462)
The previous pass scrolled the whole accordion as one block (headers
scrolled away). Restore pinned headers with per-section scrolling, without
the original equal-division premature-scroll bug.
The `disclosure` Accordion now fills its full-height card as a flex column
(`disclosure-sections`): controls are pinned, and each open section's panel
scrolls within its own share of the remaining height. Per-item `flex`
weights (shrink ∝ item count, grow 0) hand space from long sections to
short ones, so nothing scrolls until the combined content overflows the
panel — a long URIs list scrolls in place while Templates stays fully
visible below it. Drops the single outer ScrollArea (and useScrollMemory).
Verified in a real browser: 6 URIs → no scroll, headers pinned, empty
space at the bottom; 40 URIs → URIs panel scrolls in place, Templates
intact, all three headers pinned, sidebar still matches the detail height.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): Resources sidebar grows with content, capped at screen (#1462)
The `sidebar` Card variant forced `height: 100%`, so the panel was always
full-height with empty space below short content. Switch to
`max-height: 100%` (and drop `flex: 1`) so the card hugs its content and
only caps at the screen height — matching the Tools panel. When content
overflows the cap, the flex accordion still takes over per-section
scrolling with pinned headers.
Verified in a real browser: 6 URIs → card hugs (638px, shorter than the
detail panel); 40 URIs → card caps at the screen (949px == detail),
headers pinned, URIs scrolls in place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): preserve empty-section intent + kill accordion collapse jump (#1464 review)
Address review feedback on PR #1464:
- Intent loss (review #1): the accordion's `value` is the filtered
`visibleOpenSections`, so Mantine's toggle dropped open-but-empty
sections from the persisted intent. Merge them back in the onChange
handler so an open empty section stays open and reappears with items.
Adds a unit test.
- Collapse animation jump (review #3): Mantine's Collapse briefly set the
panel to its full natural height (fighting the flex layout) on
open/close, flashing siblings. Set `transitionDuration={0}` so flex
controls the height; restore a smooth chevron rotation via CSS. Verified
no jump in a real browser (panel 501→0, no spike). Since closed sections
now render no content, drop the redundant (and incorrect) "Templates"
toggle clicks from 4 ResourcesScreen tests (the section is open by
default) and add a section-toggle test to keep the openSections handler
covered.
- Nits: scope the stories' chevron query to the accordion root; tighten
the Collapsed assertion to the identity transform.
Native-vs-styled scrollbar (review #4) split out as #1466.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(web): clarify chevron-timing and intent-merge invariants (#1464 review)
Address the two optional doc nits from the re-review:
- Note that the chevron's 200ms restates Mantine's default accordion
duration (keep in sync if accordion timing is themed).
- Note that the unconditional empty-section merge is safe because
empty-section controls are disabled, so `next` never contains one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): send a cancellation notification when cancelling an ordinary tool call (#1458)
The Tool detail panel's Cancel button was a silent no-op for ordinary
(non-task) tool calls: App.onCancelToolCall found no captured taskId and
did nothing, leaving the button looking actionable while the call ran on.
Wire request cancellation through the MCP cancellation flow. callTool now
tracks the in-flight call with an AbortController whose signal is threaded
into the SDK request options; aborting it makes the SDK send a
notifications/cancelled for that request and reject the pending call.
cancelToolCall() performs the abort. callTool surfaces the cancellation as
a ToolCallCancelledError (without recording a failed call), which App
clears as a clean cancel plus a short confirmation toast.
Task-augmented calls are unchanged: they have a server-side task and are
still cancelled via cancelRequestorTask (#1457).
Closes#1458
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): address review — distinguish disconnect from cancel; tidy callTool params
Review follow-ups on #1463:
- Disconnect no longer surfaces as "Tool call cancelled". The catch in
callToolWithRetries now throws ToolCallCancelledError only when the abort
reason matches the deliberate user-cancel reason (TOOL_CALL_CANCELLED_REASON);
a disconnect aborts the same controller with a different reason and falls
through to the normal error path, so no misleading toast.
- Note the activeToolCallAbortController slot is shared (last-writer-wins)
across all callTool callers.
- Fold callToolWithRetries/attemptToolCall's long positional parameter list into
a single ToolCallRequest object; move the URL-elicitation loop state into
callToolWithRetries.
Adds an integration test that a disconnect mid-call rejects as an ordinary
error (not ToolCallCancelledError), using a slow send_progress call over HTTP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): filter toggle hover as border, not background (#1460)
The Logging/History/Network filter toggles used the same
--mantine-primary-color-light background for both their hover and their
active (toggled-on) state, so clicking a button to turn it off while the
cursor was still over it looked like a no-op — the hover background
masked the removal of the active background.
- Add a shared FilterToggleButton element used by LogControls,
NetworkControls, and MessageDirectionFilter, replacing the duplicated
UnstyledButton + inline `bg={active ? … : undefined}` in each.
- Add a `filterToggle` UnstyledButton theme variant + `.filter-toggle`
rules: hover is now a thin border (reserved via a transparent 1px
border so it doesn't shift layout); the active state is the filled
background, keyed off `aria-pressed="true"`.
- This leaves the `.list-item` selectable rows (Prompts/Apps/Tools/
Resources lists, section headers) untouched — they keep their
hover-fill since they aren't toggles.
- Add `aria-pressed` to the Logging level toggles (previously missing).
Tests + stories cover the active/hover distinction via aria-pressed for
FilterToggleButton and all three controls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): move filter-toggle border to CSS so :hover actually shows (#1460)
The previous attempt set the reserved `1px solid transparent` border via
the theme's `styles` callback, which Mantine emits as an *inline* style.
Inline styles outrank stylesheet `:hover` rules, so
`.filter-toggle:hover { border-color }` never applied — hovering showed
no border at all (verified in a real browser via Playwright).
Move the border (and border-radius) out of the inline theme styles into
the `.filter-toggle` class in App.css, so `:hover` can override the
transparent border-color. The active fill (`[aria-pressed="true"]`) was
already correct.
Verified with real Playwright page.hover: inactive+hover now shows a
1px --inspector-brand-primary border, active shows the fill, active+hover
shows both, all distinct. Story play functions assert the active fill and
the reserved transparent border (the hover border can't be asserted in the
synthetic-event test runner, so it's documented as Playwright-verified).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): collapse long tool descriptions in ToolDetailPanel (#1381)
Long tool descriptions (e.g. sequential-thinking-server's "Sequential
Thinking" tool) filled the panel and pushed the form + Execute button
off-screen. The internal scroll region for the form/progress was already
in place; this adds the collapsible description.
- Wrap the description in a Mantine Collapse, collapsed by default.
- Expose the trigger as a chevron ActionIcon pinned to the right of the
tool-name row (FaChevronRight collapsed / FaChevronDown expanded), with
Show/Hide description aria-labels. Rendered only when a description exists.
- Reset to collapsed on tool change via the adjust-state-during-render
pattern, mirroring how ToolsScreen clears formValues.
Tests + stories cover collapsed default, expand/collapse toggle, reset on
tool switch, and the no-description case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): show tool description by default, click to hide (#1381)
Most tool descriptions are short, so default to showing the description
and let the user collapse a long one via the chevron, rather than the
reverse. Flips the initial/reset state to open and updates the toggle
aria-labels, tests, and stories accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): use Ri disclosure chevrons in ToolDetailPanel (#1459 review)
Swap react-icons/fa FaChevronDown/FaChevronRight for the established
react-icons/ri RiArrowDownSLine/RiArrowRightSLine disclosure-chevron
convention already used in ResourceLink, keeping chevron visuals
consistent across the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* a11y(web): wire description toggle as expandable control (#1459 review)
Add aria-expanded + aria-controls to the description chevron, tying it to
the Collapse region (via useId) so assistive tech announces it as a single
expandable control rather than two differently-labelled buttons. Adds a
test covering the wiring and the aria-expanded flip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): cancel the underlying task when cancelling a task tool call (#1455)
The ToolDetailPanel Cancel button fired onCancel, but App never supplied
an onCancelToolCall handler, so clicking Cancel did nothing — a tool
running as a task kept running on the server.
App now captures the in-flight task-augmented call's taskId from the
`toolCallTaskUpdated` event callToolStream dispatches, and the new
onCancelToolCall handler cancels that task via the existing tasks API
(reusing onCancelTask). The id is reset at the start of every call, so an
ordinary (non-task) call's Cancel stays a no-op (no task to cancel, no
request-abort channel yet) — preserving prior behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): show a toast when a task is cancelled
Cancellation goes out as a `taskCancelled` event (not a status
notification), so a running task's open 'Task <status>' toast would
otherwise hang with no confirmation. Subscribe to `taskCancelled` and
replace that toast (or show a fresh one) with a short auto-closing
'Task cancelled' toast, pruning the dead progress entry. Covers both
cancel paths — the Tasks screen and the Tool detail panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): report a cancelled task as cancelled, not failed
When a task is cancelled, the in-flight callToolStream rejects with a
generic -32603 error, whose error path reported the task as "failed" —
so the Tasks screen flashed FAILED until a manual refresh fetched the
server's true "cancelled" state. InspectorClient now records cancelled
task ids (set in cancelRequestorTask before the await, since cancelling
unblocks the stream) and the stream's error path labels the terminal
task "cancelled" with a matching status message. Cleared on disconnect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: address review nits on task-cancel wiring
- Prune the consumed taskId from cancelledTaskIds in the stream error path
so the set can't grow across a long session (disconnect-clear remains the
backstop for cancels whose task completed before the cancel landed).
- Clear activeToolCallTaskIdRef before the cancel resolves so a rapid second
Cancel click doesn't re-cancel the terminating task (which would surface a
spurious 'Failed to cancel task' toast). Add a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): expandable resource_link with read-on-demand (#1449)
Replace the placeholder `resource_link` rendering in ContentViewer with a
dedicated, Mantine-native `ResourceLink` element that mirrors V1's behavior:
- Shows uri (monospace, link-styled), optional name/description, and a
mimeType badge.
- Expand/collapse affordance (chevron + loading spinner) that is keyboard
accessible (native button: role, aria-expanded, aria-label, Enter/Space).
- On first expand, reads the linked resource on demand via an injected
callback and renders the full read result inline as formatted JSON under a
green "Resource:" label, capped to a max height with scroll for large
results. The fetched result is cached across collapse/expand.
The read handler is threaded as a "dumb component + callback as prop" through
ToolResultPanel → ToolsScreen → InspectorView, with App supplying a handler
that returns the `ReadResourceResult` from `inspectorClient.readResource`.
Also extract the shared `resourceContentsToBlock` helper into
`utils/resourceContents.ts` (previously private to ResourcePreviewPanel).
Tests: unit coverage for ResourceLink (collapsed/expanded/error/cache) and
the ContentViewer wiring, plus Storybook stories with play functions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): scroll large resource_link read results within the card
A plain ScrollArea with only `mah` does not scroll when its parent (the
content-sized card) has no definite height, so a large read result overflowed
the card instead of scrolling. Switch to ScrollArea.Autosize, which grows with
content up to the max height and then scrolls. Adds a LargeResult story
covering the bounded-scroll behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): make ResourceLink a group; extract ResourceLinkInfo element
ResourceLink is stateful (expand/loading/cached result) and fetches data via
a callback — group-like behavior, and the only `elements/` component using
useState. Move it to `groups/` and split out the pure-display metadata as a
new `elements/ResourceLinkInfo` element (uri/name/description/mime + an
optional action slot).
This keeps the layer dependencies clean (no element→group import, which
moving ResourceLink alone would have introduced):
- ContentViewer (element) renders ResourceLinkInfo (element) statically for
`resource_link` blocks and no longer takes an onReadResource prop.
- ToolResultPanel (group) renders the interactive ResourceLink (group) for
`resource_link` blocks, ContentViewer for the rest.
- ResourceLink (group) composes ResourceLinkInfo + ContentViewer.
Also reverts the now-single-consumer resourceContents.ts extraction, restoring
ResourcePreviewPanel's local toContentBlock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): make ResourceLink read retryable and guard double-fetch
Address PR review: a failed read is no longer cached as terminal — only
a successful result is cached, so collapsing and re-expanding retries the
read after a transient failure. Add a loading guard so toggling while a
read is in flight does not fire a redundant second read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): prettier formatting for ResourceLink test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: remove accidentally-committed build artifacts; gitignore clients/*/build
A stray 'git add -A' swept 8 compiled build files into d25c9382. Remove
them from tracking and add clients/*/build to .gitignore so client build
output is never committed again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): show Apps tab only when server exposes MCP App tools (#1450)
Gate the "Apps" tab on appTools.length > 0 so it is hidden when the
connected server exposes no MCP App tools. appTools is now computed
before availableTabs, which depends on it — the tab appears/disappears
live as the tool list changes (list-changed refresh, server switch).
The existing activeTab fallback already snaps a user sitting on Apps
back to Servers when the tab disappears. When app tools exist but the
sandbox is unavailable the tab stays visible so the AppsScreen's
"unavailable" message remains reachable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): content-gate the Prompts and Resources tabs too (#1450)
Extend the Apps tab gating to Prompts and Resources: each tab is hidden
when its list is empty and appears reactively when the list gains an
entry (list-changed refresh, server switch). Resources is gated on
resources OR templates, since a server may expose only templates. The
existing activeTab fallback snaps a user on a now-empty tab back to
Servers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): content-gate the Tasks tab too (#1450)
Extend the content gating to the Tasks tab: hidden when no tasks exist,
appears reactively once a task-augmented tool call creates one and
disappears when all are cleared. Consistent with the Apps/Prompts/
Resources gates — the existing activeTab fallback snaps a user on a now-
empty tab back to Servers. The "run as task" affordance still lives on
the Tools screen (gated by server task support), so discovery is
unaffected; advertised capabilities remain visible in Connection Info.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): fade content-gated tabs in to draw attention on appearance (#1450)
Add a brief 325ms fade-in to navigation tab labels via a new `tabLabel`
Text theme variant + `inspector-fade-in` keyframe. SegmentedControl keys
segments by value, so when a content-gated tab (Apps/Prompts/Resources/
Tasks) appears after a list change its label mounts fresh and fades in to
draw the eye, while existing tabs stay put.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): animate tab-bar width when a tab is added or removed (#1450)
Wrap the tab SegmentedControl in a clip element (new `tabBar` Group
variant) whose width is set to the control's measured border-box size and
transitions over 325ms ease-in. The control is sized `max-content` and
the clip `overflow: hidden`, so when a content-gated tab appears or
disappears the bar grows/shrinks smoothly. Width is measured with
useElementSize (ResizeObserver); it falls back to auto width where the
observer is unavailable, so the bar still renders at its natural size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): lengthen tab-label fade-in to 450ms (#1450)
Bump the `tabLabel` fade from 325ms to 450ms so a newly-appearing tab
name fades in more gently. Also formats the previously-unformatted
ViewHeader width-clip test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): drop tab-label fade-in, keep only the 325ms width animation (#1450)
Remove the per-tab fade-in (tabLabel Text variant + inspector-fade-in
keyframe + label wrapping) so the tab bar's 325ms ease-in width change is
the sole animation when a content-gated tab appears or disappears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): collapse the tab bar to 0 width when it leaves the DOM on disconnect (#1450)
Unify the connected/disconnected ViewHeader into a single tree so the tab
controls can sit in a keep-alive Mantine Transition. On disconnect the
Transition holds the bar mounted for 325ms while the clip's CSS width
transition collapses it measured->0, then unmounts. The connected tab
list/selection is snapshotted (adjust-state-during-render, no refs) so the
bar keeps rendering its tabs as it shrinks. The disconnected header keeps
its logo-left / title-center / theme-right layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): fade the disconnected title in after the tab bar collapses (#1450)
On disconnect, hold the "MCP Inspector" title until the tab bar's
shrink-to-0 exit finishes (tracked via the keep-alive Transition's
onExited), then fade it in over 300ms. Removes the prior overlap where the
title appeared beside the still-collapsing bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): set both disconnect animations to 300ms (#1450)
Drop the tab-bar collapse from 325ms to 300ms so it matches the title
fade (also 300ms), trimming the overall disconnect sequence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): staggered fade+slide-down crossfade between title and tab bar (#1450)
Replace the tab-bar width/size animation with a staggered crossfade. The
disconnected "MCP Inspector" title and the connected tab bar share one CSS
grid cell, so on connect/disconnect one fades + slides down out while the
other fades + slides down in — the incoming one delayed by half the 300ms
duration so they're staggered and one replaces the other in place.
Both motions travel downward (enter descends from above, exit descends
below) via inspector-fade-slide-in/out keyframes, selected per element with
a data-anim attribute; Mantine Transition provides keep-alive so the exit
plays before unmount. Removes the clip/useElementSize/tabBar-variant
size-animation machinery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): pulse a red glow on a tab that newly appears mid-session (#1450)
When a content-gated tab is added to the bar while connected, pulse a red
text glow around its label in-then-out over 325ms. New tabs are detected by
comparing the shown set to the previous render's (adjust-state-during-
render), so only mid-session additions glow — not the initial set on
connect, nor anything on disconnect. The glow is a CSS keyframe
(inspector-tab-glow) applied via a `tabGlow` Text variant + `data-glow`
attribute; the red comes from a new --inspector-glow-red token.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): start the new-tab glow at full intensity, then fade (#1450)
Change the tab glow from an in-then-out pulse to full-on-appear then fade
over 325ms (ease-out), so a newly added tab flashes brightest the instant
it appears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): keep new-tab label text red through the glow, then revert to theme (#1450)
Animate `color` red across the whole glow keyframe so a newly-appeared
tab's text stays red for the 325ms; with no forwards fill it snaps back to
the label's theme color when the animation ends. The glow text-shadow still
fades over the duration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): revert new-tab text color animation, keep glow only (#1450)
Drop the `color: red` keyframe stops so the label text keeps its theme
color; only the red glow (text-shadow) plays, full intensity on appear then
fading over 325ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): make the new-tab glow ease-in over 325ms (#1450)
Change the tab glow timing function from ease-out to ease-in (duration
already 325ms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): animate server name and right-side controls on connect/disconnect (#1450)
Wrap the server name (left) and the status indicator + Disconnect control
(right) in keep-alive Transitions so they fade + slide-down in on connect
and out on disconnect, using the same values as the tab bar. Split the
animation CSS (.header-anim[data-anim]) from the center-stack grid
placement so it's reusable, and broaden the snapshot to retain serverName/
status/latency for rendering during exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): address review — glow grace window, Select comment, key unification (#1450)
- Arm the new-tab glow only after a GLOW_GRACE_MS window post-connect, so
asynchronously-resolved lists (prompts/resources/tasks) aren't mistaken
for mid-session additions and don't glow on initial load.
- Document why the narrow-viewport Select doesn't glow.
- Route snapshotKey and the glow key through a shared tabsKey()/TAB_SEP
helper (also removes a stray control char from the join separator).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(web): note the slow-server tail of the glow grace window (#1450)
Acknowledge in a comment that GLOW_GRACE_MS keys on time-since-connect, not
on lists having settled, so a server slower than the window glows on its
tail. Per review follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): honor prefers-reduced-motion for the new header animations (#1450)
Add a reduced-motion media query that disables the header fade/slide
(.header-anim) and the new-tab glow (.tab-glow), leaving elements at their
resting state. Per review accessibility suggestion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add Mantine clear buttons to text inputs app-wide (#1333)
Add a clear (×) affordance to every user-editable text input across the
web client so a populated field can be reset in one click.
- Autocomplete: enable `clearable` by default via the theme
(`theme/Autocomplete.ts`) — covers the prompt-argument and
resource-template variable autocomplete branches.
- TextInput / Textarea: Mantine v8 does not expose a public `clearable`
prop on these primitives (only the combobox-based components do), so
add the clear button manually via `rightSection` + `CloseButton`
(aria-label="Clear") shown only when the field has a value, wired to
reset the field's controlled value to "".
Applied to sidebar search inputs (prompts, tools, apps, tasks, history,
resources, network, logs), prompt-argument and resource-template
variable inputs, the sampling response/model fields, server config &
settings forms, roots table, schema form, experimental features panel,
and the server.json import panel. Read-only/display Textareas
(OutputValidationModal, UrlElicitationErrorModal) are intentionally left
untouched.
Tests cover the clear behavior on representative inputs from each
category (sidebar search, prompt argument, resource-template variable,
sampling response, plus the config/settings forms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): disable ServerConfigModal clear buttons while submitting
Address review #2 on #1448: the clear CloseButton sat in `rightSection`
with `rightSectionPointerEvents="auto"`, so it stayed clickable even
while the field was `disabled={submitting}`, letting a user wipe a field
mid-submit. Forward `disabled={submitting}` to each clear button so it
locks together with its input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): remove redundant Auto-respond from SamplingRequestPanel
The sampling modal pre-loads its draft with the default stub result, and
"Send Response" sends that draft — so on an untouched form "Auto-respond"
did exactly the same thing as "Send Response", just with a fresh copy of
the same defaults. Drop the redundant button and its `onAutoRespond` prop;
"Send Response" now covers both the unedited (stub) and edited cases.
The InlineSamplingRequest variant keeps its Auto-respond button — there it
is the quick path versus "Edit & Send" (which opens this editable modal),
not a duplicate of an adjacent send action.
Updated PendingClientRequestModal wiring, stories, and tests accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369)
Let users control the order of servers on the Servers screen instead of
hand-editing mcp.json. Each ServerCard gets a grip handle (in the header,
left of the name) that drives an @dnd-kit sortable; pointer and keyboard
sensors both work, with live-region announcements for screen readers.
- Backend: PUT /api/servers/order rewrites mcp.json in the supplied order
inside the write lock, reusing the atomic-write + watcher-notify path.
Registered before /:id so "order" isn't captured as a server id. Rejects
with 409 when the id set no longer matches disk and 400 on duplicate /
malformed bodies, so a reorder racing an external edit can't drop or
duplicate an entry.
- Hook: useServers.reorderServers(orderedIds) — optimistic local reorder,
PUT, refresh-to-disk-truth on failure.
- UI: SortableServerCard wrapper owns the dnd-kit concerns; ServerListScreen
wraps the grid in DndContext + SortableContext (rect strategy) with
pointer + keyboard sensors and announcements. Reorder math and a11y copy
extracted to reorderIds.ts / serverReorder.ts for direct unit testing.
Renders plain cards (no grip) when no onReorder is wired.
- Wired onServerReorder through InspectorView -> App.tsx.
Tests: pure-helper units, hook (persist / optimistic / 409 revert), 9
backend integration tests (incl. id-set-mismatch rejection and the
route-registration guard), ServerCard/ServerListScreen units, and a
real-browser Storybook keyboard-reorder play (the dnd-kit keyboard sensor
needs layout rects that happy-dom doesn't provide).
Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): notify on reorder failure; skip reorder glue when not reorderable
Address PR #1447 review:
- App.tsx: catch reorderServers rejection (409 from a racing external edit,
or a network error) and show a red toast, matching the error-handling
pattern used by every other mutation — previously the rejection was
discarded with `void` and the drag silently bounced back.
- ServerListScreen: only build handleDragEnd / announcements when
`reorderable`, so the four-closure announcements object isn't allocated
every render when no onReorder is wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: apply auto-refresh setting live + diff-aware list-changed indicator (#1444)
Two follow-ups to #1402.
1. Live setting application — InspectorClient gains setServerSettings()
(on the protocol + fake); App pushes edited settings onto the live
client when the Server Settings modal closes for the connected server,
so toggling auto-refresh-on-list-changed takes effect without a
reconnect (the managed state reads getServerSettings() at notification
time).
2. Diff-aware indicator — in flag-only mode, on */list_changed the managed
Tools/Prompts/Resources state now fetches the new list and compares it
to the current one, lighting the indicator only when it actually
differs. The displayed list stays put until the user pulls via Refresh
(pull-on-demand preserved). Servers like everything re-send identical
lists on list_changed; this suppresses the false-positive indicator.
Refactors each manager's refresh into fetch + apply helpers shared with
the peek path.
Trade-off: item 2 reintroduces a fetch on */list_changed (to compare), but
unlike the pre-#1402 behavior it never replaces the displayed list under
the user — only the indicator reflects a real change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): make the diff-aware peek clear the indicator on revert (#1444 review)
peekForChange now sets the list-changed flag to whether the server list
differs from the displayed one (setListChanged(differs)) instead of only
ever setting it true. So a later notification that reverts the server back
to the displayed list clears the indicator — nothing left to pull. Adds a
clear-on-revert regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): consolidate managed-list state managers onto a shared base (#1444)
The four per-primitive list managers (tools, prompts, resources, resource
templates) were near-identical copies of the same fetch/apply/peek/connect/
disconnect/list_changed machinery, a standing drift risk. Extract a generic
ManagedListState<T, M> base parameterized by a config (change event,
list_changed notification, capability key, page fetcher, and whether the
list drives an indicator). Each manager is now a ~40-line subclass: its
config plus a typed getter alias.
Public contracts are unchanged — same per-manager *Change / listChangedChange
events and get*/refresh/getListChanged/clearListChanged methods — so the
hooks and all existing tests pass untouched. Templates opt out of the
indicator (supportsIndicator: false): a list_changed auto-refreshes only
when the server opts in, else does nothing. Net -245 lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(core): coalesce bursts of list_changed peeks (#1444 review)
A rapid burst of */list_changed notifications fired one concurrent
paginated fetch per notification. Guard peekForChange with an in-flight
flag: while a peek is fetching, a new notification queues a single re-run
instead of launching a concurrent fetch, and the running peek loops once
more on completion to capture the latest server state. Collapses a burst
into sequential peeks. Adds a coalescing test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): debounce list_changed bursts into a single fetch (#1444)
The in-flight guard only coalesced fetches that actually overlapped — when
a server (e.g. everything) emits a burst of */list_changed spaced just far
enough apart that each list call returns before the next notification, we
still fired one list call per notification. Add a real debounce in the
ManagedListState base: a notification (re)starts a timer and the
refresh/peek runs once the burst settles, collapsing N notifications into
one fetch. The in-flight coalescing guard stays as a second line of defense
for a post-debounce notification landing during a slow paginated fetch.
The delay is injectable (DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; subclasses
take an optional constructor arg) so tests run with 0 for fast, determinist
ic behavior. Timer is cleared on disconnect, unsubscribe, and destroy. Adds
debounce + in-flight regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): extend the in-flight guard to the auto-refresh path (#1444 review)
The overlap guard was only on the peek path; a burst-during-a-slow auto-
refresh could let a slower older fetch clobber a newer list (applyItems is
last-write-wins). Lift the guard from peekForChange up to runListChanged so
it wraps both the auto-refresh and peek actions: while one is fetching, a
new post-debounce notification queues a single re-run. Adds an auto-refresh
coalescing test alongside the peek one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): light the indicator without fetching when auto-refresh is off (#1444)
Per maintainer feedback: with auto-refresh off, a list_changed should not
issue any list call. The diff-aware peek (fetch-to-compare) meant one
tools/list went out per settled burst even with auto-refresh off, which the
maintainer doesn't want. Revert flag-only mode to lighting the indicator
blindly on a (debounced) list_changed — no network — and the user pulls the
new list via Refresh.
Trade-off (accepted): the indicator lights even when the server re-sent an
identical list (the everything-server case), since we no longer fetch to
compare. Keeps the debounce (one indicator light per burst), the
auto-refresh path + its in-flight guard, the live setServerSettings, and the
ManagedListState consolidation. Removes peekForChange and its tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(core): scrub stale peek docstrings after the blind-indicator revert (#1445 review)
The diff-aware revert removed peekForChange's body but left its docstring,
which orphaned onto destroy() and described fetch-to-compare/clear-on-revert
behavior that no longer exists. Replace it with a one-line destroy() doc, and
update clearListChanged's doc (peek -> blind light).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(core): finish scrubbing stale peek references after the revert (#1445 review)
Round-7's scrub missed four more peek/fetch-to-compare mentions in
managedListState.ts (supportsIndicator, fetchAll, the debounce-default and
onListChanged comments) plus six 'async peek' comments in the state tests.
Update them all to the blind-indicator semantics. Doc/comment-only; no
behavior change. grep -i peek over core/mcp/state now returns nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): wire the listChanged indicator across all screens (#1402)
The per-screen list-changed indicator was hardcoded false in
InspectorView for Tools, Apps, Prompts, and Resources, so it could
never light up. Source it from the managed-state layer instead.
Each ManagedXState now tracks a listChanged flag: set true when its
*/list_changed notification arrives (the existing auto-refresh leaves
it set), exposed via getListChanged()/a listChangedChange event, and
cleared by clearListChanged() or on disconnect. The useManagedX hooks
surface the flag and clear it on a user-initiated refresh (the
indicator's Refresh button = acknowledge). App threads the three flags
to InspectorView, which maps them to the screens — Apps shares the
tools flag since apps are a filtered view of tools.
Tests cover set/clear/disconnect at the state-manager layer, the hook
subscription + refresh-clears behavior, and the InspectorView routing
(including Apps sharing the tools flag and no cross-wiring between
screens).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): close clear-after-await race in managed-list refresh (#1402 review)
The useManagedX refresh() callbacks cleared the listChanged flag AFTER
awaiting the fetch. If a */list_changed arrived mid-fetch, the state
re-set the flag (and auto-refreshed), then the trailing clear wiped that
genuinely-new signal. Clear BEFORE the await instead: the user's refresh
acknowledges only the change in hand, and a mid-fetch change re-sets the
flag and survives. Adds a race regression test on the tools hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): drop duplicate Refresh on the Apps screen (#1402)
AppControls uniquely had a standalone toolbar Refresh button AND the
ListChangedIndicator on a separate row, so once the indicator could
actually appear (this PR), the Apps screen showed two Refresh buttons.
Match the other three controls (Tools/Prompts/Resources): the indicator
is the single header-right refresh affordance. Updates AppControls and
AppsScreen tests to assert exactly one Refresh (and none when the list
hasn't changed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402)
With auto-refresh on, the list was already current by the time the
list-changed indicator appeared, making its Refresh button moot. Now the
managed Tools/Prompts/Resources state managers only flag the change on
*/list_changed (they no longer auto-refresh); the list stays as-is until
the user clicks Refresh, which fetches the new list and clears the
indicator. The connect-time initial load is unchanged.
Resource templates (no own indicator, part of the Resources screen) also
stop auto-refreshing; onRefreshResources now refreshes both resources and
templates, since a single resources/list_changed covers both. Tasks are
left auto-refreshing — they have no list-changed indicator and need live
status updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(core): correct ManagedResourceTemplatesState header comments (#1402 review)
The class no longer subscribes to resourceTemplatesListChanged; update
the stale file/class docstrings that still claimed it syncs on that
notification to reflect connect + statusChange only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402)
New Options section in ServerSettingsForm with a single checkbox. When
checked, the managed Tools/Prompts/Resources (and resource templates)
state auto-refreshes on */list_changed notifications; when unchecked
(default) the notification only lights the list-changed indicator and the
user pulls via Refresh.
Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings
and StoredMCPServer, round-tripped through serverList (lifted with a false
default, persisted only when true, added to the exhaustive field-key map);
surfaced from InspectorClient via a new getServerSettings() on the protocol;
the state managers read it live at notification time to decide auto-refresh
vs flag-only. Resource templates re-subscribe to their list_changed but only
auto-refresh when the option is on (they have no indicator of their own).
Tests cover the round-trip (true persists, false omitted), the form
section (render/checked/toggle), and each state manager's on/off branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): persist autoRefreshOnListChanged through validateSettings (#1402)
The PUT /api/servers write path runs settings through validateSettings,
which rebuilds the value from explicitly-named fields and silently drops
unknown keys — so autoRefreshOnListChanged never reached disk (the
serverList round-trip test passed because it calls
inspectorSettingsToStoredFields directly, bypassing this path). Validate
it as an optional boolean and include it in the built value; false is
omitted on disk by inspectorSettingsToStoredFields as before.
Adds integration tests over the real PUT path: true persists, false is
omitted, and a non-boolean is rejected (400).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): default the Server Settings modal to the Options section (#1402)
Open 'Options' instead of 'Custom Headers' when the Server Settings modal
first opens, so the new Auto Refresh toggle is the first thing surfaced.
Updates the modal default-expanded section, the modal test's initial-state
assertions, the three header tests (now expand 'Custom Headers' first since
it's no longer open by default), and the story defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: drop accidentally-committed Inspector history dump; gitignore the class
83076441 swept an exported Inspector message-history JSON (manual-testing
artifact at the repo root) into the tree via git add -A. Untrack it and
extend the existing repo-root debug-artifact .gitignore block (alongside
/*.png) to cover /inspector-history-*.json so these can't be re-added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): show negotiated MCP protocol version on ServerCard (#1324)
Add optional protocolVersion to ConnectionState and render it as a
dimmed "MCP <version>" label at the end of the transport-badge/
description row on ServerCard, shown only when connected. The value
is plumbed through useInspectorClient as part of #1324.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core,web): surface negotiated protocolVersion end-to-end (#1324)
Plumb the MCP protocol version negotiated during initialize from the
InspectorClient through to the ServerCard so the active connected card
displays "MCP <version>".
The SDK Client doesn't expose protocolVersion; it stamps it onto the
transport via setProtocolVersion. MessageTrackingTransport now captures
it (and forwards to the base transport when present), so it works for
stdio too — whose base transport has no setProtocolVersion.
- core: MessageTrackingTransport captures + exposes protocolVersion;
InspectorClient stores it, exposes getProtocolVersion(), dispatches a
protocolVersionChange event, and clears it on disconnect.
- core: useInspectorClient subscribes and returns protocolVersion.
- web: App reads it from the hook (drops the hard-coded "2025-06-18")
and InspectorView splices it onto the active server's connection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): decouple initializeResult from protocolVersion (#1324 review)
Gate the initializeResult memo on serverInfo only, not protocolVersion.
initializeResult also drives the connected ViewHeader and Connection Info
modal, so requiring the version risked hiding the whole connected surface
in the edge case where it's absent. protocolVersion now flows through as
the optional field it already is everywhere downstream ('' reads as
unknown; the ServerCard label and modal value both tolerate it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): drop redundant connected-check in InspectorView splice (#1324 review)
initializeResult is derived from connectionStatus in App's memo, so a
truthy initializeResult.protocolVersion already implies a live
connection — the extra connectionStatus === 'connected' guard in the
splice was redundant. ServerCard keeps its own status guard (dumb-
component robustness). No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): don't show a permanent PENDING badge on history notifications (#1439)
`extractStatus` derived the history status badge purely from whether a
`response` was attached, so a fire-and-forget notification (no id, no
response ever) — and any unmatched standalone response — was labelled
"Pending" forever. The pending → OK/Error lifecycle only applies to
request entries (messageLogState attaches the response by JSON-RPC id),
so scope the status to `direction === "request"` and render no
request-style badge for notifications/responses. The method badge already
labels them.
Part of the History-screen work on #1438.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): track outgoing responses so server→client requests resolve in history (#1440)
MessageTrackingTransport.send only tracked outgoing requests, so the
client's response to a server→client request (roots/list, sampling,
elicitation) was never recorded. The inbound request entry never got its
response folded in, leaving it stuck "Pending" with no response body.
Make send symmetric with onmessage: track outgoing responses (by id +
result/error) as well as outgoing requests. The response now correlates
to its request entry, which resolves to OK/Error with the body. Outgoing
notifications remain untracked (separate gap).
Part of the History-screen work on #1438.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): wire up History Pin and Replay (#1438)
Both HistoryEntry actions were rendered but stubbed to todoNoop in App.
- Pin: App now owns a session-scoped Set<string> of pinned entry ids,
toggled by onTogglePinHistory and passed down as pinnedHistoryIds.
HistoryListPanel already sorts pinned entries to the top. The set resets
with the rest of the per-screen state on disconnect/server-switch.
- Replay: onReplayHistory re-issues the entry's original request by method
(tools/call → callTool, prompts/get → getPrompt, resources/read →
readResource). The call flows through InspectorClient → tracked
transport → message log, so the fresh request+response surface as a new
History entry (history-local) without touching the Tools/Prompts/
Resources panels. Unsupported methods / a removed tool surface a toast.
Tests: pin toggle propagation, replay dispatch for each supported method,
unsupported-method and missing-tool toasts.
Closes#1438.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): make history Replay support list/discovery requests
Replaying a tools/list entry reported "Replay isn't supported". Extend
replayHistoryRequest beyond the call-style requests to the read/discovery
methods, re-issuing each via its InspectorClient method (preserving the
pagination cursor):
- tools/list → listTools, prompts/list → listPrompts,
resources/list → listResources,
resources/templates/list → listResourceTemplates, ping → ping.
Tests: tools/list replay (cursor preserved) and an unsupported-method
toast (logging/setLevel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): replay tasks/list; reorder + conditionally show History buttons
- Add tasks/list to the replayable set (re-issued via listRequestorTasks),
completing the client-issued */list methods.
- Reverse the HistoryEntry actions to Pin then Replay, and hide Replay for
entries whose method can't be replayed.
- Introduce a single source of truth — REPLAYABLE_HISTORY_METHODS /
isReplayableHistoryMethod in historyUtils — used by HistoryEntry to
show/hide the button and by App's replayHistoryRequest to gate dispatch,
so the two can't drift.
Tests: tasks/list replay, Replay hidden for a non-replayable method, and
Pin-before-Replay ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add client↔server direction badge to History and Network (#1437)
A dual-state badge at the top-left of each entry shows which way it
traveled: "client → server" (green) for inspector-originated, "client ←
server" (yellow) for server-originated.
- New shared element MessageDirectionBadge (Elements).
- History: MessageEntry gains an `origin` ("client" | "server") set at
tracking time — MessageTrackingTransport tags `send` as client and
`onmessage` as server, threaded through the track callbacks. HistoryEntry
maps origin → badge. (direction stays the request/response/notification
message-type used for response correlation; origin is the flow.)
- Network: fetches are always inspector-originated, so NetworkEntry renders
the badge as outgoing.
Tests + stories for the badge; HistoryEntry direction tests; transport
test asserts the origin argument.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): use purple (not yellow) for the client ← server direction badge
Yellow + green read as caution/ok status; purple vs green reads as
direction. Incoming (server → client) is now violet; outgoing stays green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): make History Pinned/History sections collapsible toggles
The "Pinned Requests (N)" and "History (N)" section headers are now
clickable toggle buttons that expand/collapse their entries — styled like
the LogControls level toggles (UnstyledButton listItem variant with the
active background) and wrapping the entries in a Collapse. Both default
open and collapse independently.
Tests assert the per-section aria-expanded toggling and independence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): replace Expand/Collapse text button with an icon toggle
History, Network, and Task cards now use a shared ExpandToggle ActionIcon
instead of the "Expand"/"Collapse" text button: LuArrowDownToLine when
collapsed (expand), LuArrowUpFromLine when expanded (collapse). The
aria-label stays "Expand"/"Collapse" so the control reads the same.
- New shared element ExpandToggle (+ test + stories).
- HistoryEntry: actions split into a left group (Pin/Replay) with the
toggle pushed right via space-between.
- NetworkEntry / TaskCard: drop their now-unused SubtleButton const.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): pin icon toggle on HistoryEntry, moved to the right
Replace the Pin/Unpin text button with a shared PinToggle ActionIcon
(TiPinOutline when unpinned, TiPin when pinned; aria-label stays
Pin/Unpin). Move it to the right action group, just left of the
expand/collapse toggle — Replay stays on the left.
- New shared element PinToggle (+ test + stories).
- HistoryEntry action row: Replay left; Pin + expand toggle right.
- Drop the now-unused formatPinLabel helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): per-section Clear/Export on the Pinned and History headers
Each section header now shows Clear and Export links on the right, but
only when both sections are present (a single section is already covered
by the panel-level Clear/Export). They act on just that section's entries:
- Clear: removes that section's entries by pin membership (clearing the
Pinned section also drops the now-stale pinned id set).
- Export: downloads just that section's entries
(inspector-history-pinned/-unpinned-...json).
Threaded onClearSection/onExportSection App → InspectorView → HistoryScreen
→ HistoryListPanel; App computes each section from messages + pinned ids.
Extracted a CollapsibleSection subcomponent and a SectionActions links
group. Extended ExportKind with history-pinned/history-unpinned.
Tests cover both-sections visibility gating and per-section dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): Filter by Message Direction on the History controls
Add a "Filter by Message Direction" section to HistoryControls, modeled on
LogControls' "Filter by Level": a Select/Deselect All control plus two
listItem toggles — "client → server" and "client ← server" — that show or
hide entries by origin.
- HistoryUiState gains visibleDirections (Record<MessageOrigin, boolean>),
defaulting to both visible; persists with the other per-screen state.
- HistoryScreen wires the toggles (single + all) through onUiChange and
passes the filter to HistoryListPanel, where matchesFilters drops entries
whose direction is off (entries without a recorded origin are never
filtered out).
Tests cover the controls (render, single toggle, select/deselect all), the
HistoryScreen onUiChange wiring, and the list-level direction filtering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): section Clear/Export as subtle buttons, not link anchors
Match the Select/Deselect All control in HistoryControls: the per-section
Clear/Export on the History/Pinned headers now use a subtle Button
(variant subtle, size xs) instead of an Anchor link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): color the direction filter labels to match the badge
The "client → server" / "client ← server" toggles in HistoryControls now
color their labels green / violet, matching the MessageDirectionBadge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): track outgoing client notifications in history too
Review follow-up (#1441): MessageTrackingTransport.send now also tracks
the client's own notifications (initialized, progress,
roots/list_changed) as origin "client", making send fully symmetric with
onmessage. Previously only id-bearing outgoing messages (requests,
responses) were tracked, so client notifications never reached History
while incoming server notifications did.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): Filter by Message Direction on the Network screen
Add the same "Filter by Message Direction" section to NetworkControls as
on HistoryControls. Extracted the shared section into a MessageDirectionFilter
group component (used by both HistoryControls and NetworkControls) so they
stay identical — a Select/Deselect All control plus green/violet
client → server / client ← server toggles.
- NetworkUiState gains visibleDirections (default both visible), persisted
with the rest of the per-screen state; NetworkScreen wires the toggles and
NetworkStreamPanel filters on them.
- Network fetches are always inspector-originated, so all entries are
client → server: the client toggle gates them all and the server toggle
matches nothing (documented in NetworkStreamPanel). The section mirrors
History for parity.
Tests cover the shared filter, both controls' direction sections, the
Network list filtering, and the NetworkScreen wiring; ambiguous "Deselect
All" selectors (now two sections) disambiguated by order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* revert(web): drop message direction from the Network tab
Network traffic is always client → server — even SSE is a client-initiated
stream tagged outgoing — so a direction badge/filter there is meaningless.
Remove the MessageDirectionBadge from NetworkEntry and the Filter by Message
Direction section (and its NetworkUiState.visibleDirections wiring) from the
Network screen.
History keeps its direction badge + filter; the shared MessageDirectionBadge
and MessageDirectionFilter components stay (still used by History).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): a lone History section isn't a collapsible accordion
When only one of Pinned / History is present, its header is now a plain
title and its entries always show — instead of a `listItem` accordion
toggle that could hide them based on a stale collapsed state from when
both sections were on screen. The accordion (and per-section Clear/Export)
only applies when both sections are present.
Example fixed: collapse History while pins exist, then unpin everything —
History becomes the sole section and shows its entries under a plain
header rather than staying collapsed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): put section Clear/Export on the header pleat itself
The per-section Clear/Export sat to the right of the collapsible header
bar, on the panel background. Make the whole header row the "pleat":
a new Group `sectionHeader` theme variant (rounded + hover, active bg
passed per instance) wraps a flex-1 toggle button (the title) on the left
and the Clear/Export buttons on the right — so the actions live on the
pleat. The toggle is a separate button since the action buttons can't nest
inside it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(web): entry expand toggle uses the same icons as ListToggle
Swap ExpandToggle (History / Network / Task cards) from the LuArrow icons
to RiExpandVerticalLine / RiCollapseVerticalLine — the same expand/collapse
icons the list-level ListToggle uses. Same ActionIcon size, so it matches
the other small buttons on the cards; aria-labels unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): panel Clear keeps pinned entries (history-only)
Review follow-up (#1441): the panel Clear button is gated on the unpinned
count (implying history-only), but onClearHistory wiped everything
(pinned included) and left pinnedHistoryIds stale. Make Clear history-only
— clearMessages((m) => !pinnedHistoryIds.has(m.id)) — so pinning protects
an entry, matching the gating and the per-section model; the pin set stays
valid since its entries survive.
Also drop the stale "and Network" mention from the MessageDirectionFilter
docstring (Network direction filter was reverted).
Test: panel Clear's predicate keeps pinned ids and removes unpinned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): two-step URL elicitation completion (#1415)
Replace accept-on-open with an explicit completion step for URL
elicitations. Opening the URL now only transitions the panel to its
waiting state; the elicitation resolves with `accept` only when the user
clicks the revealed "I've completed it" action, or with `cancel`.
- ElicitationUrlPanel: add `onComplete`, reveal the completion button and
relabel open→"Reopen in Browser" while `isWaiting`.
- PendingClientRequestModal: wire local `isWaiting`; open no longer sends
a response. Removed the accept-on-open comment that pointed here.
- Tests cover open→waiting→complete and open→cancel, and assert opening
alone does not resolve the elicitation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): handle URL-elicitation-required (-32042) error path
Implements the spec's "URL mode with elicitation required error" flow
(2025-11-25). When a tools/call returns a `-32042`
UrlElicitationRequired error, the client now surfaces the required URL
elicitation(s) and retries the call once completed, instead of showing a
bare error.
Core (InspectorClient.callTool):
- Detect `-32042` (typed UrlElicitationRequiredError or generic McpError)
via the new `getUrlElicitationsFromError` helper.
- Surface each carried elicitation in order through the existing pending-
elicitation queue / modal (reusing the two-step "Open → I've completed
it" UI), then re-issue the original call once all are accepted. A
decline/cancel aborts with a clear message; a bounded retry count guards
a server that keeps returning the error.
- `ElicitationCreateMessage.completeIfPending()` lets the optional
`notifications/elicitation/complete` auto-advance/auto-accept an open
URL elicitation; the completion-notification handler now resolves
(was: only removed) the pending entry.
- Extracted `attemptToolCall` / `dispatchFailedToolCall` from the old
callTool body to support the retry loop without duplicating bookkeeping.
Web (App):
- A non-spec `-32042` with no elicitations (nothing to open) shows a
dedicated yellow toast linking to a new UrlElicitationErrorModal with
the raw error body, mirroring the output-schema-validation toast.
Tests: helper unit tests, completeIfPending unit tests, callTool
error-path retry/cancel/no-list/ordinary-error tests (injected fake
client), the new modal (test + stories), and an App toast test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): abort URL-elicitation error path on a repeated URL
Guard the -32042 retry loop: if the server's retry error re-requests a
URL the user already completed earlier in the same call, completing it
again can't make progress, so callTool aborts with a new typed
UrlElicitationLoopError instead of re-prompting (which would loop until
the retry cap). The web layer detects it and shows a "URL elicitation
loop" toast naming the repeated URL rather than a generic failure.
Tests: core abort-on-repeat test (URL presented once, two attempts, then
loop error) and an App loop-toast test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): let Copy-URL reach the URL-elicitation completion step
Address review of #1436: the "I've completed it" action was gated behind
clicking "Open in Browser" (only that set isWaiting), so a user who used
Copy URL to finish the flow externally could only Cancel. Copying the URL
now also moves the panel into the waiting state, exposing the completion
step. Also trims the now-redundant second sentence of the waiting hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): address review of error-path URL elicitation
Review follow-ups on #1436 (all non-blocking):
- Remove the leftover bare block scope in attemptToolCall (extraction
artifact); body de-indented one level.
- Log a warning when callTool gives up after the URL-elicitation retry
cap is hit, so a server that keeps demanding new elicitations is
diagnosable rather than looking like an ordinary failure.
- Assert the dispatch-once invariant on the loop-abort and cancel-abort
paths (exactly one failed toolCallResultChange event).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(core): fix attemptToolCall body indentation
Run prettier over the extracted attemptToolCall method body, which was left
over-indented by 2 spaces after the bare-block removal in 0754b43d. core/ is
outside clients/web's format:check scope, so npm run validate did not catch it.
Whitespace-only; the pre-existing line 658 wrap (also outside the format scope)
is left as-is, out of scope for this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): settle pending elicitations on disconnect to avoid callTool hang
Address review of #1436: an error-path `awaitUrlElicitation` promise only
settled via respond, but `disconnect()` cleared `pendingElicitations`
wholesale — so a disconnect mid-elicitation (server crash, manual
disconnect, transport close) left `callTool` (and the UI's in-flight
toolCallState) hanging forever.
- Add `ElicitationCreateMessage.cancel()`: settles a pending elicitation
as `{ action: "cancel" }` without calling `onRemove` (the queue is
cleared by the caller, so it must not splice mid-iteration).
- `disconnect()` now cancels each pending elicitation before clearing the
array, and dispatches `pendingElicitationsChange` (it already did so for
samples) so the modal closes on a transport-close teardown too.
Tests: disconnect-mid-elicitation settles callTool (no hang); cancel()
unit tests (resolves-without-remove, no-op once responded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add per-server Roots config + client roots capability (#1425)
Add MCP roots support to the v2 web Inspector as a per-server config
section in the server settings form (no separate Roots tab/screen).
- Persistence: add `roots: Root[]` to `InspectorServerSettings` and
`StoredMCPServer`; thread through serverList converters (lift on read,
drop empty-uri rows / blank names on write, omit when empty) and the
remote server's read-side drop guard + `validateSettings`.
- Capability: `setupClientForServer` always advertises the roots
capability (`roots: cleanRoots(savedSettings?.roots ?? [])`) so the
server can issue `roots/list` and receive `roots/list_changed`.
- Live apply: edits to a connected server's roots are pushed via
`inspectorClient.setRoots(...)` once, on settings-dialog close — diffed
against the client's current roots — rather than per keystroke, to
avoid flooding the server with `roots/list_changed` / `roots/list`.
- Form: new Roots accordion section with a required URI and an optional
name per row (unlike v1, which was URI-only), + Add Root, and remove.
Tests cover the persistence round-trip (serverList + servers-route),
the form/modal section, and reuse the existing InspectorClient
roots/`setRoots` integration coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(roots): address review — dedup guards/cleanRoots, preserve extra fields, test live-apply
Responding to the PR #1431 code review:
- Dedup `isRootArray`: drop the copy inside `validateSettings` and reuse the
module-scope guard the read path uses, so the two can't drift
(core/mcp/remote/node/server.ts).
- Dedup the clean-roots normalizer: hoist a single exported `cleanRoots`
into core/mcp/serverList.ts and reuse it from both
`inspectorSettingsToStoredFields` and App.tsx (drops the local copy).
- Preserve non-form root fields (e.g. `_meta`): `cleanRoots` now spreads the
carried-through fields and only normalizes uri/name, and the modal's
`handleRootChange` spreads the existing root on edit — so a hand-edited
root's `_meta` survives load + persist + edit instead of being silently
dropped.
- Test the App live-apply-on-close path: new App.test.tsx cases assert
setRoots is called once with cleaned roots when changed on the active
server, and not called when the cleaned roots already match what the
client advertises. Adds a cleanRoots unit test (blank drop + _meta
preserve).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
* feat(web): tool progress toasts + persist tool selection/result across navigation (#1414)
Surface incoming notifications/progress as toasts so the user can watch a
long-running tool's progress without leaving the tool view — the v2 replacement
for v1's always-visible "Server Notifications" shelf. Toasts are keyed by
progress stream and updated (not stacked) per tick so a chatty server updates
one toast rather than flooding the corner. Progress notifications still land in
the History tab via the existing transport-level tracking.
Companion change: lift the selected tool and its form values into App.tsx
alongside the already-lifted toolCallState, make ToolsScreen controlled, and
drop the clear-on-unmount effect. Leaving the Tools tab and returning now
preserves selection, inputs, and result. All three reset together on
disconnect via resetSessionScopedUiState, preserving the #1368/#1394 behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): persist per-screen selection/search/filter across navigation (#1417)
Lift every screen's selection, form, search, and filter state out of the
screen components into session-scoped state owned by App, passed down as
controlled props. The screens unmount on tab switch, so this is what keeps
the user's context in place when they navigate away and back within a live
session. State is cleared only on disconnect (resetSessionScopedUiState) or
an explicit user action — never on plain navigation.
Lifted per screen:
- Tools: sidebar search (selection/form/result were lifted in #1414)
- Prompts: selected prompt, argument values, submitted marker, search
- Resources: selected resource/template URI, originating-template marker,
search, accordion open-sections
- Apps: selected app, form values, search (running/maximized stay local —
they're tied to the live iframe/bridge that is torn down on unmount)
- Tasks: search + status filter
- Logs: filter text + visible-level set
- History: search + method filter
- Network: filter text + visible-category set
The four sidebar *Controls (Tool/Prompt/App/Resource) become controlled for
search; ResourceControls also controls accordion open-sections. Level/category
default maps move to logLevels.ts / fetchCategories.ts so the screen files
keep a single component export.
Pagination already persists (it lives in the App-level Paged*State managers),
so no work was needed there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): persist list scroll position across navigation + reset tests (#1417)
Add useScrollMemory: a module-keyed hook that remembers a Mantine ScrollArea's
offset across unmount/remount (the screens unmount on tab switch). Scroll lives
in a module-scope map rather than threaded App state — it's ephemeral DOM state,
and threading ~16 more props would balloon the surface for no benefit. App calls
clearScrollMemory() in resetSessionScopedUiState so offsets clear on disconnect
alongside the lifted selection/filter state.
Wired into the four main list panels (Logs, History, Network, Tasks) and the
Tools/Prompts/Apps sidebar lists. Pagination already persists via the App-level
Paged*State managers.
Tests: useScrollMemory unit test (save/restore, key isolation, clear), and the
App disconnect test now also exercises representative lifted UI state (tool
search, prompt selection, log filter) clearing on disconnect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): collapse per-screen lifted state into one {screen}Ui object
Replace the ~46 individual selection/search/filter props threaded App →
InspectorView → screen with one `{screen}Ui` object + one `on{Screen}UiChange`
setter per screen (8 pairs). Each screen reads its fields from `ui` and updates
via `onUiChange({ ...ui, field })`; the in-flight result panels
(toolCallState / getPromptState / readResourceState) stay separate since
they're written by App's async action handlers, not the screens.
The `*UiState` interfaces live with their screens; the empty-state defaults are
gathered in screenUiState.ts so the screen files keep a single component export
(react-refresh), mirroring logLevels.ts / fetchCategories.ts.
Trims InspectorView's prop surface substantially and keeps the persistence
behavior identical. Tests/stories collapse their stateful wrappers to a single
ui object; added Tools/Tasks/History interaction tests to keep per-file
coverage above the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): dismiss visible progress toasts on client teardown (#1414 review)
The progressNotification effect cleanup cleared its id-tracking set but left
on-screen "Tool progress" toasts to auto-close up to 5s later — they could
linger into the next session, and the lingering toast's onClose could delete an
id from the new session's set and trigger a duplicate-id re-show. Hide each
tracked toast in cleanup so both gaps close. Addresses @claude review note #2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): clear the tool result when a different tool is selected
The Tools result panel renders `toolCallState` regardless of which tool is
selected, so selecting a new tool used to leave the previous tool's result
showing beneath it. Route tools UI changes through an App handler that drops
`toolCallState` whenever `selectedToolName` changes; search/form edits keep the
selection (and the result) intact. Tests cover both the clear-on-switch and
keep-on-search paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Hono dev-proxy middleware pumped streaming /api responses to the Node
ServerResponse without handling client disconnect. When the browser dropped a
stream mid-flight (SSE close, navigation, HMR reload) `res` was destroyed and
the next write failed with ERR_STREAM_DESTROYED, which the pump logged as an
error. It also had no `error` listener (a failed write could surface as an
uncaught exception) and no `close` listener (it kept reading upstream after the
client left).
Make the pump disconnect-aware:
- Listen for res `close`/`error`; either cancels the upstream reader (resolving
the pending read so the loop exits) and flips a `clientGone` flag.
- Treat ERR_STREAM_DESTROYED / EPIPE / ECONNRESET as a normal end — not logged.
- Only log genuinely unexpected stream errors.
- Await each write so a slow client applies backpressure instead of the proxy
buffering the whole stream.
Dev-only plugin (excluded from the coverage gate — it needs a real Vite server
to exercise). Verified via npm run validate.
Closes#1418
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): render pending sampling/elicitation requests (#1407)
Server-initiated sampling and elicitation requests never reached the v2
web UI: the InspectorClient queued them and returned a Promise that only
resolved on a user response, but nothing subscribed to the pending-change
events or rendered the (already-built) request components. A tool that
triggered one spun the Execute button forever.
Wire the queues to the UI:
- Add usePendingClientRequests() (core/react) subscribing to
pendingSamplesChange / pendingElicitationsChange and returning the live
arrays. Expose getPendingSamples() / getPendingElicitations() on
InspectorClientProtocol (and the test fake) so hooks can read them.
- Add PendingClientRequestModal, an App-level modal that surfaces one
queued request at a time (sampling-first) via the existing
SamplingRequestPanel / ElicitationFormPanel / ElicitationUrlPanel. It is
non-dismissable so the blocked call only resolves via an explicit action.
- Wire it in App.tsx: responding/rejecting resolves the client's handler
Promise, which unblocks the originating call and clears the spinner.
Also fold in two issues surfaced while verifying:
- SchemaForm rendered an `array` + `items.enum` (multi-select enum) as a
JSON textarea because only `items.anyOf` had a branch; add a MultiSelect
branch with enumNames label support.
- ToolsScreen's result lives in App and outlived the screen's local
selection (reset on tab-switch unmount), leaving a result with no
selected tool. Clear the result on unmount for a clean slate on return.
Prompts/Resources gate their preview on selection, so they are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(elicitation): support explicit decline; honest queue label; review polish
Address @claude review on #1411:
- Add an explicit "Decline" action to ElicitationFormPanel so the spec's
decline (refusal) is distinguishable from cancel (dismissal); the JSDoc
claimed accept/decline/cancel but only accept/cancel were wired. Wire the
modal body to emit { action: "decline" }.
- Replace the misleading "1 of N" queue label (the leading 1 never changed
since the modal always shows the head) with an "N pending" hint, hidden when
nothing else is queued.
- Make the dead useMemo honest: derive the head request inside the memo from
the source arrays so it actually caches (the inline activeElicitation had a
fresh identity every render).
- Document the URL elicitation accept-on-open semantic with a comment pointing
at the follow-up (#1415).
- Add an App-level round-trip test driving pendingSamplesChange end-to-end
(modal opens, respond() fires, empty event closes it).
Follow-ups filed: #1415 (URL two-step completion flow), #1416 (form required-
field submit gate + double-submit guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(elicitation): include untouched schema defaults in submitted content
The schema form displays a field's default via resolveValue(), but onChange
only writes a field into `values` once the user edits it. So an elicitation
submitted with default-only fields left untouched dropped them entirely —
v2 sent just the manually-edited fields while v1 sent defaults too (reported
with the everything server: firstLine/integer/number/enum defaults missing).
Add collectSchemaDefaults() to jsonUtils (recurses nested objects, omits
fields with no default) and seed ElicitationFormModalBody's values state with
it via a lazy initializer. The modal body is keyed by request id, so it
re-seeds per request; user edits still override seeded defaults via the
existing onChange merge.
Tests: collectSchemaDefaults unit tests (field types, nested, empty) in
jsonUtils.test; a modal test asserting an untouched-default form still submits
its defaults in content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(forms): seed schema defaults for tool-call and app-launch forms too
Extend the elicitation defaults fix (collectSchemaDefaults) to the other two
SchemaForm consumers that own form state and submit it: ToolsScreen (tool
execute args) and AppsScreen (app launch args). Both reset formValues to {} on
selection, so default-only fields the user never edited were dropped from the
submitted args — same root cause as the elicitation modal.
Seed formValues from the selected tool/app inputSchema defaults on select.
User edits still override via the existing onFormChange merge.
(InlineElicitationRequest has no consumers wired up, so it's left as-is.)
Tests: ToolsScreen sends a tool's default on Execute without editing;
AppsScreen sends an app's default on Open App without editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(elicitation): gate form submit on required fields; guard double-submit (#1416)
Fold the #1416 hardening into this PR:
- Gate the elicitation form's Submit button on required fields. Promote the
hasMissingRequiredFields helper out of AppDetailPanel into jsonUtils (shared)
and use it in ElicitationFormPanel so Submit is disabled until every required
field has a value.
- Guard against double-submit. Add an optional `busy` prop to the sampling,
elicitation-form, and elicitation-url panels that disables their responding
actions. PendingClientRequestModal's bodies use a new ref-guarded
useRespondOnce() hook: the first action dispatches and locks the panel; later
clicks no-op. This stops a fast double-click from calling respond()/reject()
twice (which throws and produced an unhandled rejection). Copy URL stays
enabled — it doesn't resolve the request.
Tests: hasMissingRequiredFields unit tests; ElicitationFormPanel submit-gate
and busy-lock tests; a modal double-click-resolves-once test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tools): cap detail/result panels at viewport and scroll internally
The Tools screen's detail and result columns sized to their content and bled
past the bottom of the viewport on long output (e.g. the env dump or a long
elicitation result). Cap each column at the screen's available height and let
its panel scroll internally instead.
- ToolsScreen: drop `align: flex-start` and wrap each column in a stretched
`ContentPane` (Flex column, capped by `mah` = viewport − header − xl*2). The
pane's definite height is what lets the card's inner ScrollArea know how far
to shrink — a bare `mah` on the card does not (the card sizes to content, so
the scroll region renders full-height and overflow:hidden just clips it,
unreachable). Mirrors the proven Prompts/Resources preview panes.
- ToolResultPanel: pinned header + inner ScrollArea (flex 0 1 auto, mih 0).
- ToolDetailPanel: pinned title/annotations header, scrolling body
(description + form + progress), pinned Execute footer.
Audited the other screens: Prompts/Resources already use this pattern,
ServerList and the four list panels (Logging/Network/History/Tasks) already cap
via ScrollArea.Autosize. Only AppsScreen's app-input form had the same gap —
fixed it the same way: AppDetailPanel now scrolls internally and the card uses
variant="preview" so the running app's iframe and a long form both stay within
the viewport.
Verified in-browser against the everything server: the get-env result now
stops at the viewport bottom with margin and scrolls within the card
(clientHeight 502 vs scrollHeight 1931), header pinned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(remote): use sync pino destinations in logging tests to fix teardown race
CI flaked with an uncaught `ENOENT: ... /tmp/inspector-log-unknown-level-*/app.log`
that failed an unrelated later test. The remote-logging integration tests create
`pino.destination(logFile)` loggers (async sonic-boom streams) and rmSync the temp
dir in finally/afterEach without closing the stream — a buffered background flush
then fires after the dir is gone and throws an uncaught error, which Vitest counts
as a run failure even though all tests pass.
Make those destinations `sync: true` so the write lands during the request instead
of on a later flush, removing the race. Test-only; unrelated to this PR's feature
work but it was blocking the PR's CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(storage): await store-file writes instead of polling (#1412)
OAuth persistence is Zustand `persist` → file-storage adapter →
writeStoreFile, which the persist middleware invokes fire-and-forget:
saveTokens updates the in-memory store synchronously while the file
write lags. Tests verified persistence by polling the state file with a
2s/50ms `waitForStateFile()` budget, which flaked under full-suite load
(the file was written, the poll just didn't observe it in time).
Make the write completion awaitable instead:
- Track in-flight writeStoreFile() promises per resolved path in
store-io and add flushStoreFileWrites(filePath?) to await them (also
useful for graceful shutdown). Writes to the same path are chained so a
flush awaits every queued write.
- Convert the OAuth E2E, node storage, and file-storage-adapter tests
from poll loops (waitForStateFile / vi.waitFor on existsSync /
setTimeout) to `await flushStoreFileWrites(path)` + a single read.
- Remove the now-unused waitForStateFile() test helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(storage): doc load-bearing sync registration; rename stale debug env
Address @claude review on #1413:
- Document that writeStoreFile()'s synchronous pendingWrites registration is
load-bearing for flushStoreFileWrites(), in both the storage adapter's
setItem callsite and the pendingWrites docblock, so future refactors don't
introduce an await before registration and silently re-flake the tests.
- Rename the stale DEBUG_WAIT_FOR_STATE_FILE test env var to DEBUG_PERSIST_PATH
(waitForStateFile was removed in this PR).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(web): silence benign config-load dep warnings in `npm run dev`
`vite dev` printed three UNRESOLVED_IMPORT warnings at startup (chokidar,
atomically, @napi-rs/keyring). They originate from Vite's config bundler
(Rolldown): the Hono plugin statically imports the node-only dev backend
(core/mcp/remote/node/server.ts), so loading vite.config.ts bundles it and
Rolldown walks its node-only deps. Those deps are correctly externalized — the
browser never loads them and Node resolves them at runtime — so the warnings are
pure noise.
They can't be filtered the usual ways: the static import is required (the
monorepo has no node_modules under core/, so the aliases must resolve the deps
at bundle time — ssrLoadModule / `--configLoader runner` both fail to resolve
them), and the warnings are written directly to the process streams during
config load, bypassing customLogger, logLevel, and rollupOptions.onwarn.
Fix: a tiny `--import` hook (server/quiet-config-warnings.mjs) that drops only
those specific benign warning writes, wired into the `dev` script. Scoped to
`npm run dev` — build, tests, and prod are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(web): harden dev warning filter per review
- Tighten BENIGN_PAIRS to repo-relative path fragments (not basenames) so an
unrelated future UNRESOLVED_IMPORT warning mentioning the same dep + a
same-named file isn't silently dropped. Verified Rolldown prints the full
repo-relative path. (review #1)
- Add a note that the matcher tracks Rolldown's message format and degrades to a
safe no-op (warnings return) if that format changes. (review #2)
- Export isBenignWarning/BENIGN_PAIRS, guard the stream patching behind
!VITEST, add a .d.mts so the pure helper is importable from TS, and add a unit
test locking the signature pairs and the over-match guards. (review #3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(apps): render running MCP Apps end-to-end (#1370)
Wire the v2 Apps screen so clicking Open App actually runs an MCP App.
Previously App.tsx stubbed the three load-bearing pieces (sandbox URL,
bridge factory, action handlers), so the iframe loaded about:blank and
nothing crossed the bridge.
- Add useSandboxUrl hook (core/react) to read sandboxUrl from GET
/api/config and thread it down as sandboxPath; render an "MCP Apps
unavailable" state instead of a blank iframe when absent.
- Add createAppBridgeFactory: builds a host-side ext-apps AppBridge over
the active client's SDK client, reads the tool's UI resource on the
sandbox handshake and pushes it into the proxy, handles openLinks, and
connects a PostMessageTransport.
- AppRenderer: pass the tool to the bridge factory; buffer tool
input/result and release them once the view signals `initialized`
(input before result, latest-wins).
- Move the AppRenderer handle ref to App.tsx so onOpenApp issues
tools/call with the form values and delivers input + result to the
running app via the bridge; onCloseApp tears the bridge/iframe down.
- Stories: themed sandbox placeholder (visible in light/dark), an
echo-bridge running story, and an unavailable story. New unit tests
for the factory, the buffering, useSandboxUrl, and the empty state.
The listChanged indicator badge wiring is tracked separately in #1402.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(apps): make MCP Apps render in dev — single bridge + result passthrough
Two bugs kept apps (e.g. NightRider's MIDI Pattern Editor) stuck on an empty
shell even though the host fetched the resource and called the tool correctly.
1. StrictMode double-mounted the bridge. The dev-only effect setup→cleanup→
setup built TWO host bridges/transports, double-loaded the sandbox, and
raced the app's ui/initialize handshake (it timed out, so `initialized`
never fired and buffered input/result were never flushed). AppRenderer now
defers bridge disposal to a microtask and, when the effect re-runs
synchronously with identical inputs, cancels the disposal and reuses the
live bridge — so a single bridge survives the double-invoke. The pending
input/result buffer is no longer cleared on a transient cleanup.
2. The SDK rejected the server's result. callTool() validates structuredContent
against the tool's outputSchema and throws on a mismatch — but an MCP App is
a passthrough: the host must forward whatever the server returned to the
running view (which legacy hosts render fine). InspectorClient.callTool gains
a `skipOutputValidation` option that routes through client.request() (no
wrapper validation); onOpenApp uses it.
Verified end-to-end against a real app via Playwright: the editor now renders
its grid + pattern data identically to the v1 inspector.
Tests: AppRenderer StrictMode single-bridge/no-dispose case; a get_temp_extra
test-server fixture (returns an extra structuredContent property) with
integration tests covering both the throwing default path and the
skipOutputValidation passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(apps): warn (toast) when an app's result violates its outputSchema
The skipOutputValidation passthrough keeps schema-mismatched apps rendering,
but that silently hid a real spec problem from server developers. Now the
host still renders, but surfaces the mismatch.
- InspectorClient.callTool runs the SDK's own Ajv output validation on the
skipOutputValidation path NON-fatally and returns the message as
`outputValidationError` on the ToolCallInvocation (and the
toolCallResultChange event); the result is still delivered.
- App.tsx onOpenApp shows a yellow toast when outputValidationError is set,
explaining strict MCP clients may not render the app.
Tests: integration cases asserting outputValidationError is set for a
schema-violating result (get_temp_extra) and unset for a valid one (get_temp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(apps): summarize output-schema warning in toast + details modal
The previous toast dumped the full (very long) validation error and
overflowed. Now the toast shows a one-line summary with a "View validation
details" link that opens a modal containing the full error in a read-only,
scrollable textarea.
- New OutputValidationModal group component (+ story + tests).
- App.tsx: toast message is a summary + link; clicking it opens the modal
populated with the tool name and the full outputValidationError.
Verified via Playwright against the NightRider MIDI editor: toast is concise,
link opens the modal with the scrollable details.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(apps): address PR review — theme stability, onError, validation polish
Responds to the automated PR review on #1403.
- Theme toggle no longer rebuilds a running app's bridge. createAppBridgeFactory
reads the resolved theme from the DOM (Mantine's data-mantine-color-scheme) at
bridge-build time instead of capturing isDark in the factory deps, so the
factory identity is stable across theme flips. (#1)
- Wire AppRenderer's onError through AppsScreen -> InspectorView -> App.tsx,
surfacing bridge/runtime failures (no client, late rejection, failed
tools/call) as a red toast instead of a silent blank iframe. (#2)
- Extract validateToolOutput into a pure, unit-tested helper covering the
matching / extra-property / missing-structuredContent / error / bad-schema
cases (the no-structuredContent branch can't be produced via the SDK server,
which rejects it before sending). (#4)
- useSandboxUrl aborts its in-flight fetch on unmount (cancelled flag) to avoid
setState after unmount. (#5)
- Schema-mismatch toast: autoClose:false (details modal is one click away). (note)
- AppRenderer: add a state-machine comment block documenting the bridge
lifecycle refs. (#6)
- inspectorClient: note why the `as CallToolResult` casts are safe. (note)
Tests: AppsScreen onError surface; validateToolOutput unit suite; factory theme
read from DOM. validate + test:storybook green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(apps): clarify theme attribute + useSandboxUrl fetch comments
Re-review follow-ups (non-blocking):
- createAppBridgeFactory: note that Mantine's data-mantine-color-scheme is only
ever light/dark (auto is resolved before paint), so no `auto` branch is needed
and matchMedia only covers an absent attribute.
- useSandboxUrl: correct the doc to say it fetches on mount AND re-fetches on
baseUrl/authToken change (idempotent), not "once on mount".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(state): dispatch connect event after capabilities are populated
The list-state managers (tools/prompts/resources/templates) refresh on the
"connect" event and, since #1395, gate their list RPC on getCapabilities().
But connect() dispatched "connect" before fetchServerInfo() populated
this.capabilities, and refresh() runs synchronously up to its first await —
so the capability gate executed during the synchronous dispatch, read
undefined capabilities (reset to undefined on every disconnect), tripped,
and wiped every list to empty on connect. Tools, prompts, resources, and
resource templates all vanished against any server.
Move the "connect" dispatch to after the awaited fetchServerInfo() so
capabilities are in place before any manager refreshes. The unit tests
missed this because their FakeInspectorClient has capabilities populated
from construction; added an integration regression test wiring a real
ManagedToolsState on connect and asserting the tool list is non-empty
(fails without the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(integration): harden connect-race regression test per review
Address the two non-blocking suggestions from the PR review:
- Replace the 100ms settle sleep with waitForEvent() on the managers'
change events, so the test is deterministic against slow CI instead of
racing a fixed timeout.
- Assert a second affected primitive (ManagedResourcesState alongside
ManagedToolsState). The fix is a single shared line, but covering two
distinct capabilities guards against a future change gating one
primitive differently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull the four session-scoped UI resets (toolCallState / getPromptState /
readResourceState panels + optimistic currentLogLevel) out of the
disconnect listener into a colocated `resetSessionScopedUiState`
useCallback, so there's a single named place to extend as App.tsx accrues
more per-session state. The disconnect listener keeps its activeServerId +
modal-flag bookkeeping inline and calls the helper. Pure refactor — no
behavior change; the existing App.test.tsx disconnect test (asserts all
four pieces reset) is unchanged and still passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The four list-fetching state managers fired their list RPC against every
server on connect regardless of advertised capabilities, so a server that
doesn't implement a given primitive replied -32601 "Method not found",
surfacing in the console on every connect. Mirrors the `tasks`-capability
gate added for ManagedRequestorTasksState in #1349.
- managedToolsState → gate on capabilities.tools
- managedPromptsState → gate on capabilities.prompts
- managedResourcesState → gate on capabilities.resources
- managedResourceTemplatesState → gate on capabilities.resources
(the spec defines no separate resourceTemplates capability;
resources/templates/list is part of the resources surface)
When the capability is absent each manager sets an empty list, dispatches
its change event, and returns — the right semantics for "this server
doesn't support X."
Tests: existing flow tests now construct their FakeInspectorClient with
the relevant capability so the live list path is still exercised; added a
"refresh skips listX when capability absent" and a "connect against an
X-less server doesn't fire listX" test per manager. Updated the
useManagedX peers and resourceSubscriptionsState (which drives a
ManagedResourcesState refresh) to advertise the capability too.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): clear per-call panels and log level on disconnect (#1368)
Session-scoped UI state owned by App.tsx — the Tools / Prompts /
Resources result panels (`toolCallState` / `getPromptState` /
`readResourceState`) and the optimistic `currentLogLevel` — survived a
disconnect/reconnect cycle, so server B's screens showed server A's last
result. Extend the existing `disconnect` listener to reset all four back
to empty / "info", alongside the activeServerId + modal-flag resets it
already does. This covers all three disconnect paths the listener handles
(explicit toggle, header Disconnect button, mid-session transport
failure). `latencyMs` continues to reset via the connectionStatus effect.
Adds App.test.tsx covering the disconnect → tool-call-panel-cleared and
log-level-reset transition, mocking the InspectorClient (a fake
EventTarget), the per-server state managers, the core hooks, and
InspectorView.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(app): assert all four panels reset on disconnect (#1368)
Address PR review: the disconnect test asserted only toolCallState and
currentLogLevel. Extend the InspectorView double with prompt/resource
status spans and get-prompt/read-resource buttons so the test fills and
then asserts the reset of all four pieces of session-scoped state
(tool / prompt / resource panels + log level), fully matching the
"reset on disconnect" contract. Retitle the test accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)
Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.
Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:
- New shared helper `clients/web/server/inject-auth-token.ts` embeds
`<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
`</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
(`core/mcp/remote/constants.ts`).
Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)
OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.
Wire the two missing entry points (all core primitives already in place):
- Auto-trigger on 401: in onToggleConnection's catch, detect an upstream
401 (isUnauthorizedError) and call client.authenticate(), which runs
discovery + DCR (backend-proxied) and redirects the page to the auth
server via BrowserNavigation. The initiating server id is persisted to
sessionStorage first, since the OAuth `state` carries only mode+authId
and the full-page redirect wipes React state.
- /oauth/callback handler: a mount effect that, once `servers` hydrate,
parses the callback params, recovers the pending server, rebuilds its
InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client
info survive in BrowserOAuthStorage), replaceState("/") so a reload can't
replay the single-use code, then connect(). An `error=` callback toasts
instead of retrying.
connect() already attaches the OAuth provider to the transport
(inspectorClient.ts), so once tokens land in BrowserOAuthStorage the
outbound request carries the bearer token.
Extracted the pure pieces (constants + isUnauthorizedError) to
src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real
browser against the MCP SDK demo OAuth server: Connect -> redirect ->
auto-approve -> callback -> Connected, with the access token shown in the
Connection Info modal (#1377).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384)
After #1379, the Network tab showed only the post-redirect auth HTTP
(discovery re-run + POST /token); the pre-redirect discovery and the DCR
POST /register that run during authenticate() were lost when the page
navigated to the auth server.
Root causes:
1. Ordering — BrowserNavigation set `window.location.href` before the
client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect
*after* auth() already navigated), so the save raced the unload and was
dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate`
hook immediately before assigning location.href; App wires it through
createWebEnvironment to flush the active fetch log to RemoteInspectorClient
Storage (keyed by the authId parsed from the auth URL) via a keepalive POST
that outlives the unload.
2. Illegal invocation — RemoteInspectorClientStorage defaulted to
`this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which
re-binds `this` and makes native fetch throw "Illegal invocation"
(swallowed by the catch). This silently broke *all* session save/load.
Fix: default to a wrapper that preserves the global receiver.
3. Restore race — hydrateFetchRequests replaced the list, so a load that
resolved after the resuming connect appended live entries would clobber
them. Fix: merge restored (older) entries ahead of live ones, dedupe by id.
saveSession also now uses keepalive: true.
Verified end-to-end against the MCP SDK demo OAuth server: the connected
page's Network tab shows the full handshake — pre-redirect discovery + DCR
/register plus post-redirect discovery + /token as `auth`, alongside
`transport`. Added unit tests for the beforeNavigate ordering and the
hydrate merge/dedupe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(network): show auth response bodies with masked secrets + reveal toggle (#1386)
Auth-category Network entries showed request body/headers/status but never
the response body (rendered "(empty)"), because buildEffectiveAuthFetch
deliberately skipped capturing it to avoid surfacing access_token /
refresh_token. That hid the most useful thing to inspect when debugging
OAuth — the token exchange.
Capture auth response bodies, but mask sensitive OAuth fields by default
behind a click-to-reveal toggle so they aren't exposed at a glance during
a screen-share:
- inspectorClient: wire updateResponseBody on the auth fetcher.
- src/utils/maskSecrets.ts: maskSecretsInBody() masks access_token,
refresh_token, id_token, client_secret (case-insensitive, nested) in JSON
bodies; reports whether anything was masked. Non-JSON / secret-free bodies
pass through untouched.
- NetworkEntry BodyPreview: when a body has masked fields, render it masked
with a Reveal/Hide toggle (copy honors the shown view). Masking is a UI
concern; the raw entry is unchanged so reveal shows the real value.
access_token / refresh_token live in the post-redirect /token response,
which is never written to the session-restore files (#1384); only
pre-redirect bodies (public discovery, DCR /register) persist, so no bearer
token hits disk.
Verified end-to-end: the /token response shows masked by default
(access_token: "••••••••"), Reveal exposes the raw value, and discovery /
the public DCR /register response (no secret) render with no toggle. Added
util + component unit tests and a story play function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)
Addresses code-review feedback on the token-injection PR:
- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
served the raw index.html off disk via serveStatic, bypassing injection — so
a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
real static assets (paths with a dot) still serve verbatim. Dev already
injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
to sessionStorage (not just the URL-param branch), priming the backstop for
any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
carrying a token isn't cached and served stale after a restart regenerates
the token.
Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379)
Code-review feedback on the OAuth-wiring PR:
- Callback effect: split completeOAuthFlow vs connect() into separate
try/catch blocks. A token-exchange failure now reads "OAuth token exchange
failed … Please try connecting again." (the single-use code is spent and the
URL was cleared, so a reload can't retry); a post-OAuth connect failure reads
"Failed to connect" since OAuth actually succeeded and re-clicking Connect
reuses the persisted tokens.
- isUnauthorizedError: anchor the message fallback on the transport's
`failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)`
spliced into an error message can't trip the OAuth flow. Added a test.
- Documented that clearing the pending id + URL before the server lookup is
intentional (deleted/renamed server mid-flow → require a fresh Connect).
Also merges the squash-merged #1382 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384)
Code-review feedback on the OAuth Network-log persistence PR:
- Documented the double-save: `FetchRequestLogState`'s `saveSession` listener
is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary
flush for the redirect case. Notes the listener may lose the navigation race
and is harmless when it duplicates (last-writer-wins, identical payload).
- Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`:
the 64KB cap is general (the method is also reachable from the listener with
the full session log), so a long session could exceed it and drop silently —
acceptable since the persisted log is best-effort, not load-bearing.
- Added a regression test that constructs `RemoteInspectorClientStorage`
without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default
wrapper calls it (locks in the "Illegal invocation" fix, which callers
otherwise swallow).
Optional items (logger.warn on swallowed save errors; setupClientForServer dep
churn) acknowledged on the PR, not changed.
Also merges the squash-merged #1383 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(network): address #1387 review — form-encoded masking, cleaner flag, a11y (#1386)
Code-review feedback on the auth-response-body masking PR:
- Extended masking to form-urlencoded bodies (finding #1): the token *request*
is `application/x-www-form-urlencoded` and carries `code` / `code_verifier` /
`client_secret` / `refresh_token`. `maskSecretsInBody` now masks those in
form bodies too (preserving formatting; placeholder not percent-encoded).
`code`/`code_verifier`/`client_assertion` are form-only sensitive keys —
deliberately NOT masked in JSON, where `code` is usually an error/status code.
- Replaced the double-stringify `hasSecrets` heuristic with an explicit
masked-flag propagated out of `maskNode` (finding #2) — robust if the
transform ever grows non-identity behavior, and one fewer serialization.
- Reset reveal state on body change via `key={body}` remount instead of a
setState-in-effect (finding #3; avoids the cascading-render lint rule).
- a11y (finding #4): `aria-label` on the Reveal/Hide button and `aria-live`
on the hidden/revealed status text.
- Security tripwire comment near the `saveSession` listener (finding #6):
captured auth bodies are unmasked at source (masking is UI-only), so any new
post-token-exchange persistence path must redact first.
Tests: form-encoded masking (token + refresh requests), JSON `code` NOT masked,
non-object JSON pass-through, default-fetch wrapper. Story play + NetworkEntry
tests updated for the new aria-labels and the now-masked form request body.
Finding #5 (pretty-print asymmetry) is already handled by ContentViewer's JSON
formatting; #4-aria and #6 are the doc/a11y bits.
Also merges the squash-merged #1385 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(network): address #1387 third-pass review — content-type masking, wholesale mask, dedup (#1386)
Third-pass code-review feedback on the auth-body masking PR:
- maskSecretsInBody now takes the body's content-type (finding #1): `*json*`
→ JSON masking, form-urlencoded → form masking, any other known type
(HTML/plaintext/XML) → no masking; absent/unknown → sniff as before. Removes
the implicit "non-JSON ⇒ form" guess for error pages etc. NetworkEntry passes
the request/response `content-type` header through to BodyPreview.
- Mask any non-empty value under a sensitive key wholesale (finding #2):
a non-standard object/array value under e.g. `access_token` is replaced
rather than recursed into, so it can't leak. Empty-string still not flagged.
- Extracted `isSensitiveKey(set, key)` to dedupe the JSON/form key checks
(finding #3).
- Reworded `MaskResult.masked` doc to cover the form (non-pretty-printed) case
(finding #6).
Tests: non-string-value-under-sensitive-key wholesale mask; repeated form
param; empty form value not flagged; content-type honored (JSON type skips
form masking, text/plain skips masking, explicit form type masks).
Finding #5 (story re-hide) skipped per reviewer — unit test already covers
re-mask. Finding #4 (form edge cases) added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(network): address #1387 fourth-pass nits — content-type contract, key, form test (#1386)
Fourth-pass review (LGTM) — minor, non-blocking items:
- Documented the content-type matching contract on `maskSecretsInBody`:
substring match (`*json*` / `*x-www-form-urlencoded*`), and we trust the
wire's own label (a mislabeled body renders raw — acceptable for the
screen-share threat model) (finding #1).
- Key `<BodyPreview>` by content-type + body (not body alone) so a header-only
change would also reset the reveal state (finding #3; not reachable today).
- Storybook `AuthSuccess`: assert revealed count `>=` reveal-button count to
mirror the `hidden >= 2` check, so adding a non-masked body later can't drift
the assertion silently (finding #4).
- Added a `NetworkEntry` unit test for form-encoded request-body masking
(code/code_verifier) — previously exercised only via the Storybook play.
Finding #2 (silent decodeURIComponent fallback on malformed keys) acknowledged
as a known quirk outside the threat model; no change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(network): address #1387 fifth-pass — memoize masking, mask DCR mgmt token (#1386)
Fifth-pass review (LGTM):
- Memoized `maskSecretsInBody` in `BodyPreview` (recommended item #1): a
Reveal/Hide click no longer re-parses + re-walks the body; cost is once per
mount, and the `key` remount re-runs it on body/content-type change. Guarded
so a too-large body is never parsed (the hook runs unconditionally, with the
size check inside the memo).
- Added `registration_access_token` (RFC 7592 DCR management credential, same
bearer class) to the masked key set, and a confidential-client `/register`
response fixture test (#5) asserting client_secret + registration_access_token
masked, client_id/metadata visible.
- Tightened the `isMaskableValue` doc to state the exact contract: non-null,
non-empty-string values are masked wholesale (#3).
Items #2 (transport parse cost — capped by the memo) and #4 (test-only
MASK_PLACEHOLDER export) left as-is per the review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(network): clarify body-update emits list event only (#1387 sixth pass) (#1386)
Sixth-pass review (LGTM, all nits). Added a comment on
`onFetchRequestBodyUpdate` noting it re-emits only `fetchRequestsChange`, not
the per-entry `fetchRequest` event — list-reading consumers pick up the body
on the next render; a future per-entry subscriber would need its own event.
Other items left as-is per the review: full-string `BodyPreview` key (#1,
micro-perf only if profiled), NaN under a sensitive key (#3, consistent with
the mask-non-null-non-empty contract), `&`-only form separator (#4, OAuth
never emits `;`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)
Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.
Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:
- New shared helper `clients/web/server/inject-auth-token.ts` embeds
`<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
`</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
(`core/mcp/remote/constants.ts`).
Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)
OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.
Wire the two missing entry points (all core primitives already in place):
- Auto-trigger on 401: in onToggleConnection's catch, detect an upstream
401 (isUnauthorizedError) and call client.authenticate(), which runs
discovery + DCR (backend-proxied) and redirects the page to the auth
server via BrowserNavigation. The initiating server id is persisted to
sessionStorage first, since the OAuth `state` carries only mode+authId
and the full-page redirect wipes React state.
- /oauth/callback handler: a mount effect that, once `servers` hydrate,
parses the callback params, recovers the pending server, rebuilds its
InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client
info survive in BrowserOAuthStorage), replaceState("/") so a reload can't
replay the single-use code, then connect(). An `error=` callback toasts
instead of retrying.
connect() already attaches the OAuth provider to the transport
(inspectorClient.ts), so once tokens land in BrowserOAuthStorage the
outbound request carries the bearer token.
Extracted the pure pieces (constants + isUnauthorizedError) to
src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real
browser against the MCP SDK demo OAuth server: Connect -> redirect ->
auto-approve -> callback -> Connected, with the access token shown in the
Connection Info modal (#1377).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384)
After #1379, the Network tab showed only the post-redirect auth HTTP
(discovery re-run + POST /token); the pre-redirect discovery and the DCR
POST /register that run during authenticate() were lost when the page
navigated to the auth server.
Root causes:
1. Ordering — BrowserNavigation set `window.location.href` before the
client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect
*after* auth() already navigated), so the save raced the unload and was
dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate`
hook immediately before assigning location.href; App wires it through
createWebEnvironment to flush the active fetch log to RemoteInspectorClient
Storage (keyed by the authId parsed from the auth URL) via a keepalive POST
that outlives the unload.
2. Illegal invocation — RemoteInspectorClientStorage defaulted to
`this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which
re-binds `this` and makes native fetch throw "Illegal invocation"
(swallowed by the catch). This silently broke *all* session save/load.
Fix: default to a wrapper that preserves the global receiver.
3. Restore race — hydrateFetchRequests replaced the list, so a load that
resolved after the resuming connect appended live entries would clobber
them. Fix: merge restored (older) entries ahead of live ones, dedupe by id.
saveSession also now uses keepalive: true.
Verified end-to-end against the MCP SDK demo OAuth server: the connected
page's Network tab shows the full handshake — pre-redirect discovery + DCR
/register plus post-redirect discovery + /token as `auth`, alongside
`transport`. Added unit tests for the beforeNavigate ordering and the
hydrate merge/dedupe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)
Addresses code-review feedback on the token-injection PR:
- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
served the raw index.html off disk via serveStatic, bypassing injection — so
a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
real static assets (paths with a dot) still serve verbatim. Dev already
injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
to sessionStorage (not just the URL-param branch), priming the backstop for
any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
carrying a token isn't cached and served stale after a restart regenerates
the token.
Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379)
Code-review feedback on the OAuth-wiring PR:
- Callback effect: split completeOAuthFlow vs connect() into separate
try/catch blocks. A token-exchange failure now reads "OAuth token exchange
failed … Please try connecting again." (the single-use code is spent and the
URL was cleared, so a reload can't retry); a post-OAuth connect failure reads
"Failed to connect" since OAuth actually succeeded and re-clicking Connect
reuses the persisted tokens.
- isUnauthorizedError: anchor the message fallback on the transport's
`failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)`
spliced into an error message can't trip the OAuth flow. Added a test.
- Documented that clearing the pending id + URL before the server lookup is
intentional (deleted/renamed server mid-flow → require a fresh Connect).
Also merges the squash-merged #1382 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384)
Code-review feedback on the OAuth Network-log persistence PR:
- Documented the double-save: `FetchRequestLogState`'s `saveSession` listener
is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary
flush for the redirect case. Notes the listener may lose the navigation race
and is harmless when it duplicates (last-writer-wins, identical payload).
- Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`:
the 64KB cap is general (the method is also reachable from the listener with
the full session log), so a long session could exceed it and drop silently —
acceptable since the persisted log is best-effort, not load-bearing.
- Added a regression test that constructs `RemoteInspectorClientStorage`
without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default
wrapper calls it (locks in the "Illegal invocation" fix, which callers
otherwise swallow).
Optional items (logger.warn on swallowed save errors; setupClientForServer dep
churn) acknowledged on the PR, not changed.
Also merges the squash-merged #1383 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)
Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.
Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:
- New shared helper `clients/web/server/inject-auth-token.ts` embeds
`<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
`</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
(`core/mcp/remote/constants.ts`).
Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): wire OAuth authorization-code flow into App.tsx (#1379)
OAuth-protected MCP servers could not be connected to from the v2 web
client: the core OAuth pipeline exists, but App.tsx never invoked it, so a
connect attempt 401'd and surfaced "Remote send failed (401): … Missing
Authorization header" as a toast.
Wire the two missing entry points (all core primitives already in place):
- Auto-trigger on 401: in onToggleConnection's catch, detect an upstream
401 (isUnauthorizedError) and call client.authenticate(), which runs
discovery + DCR (backend-proxied) and redirects the page to the auth
server via BrowserNavigation. The initiating server id is persisted to
sessionStorage first, since the OAuth `state` carries only mode+authId
and the full-page redirect wipes React state.
- /oauth/callback handler: a mount effect that, once `servers` hydrate,
parses the callback params, recovers the pending server, rebuilds its
InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client
info survive in BrowserOAuthStorage), replaceState("/") so a reload can't
replay the single-use code, then connect(). An `error=` callback toasts
instead of retrying.
connect() already attaches the OAuth provider to the transport
(inspectorClient.ts), so once tokens land in BrowserOAuthStorage the
outbound request carries the bearer token.
Extracted the pure pieces (constants + isUnauthorizedError) to
src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real
browser against the MCP SDK demo OAuth server: Connect -> redirect ->
auto-approve -> callback -> Connected, with the access token shown in the
Connection Info modal (#1377).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)
Addresses code-review feedback on the token-injection PR:
- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
served the raw index.html off disk via serveStatic, bypassing injection — so
a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
real static assets (paths with a dot) still serve verbatim. Dev already
injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
to sessionStorage (not just the URL-param branch), priming the backstop for
any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
carrying a token isn't cached and served stale after a restart regenerates
the token.
Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379)
Code-review feedback on the OAuth-wiring PR:
- Callback effect: split completeOAuthFlow vs connect() into separate
try/catch blocks. A token-exchange failure now reads "OAuth token exchange
failed … Please try connecting again." (the single-use code is spent and the
URL was cleared, so a reload can't retry); a post-OAuth connect failure reads
"Failed to connect" since OAuth actually succeeded and re-clicking Connect
reuses the persisted tokens.
- isUnauthorizedError: anchor the message fallback on the transport's
`failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)`
spliced into an error message can't trip the OAuth flow. Added a test.
- Documented that clearing the pending id + URL before the server lookup is
intentional (deleted/renamed server mid-flow → require a fresh Connect).
Also merges the squash-merged #1382 base from v2/main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378)
Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`
query string) with empty sessionStorage made every `/api/*` request 401 —
the browser had no way to recover the backend's auth token.
Embed the token into `index.html` on every page load so the browser no
longer depends on the query string surviving navigation:
- New shared helper `clients/web/server/inject-auth-token.ts` embeds
`<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against
`</script>` injection; no-op when auth is dangerously omitted).
- Dev: the Vite plugin injects via `transformIndexHtml`.
- Prod: the Hono server injects on the `/` route.
- `App.tsx` `getAuthToken()` now reads the injected global first, then the
query string, then sessionStorage (both fallbacks preserved).
- Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL`
(`core/mcp/remote/constants.ts`).
Tests: helper unit coverage + an integration test exercising the real
prod server's `/` → `/api/*` flow (injected token authenticates; missing
token 401s). AGENTS.md documents the token-recovery order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378)
Addresses code-review feedback on the token-injection PR:
- Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously
served the raw index.html off disk via serveStatic, bypassing injection — so
a bookmark/reload at a non-`/` route with empty sessionStorage would 401.
Route the SPA fallback through the same `serveIndexHtml` (inject) helper;
real static assets (paths with a dot) still serve verbatim. Dev already
injected on every HTML serve via Vite `transformIndexHtml`.
- `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__`
to sessionStorage (not just the URL-param branch), priming the backstop for
any later navigation that loses the global.
- Injected HTML responses now send `Cache-Control: no-store`, so a page
carrying a token isn't cached and served stale after a restart regenerates
the token.
Integration tests added: SPA fallback (`/oauth/callback`) carries the token,
`Cache-Control: no-store` on injected HTML, real assets served verbatim, and
unknown `/api` routes 404 rather than falling through to the HTML shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server-info): wire Server Info modal to the connection handshake
Closes#1364
- Add ServerInfoModal wrapping the existing ServerInfoContent, mirroring the
ServerSettingsModal layout.
- Gate the Server Info button on ServerCard to render only when the
connection is "connected".
- Expose `getClientCapabilities()` on InspectorClient (and protocol +
FakeInspectorClient), and surface it through useInspectorClient so the
modal can render the client side of the capability handshake without
poking the SDK Client's private state.
- App.tsx: open the modal from the gated Server Info button, derive the
active server's transport and OAuth details (synchronously, from the
guided-OAuth state snapshot + persisted settings), and reset the open
flag on InspectorClient `disconnect` so the modal does not auto-reopen
on a future reconnect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server-info): dedupe modal header and render instructions in ContentViewer
- Drop the in-content "Server Information" Title from ServerInfoContent and
rename the modal title to "Server Information" so the modal renders one
heading instead of two stacked ones.
- Swap the instructions Blockquote for ContentViewer (copyable, with the
existing wrapping Code variant) so long instructions wrap and the user
can copy the full payload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(server-info): rename ServerInfo -> ConnectionInfo
The popup covers server identity AND client capabilities AND OAuth state —
it's a snapshot of the live connection, not just the server. Rename to match
what it actually shows. Also add a "Server Implementation" heading above
the top section (name/version/protocol/transport) to balance the existing
capability headings.
- ServerInfoContent -> ConnectionInfoContent (directory, file, exports)
- ServerInfoModal -> ConnectionInfoModal (directory, file, exports)
- ServerCard button label "Server Info" -> "Connection Info";
prop onServerInfo -> onConnectionInfo
- Modal title -> "Connection Info"
- App.tsx / ServerListScreen / InspectorView wiring updated to match
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(connection-info): cap instructions block height so it scrolls in place
A long server prompt (e.g. an Everything-style instruction document) was
pushing the OAuth section and the modal chrome off-screen — users had to
scroll the whole modal to reach the close button. Wrap the ContentViewer
in ScrollArea.Autosize with mah=280 so the instructions section scrolls
internally and the rest of the modal stays put.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(connection-info): add subtle background to section headings
Add a `section` variant for Title that paints the heading in the existing
`--inspector-surface-subtle` token (same surface token already used for
modal call-out panels). Apply it to each section heading in
ConnectionInfoContent (Server Implementation, Server Capabilities, Client
Capabilities, Server Instructions, OAuth Details) via a SectionHeading
subcomponent constant, so the sections read as distinct cards against the
modal background instead of bleeding into one stream of text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(stories): wrap long import line in ConnectionInfoModal.stories.tsx
Prettier flagged the import in CI. The line exceeded printWidth=80 and
needed to wrap onto multiple lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(agents): require `npm run format` before committing
Add a pre-commit instruction to run the auto-fixing `npm run format` ahead
of `npm run validate`. `validate` runs `format:check` (non-fixing) which
will fail CI on any unformatted file — running the auto-fixer first
eliminates that class of failure entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(favicon): use theme/assets/MCP.svg as the favicon source of truth
Point index.html's icon link at /src/theme/assets/MCP.svg so the same MCP
logo asset already imported by ViewHeader is also the favicon. Delete the
now-unused clients/web/public/favicon.svg. Vite resolves the source path
during build and emits a hashed copy under dist/assets/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server-card): surface failed-handshake errors with a 5s auto-dismiss
Two related fixes so the user actually sees what went wrong when a
connect attempt fails (e.g. OAuth-backed server, bad URL, missing dev
auth token):
- App.tsx: add `connectInFlightRef` and skip clearing `activeServerId`
in the InspectorClient `disconnect` listener while a connect attempt
is in flight. The transport closes during a failing handshake and
dispatches `disconnect` before the catch block sets `errorMessage`;
without this guard InspectorView would map the card to plain
"disconnected" and the InlineError would never render.
- InlineError: opt-in `autoDismissMs` prop that runs a Mantine `slide-up`
Transition exit animation after the message has been visible for the
given duration. Visibility is derived from a "dismissed message"
state (rather than a mounted boolean + setMounted-in-effect), so the
timer re-arms whenever `error.message` changes without tripping the
react-hooks/set-state-in-effect rule. ServerCard passes
autoDismissMs={5000}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(server-card): own InlineError visibility + animate parent-driven removal
Move the 5s auto-dismiss timer and visibility state out of InlineError
and into ServerCard so the slide-up exit animation runs in both cases:
- Timer-driven: after 5s the alert animates out (existing behavior).
- Parent-driven: when `connection.error` is cleared mid-flight (e.g. a
successful reconnect), the alert ALSO slides up instead of vanishing.
To keep the message painted during the parent-driven exit, ServerCard
tracks a `lastError` state that mirrors `connection.error` only on its
truthy edges. The conditional setState during render is the supported
React pattern for "remember the last seen value of a prop"; gated on
message inequality so it can't loop. The `useRef` version of this
pattern failed `react-hooks/refs` (no ref writes/reads during render).
InlineError is now a pure presentational component: `mounted` prop
controls a Mantine `slide-up` Transition. The internal `autoDismissMs`
prop is gone — its single caller has moved to the controlled `mounted`
pattern.
Closes the "error vanishes the moment the parent removes it" half of #1364
(the timer half landed in 6d147e0f).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(server-card): surface connection errors as a toast, drop InlineError
Move handshake-failure messaging out of the ServerCard body and onto a
Mantine toast (`notifications.show`). The inline alert had a 5-second
visibility contract entangled with InspectorView's status pipeline, and
the active card was flipping through error → disconnected → error during
a failing handshake; surfacing the error as a toast sidesteps that whole
timing problem and keeps the card focused on the live status indicator.
- App.tsx: toast on the connect-catch path; drop `errorMessage` state +
`connectInFlightRef` (the latter was only there to keep the inline
alert visible past the transport's close event).
- InspectorView: drop the `errorMessage` prop and the
`connectionStatus === "error" && errorMessage` mapping branch.
- ServerCard: drop the InlineError rendering + the displayedError /
errorVisible visibility state machine.
- Delete the InlineError component — no other consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(notifications): anchor toasts to the bottom right
Move the Notifications portal from top-right to bottom-right so a
connection-failure toast doesn't overlap the ViewHeader / tabs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: address PR feedback on the Connection Info modal
1. Hoist `EMPTY_CLIENT_CAPABILITIES = Object.freeze({})` to module scope
in `useInspectorClient` so the fallback returns a stable reference
(avoids invalidating downstream `useMemo`/`useEffect` deps when no
client is attached).
4. Drop the `?? "stdio"` silent fallback on `connectionInfoTransport`
in the JSX — gate the modal render on `activeServer` too. The
"stdio" default still lives at the derivation site (config.type is
schema-optional and `ServerType` is required by the modal prop),
but the gate makes the invariant explicit.
6. Replace the raw `document.querySelector("button.mantine-CloseButton-root")`
in `ConnectionInfoModal.test.tsx` with `screen.getByRole("button",
{ name: /close/i })`. Add `aria-label="Close"` to the
CloseButton so the assertion (and screen readers) have a name to
match on.
7. Switch the OAuth scope splitter from `/\s+/` to the literal `" "`
per OAuth 2.1 §3.3.
Also clarify in comments:
2. Note that `connectionInfoOAuth` uses snapshot-at-open semantics on
purpose — token refreshes mid-session won't re-render the modal.
5. Note that we prefer the AS-advertised `authorization_endpoint`
over `authorizationUrl` because the latter is per-flight noise
(state, code_challenge) rather than a stable identifier.
Finding 3 (modal exit animation short-circuited by the `{initializeResult
&& …}` guard) left as intentional — we want the snap-shut on disconnect
so a stale info dialog can't keep rendering with no live client behind it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(connection-info): tighten the OAuth memo comment per re-review
The reviewer noted that "snapshot-at-open" was imprecise — the memo
re-runs on any of its deps (connectionStatus, inspectorClient,
activeServer), which includes settings edits that re-reference the
active server. Reframe as "snapshot-at-last-derivation" and call out
the natural triggers (server switch, reconnect, settings edit) so a
future reader doesn't conclude the memo is open-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sort): add SortToggle for Logs, History, and Network (#1371)
Introduces a reusable SortToggle element and wires it into the Logs,
History, and Network screens so users can flip chronological order.
Selection is persisted per-screen to localStorage under the
inspector.sortDirection.* namespace, with invalid stored values falling
back to the newest-first default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(logs): move auto-scroll checkbox left of the sort selector
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(logs): remove auto-scroll checkbox; ListToggle left of SortToggle
- Drops the Logs auto-scroll checkbox and its plumbing through
LoggingScreen / InspectorView.
- Moves the ListToggle to the left of the SortToggle on the History
and Network panel toolbars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sort): address review on #1372
- Hydrate sort direction synchronously (getInitialValueInEffect: false)
to remove the one-frame newest-first flicker on first paint.
- Drop the redundant `[...matched].sort()` copy across the three panels;
`.filter()` already returns a fresh array.
- Document why serializeSortDirection bypasses Mantine's default JSON
stringify (raw enum value in storage).
- MemoryStorage.getItem: collapse `has` + `!` to `?? null`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(history): default the request list to collapsed for parity with Network
The History list opened expanded while Network opened collapsed; align
History with Network so both panels start in the same compact view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(history,network): persist list compact state to localStorage
Lifts the per-panel `compact` flag out of HistoryListPanel /
NetworkStreamPanel and into InspectorView's localStorage hooks, mirroring
the sort-direction shape:
inspector.listCompact.history
inspector.listCompact.network
Defaults to collapsed (compact=true) on both, with a corrupted-value
deserializer that clamps unknown strings back to the default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers,resources): persist ListToggle state to localStorage
Lifts the per-screen compact preference out of ServerListScreen and
ResourceControls into InspectorView's localStorage hooks, matching the
History/Network shape:
inspector.listCompact.servers
inspector.listCompact.resources
Servers and Resources both default to expanded (compact=false). For
ResourceControls the displayed compact is still derived from openSections
(so per-section accordion clicks still work locally), and only explicit
ListToggle clicks update the persisted preference — the handler computes
the next compact value from the click outcome rather than blindly
flipping, so a half-open accordion still records the right post-state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sort-toggle): switch to icon button matching ListToggle style
Replaces the Mantine Select with a subtle Button (or ActionIcon for the
subtle variant), using TbSortDescending2 for newest-first and
TbSortAscending2 for oldest-first. Clicking the button flips between the
two values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sort-toggle): restore Select with sort icon in rightSection
Reverts SortToggle to a Mantine Select with simplified labels ("Newest
First" / "Oldest First") and the corresponding TbSortDescending2 /
TbSortAscending2 icon in the rightSection, replacing the default chevron.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sort-toggle): swap icons so they match label direction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sort-toggle): use down arrow for newest, up arrow for oldest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(inspector-view): extract useSortDirection / useListCompact
Collapses seven near-identical useLocalStorage call sites in
InspectorView into two scoped helpers. Each helper carries the shared
namespace, serialize/deserialize adapters, and getInitialValueInEffect:
false; the call sites now read as "this screen has sort persisted to
this scope" rather than re-stating the storage shape every time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sort-toggle): apply prettier formatting
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(export): wire History + Logs export JSON handlers (#1354)
Replaces the `todoNoop` placeholders in App.tsx with real handlers that
serialize the current entries and trigger a browser download via the
existing `downloadJsonFile` helper.
- Adds `buildExportFilename(kind, serverId, now?)` next to the download
helper. Produces sortable, filesystem-safe names like
`inspector-history-alpha-2026-03-17T10-00-42.123Z.json` (URL-encodes
server ids that contain slashes / spaces / colons, swaps `:` for `-`
in the ISO timestamp so the file is safe on Windows).
- Wires `onExportHistory` (full MessageEntry list) and `onExportLogs`
(the LogEntryData list the screen renders).
- Routes the existing Network export through the same filename helper
for consistency.
Unit tests cover the filename builder, including the no-server-id
fallback, special-character encoding, and the default-`now` shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(export): tighten buildExportFilename signature + docs
- Introduce an `ExportKind = "history" | "logs" | "network"` union and
use it as the type of the `kind` parameter. Typos like "hisotry" now
fail at compile time and the supported export kinds are documented
inline.
- Clarify the JSDoc: the segment is omitted when `serverId` is falsy
(undefined OR empty string), matching the existing `serverId ? ... :
undefined` check. New test pins the empty-string branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(logging): remove Copy All button from LogStreamPanel
Export now produces the same JSON the Copy All button used to dump to
the clipboard, so the button is redundant. History and Network panels
have no equivalent — dropping Copy All also brings Logs into parity.
Removes the prop from LogStreamPanel + LoggingScreen + InspectorView +
App.tsx, and updates the matching tests/stories. Tests now assert the
button is *not* in the document so a future re-introduction would have
to update the assertion deliberately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(network): add Network screen surfacing backend fetch log (#1355)
Routes `FetchRequestLogState` to a new Network tab so users can inspect
HTTP-layer request/response headers, status, duration, and category
(auth vs transport) of every fetch the backend makes for an active
session. Adds `NetworkControls` + `NetworkStreamPanel` + `NetworkEntry`
groups, a `monoBreak` Text theme variant for long header values, and
wires onClearNetwork / onExportNetwork (JSON download) from `App.tsx`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(network): include request / response bodies in search filter
Headers were already covered but bodies were not, so searches for
strings that live inside the JSON-RPC payload (e.g. "jsonrpc",
"initialize", a tool name) silently returned no matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(network): always render Response Body section, note streaming
Streaming responses (text/event-stream, application/x-ndjson, POST to
/mcp) are intentionally not body-captured by the fetch tracker, so the
section was silently omitted. Now whenever a response was received we
render the section, with either the body, a "(empty)" note, or a
"Streaming response — body not captured" note keyed off content-type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(network): capture response bodies via async body-update events
Two related fixes:
1. The fetch tracker was marking every streamable-HTTP POST /mcp call as
"not captured" because the response content-type is text/event-stream.
In practice those streams are bounded — the server sends the
JSON-RPC reply (sometimes preceded by progress events) and closes —
so we can clone the body and read it. Only GET + SSE / ndjson is the
unbounded long-lived channel that has to stay uncaptured.
2. Reading the body inline blocked the transport from processing
progress notifications until the entire stream finished, breaking the
`resetTimeoutOnProgress` integration test. The body is now read in
the background and dispatched via a new `fetchRequestBodyUpdate`
event that the FetchRequestLogState patches into the matching entry
by id. Plumbing wires through node/transport, the remote SSE channel
(RemoteSession, RemoteClientTransport), and the remote event types.
Also hides the Network tab when the active server is stdio (no HTTP
traffic to surface) and bumps the inline body preview limit to 100 KB
so typical tools/list responses render rather than tripping the "too
large" notice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(network): show orange SSE badge for long-lived stream entries
Adds a small "SSE" badge to the right side of the NetworkEntry header
(left of the HTTP status badge) when the response is a long-lived
streaming channel (GET + text/event-stream or application/x-ndjson) —
the only case where the body genuinely isn't captured. Makes the
"not captured" placeholder less surprising at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(network): address PR review findings
- Stop capturing OAuth response bodies (drop updateResponseBody from the
effectiveAuthFetch tracker). Restores pre-PR behavior for auth-category
entries so live access_token / refresh_token values can't leak into the
Network tab body preview during a screen-share. Headers + status are
still tracked.
- Extract `isLongLivedStreamResponse(method, contentType)` to
core/mcp/fetchTracking so the rule lives in one place; consumed by the
tracker and the NetworkEntry UI placeholder.
- Document the intent of NetworkEntry's `useEffect` (the list-level
toggle is authoritative; per-entry overrides are intentionally
discarded on each list toggle).
- Rename MAX_INLINE_BODY_BYTES → MAX_INLINE_BODY_CHARS (`.length` is
UTF-16 code units, not bytes) and update the user-facing notice.
- Switch NetworkStreamPanel's search from joined-string `.includes` to
per-field `.some(.includes)` so a term can't span field boundaries.
Regression test added.
- Add aria-label to ListToggle ("Expand all" / "Collapse all") — both an
a11y improvement and a stable selector that lets the panel test stop
fishing the button out by text-content negation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(network): use ListToggle aria-label in panel + modal tests
Cleanup spotted in PR review follow-up:
- HistoryListPanel + ServerSettingsModal previously fished the ListToggle
out by empty text content because the toggle had no accessible name.
Now that ListToggle exposes "Expand all" / "Collapse all", both tests
can use getByRole + name and the stale comments go away.
- ListToggle's own tests now assert the aria-label contract so a future
refactor can't silently drop it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(network): expose category toggle state via aria-pressed
The category UnstyledButtons in NetworkControls previously communicated
their toggled state only via background color. Adds aria-pressed={active}
so assistive tech can discover the on/off state without visuals, with a
test that asserts the attribute tracks the visibleCategories prop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four ServerConfigModal play functions used `findByText("Add server")`
etc. to assert the modal mounted. Mantine's Modal portals to document.body
outside the storybook canvas, and its first paint can race against the
1000ms default that `findBy*` queries retry against — especially in the
Storybook dev UI (slower than `test:storybook`'s headless browser,
particularly with devtools open or coverage instrumentation on). When the
play fired before the modal mounted, findByText timed out, Storybook
flagged the story as FAIL, and left the "preparing" loader visible.
Switch to `findByRole('dialog', { name })` with a 5s timeout, and scope
subsequent queries (`getByLabelText`, etc.) to the dialog's subtree via
`within(dialog)`. The role-based query asserts the dialog actually mounted
(more semantically meaningful than a free text lookup), the longer ceiling
covers slow first-paint, and scoping to the dialog avoids false-positive
matches from sibling loader overlays.
Verified locally: 1758 tests pass, 8/8 consecutive reloads of the Add Empty
story in the Storybook dev UI now show "Pass".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): make settings form re-render on every keystroke (#1361)
The settings modal is fully controlled by App.tsx, but pending edits were
stashed in a useRef — refs don't trigger re-renders, so the input's `value`
prop stayed stale until the 300ms debounce + PUT + refetch round-tripped.
Clicking "Add Header" / "Add Metadata" showed no row, and typing in OAuth
fields had no character echo, until that round-trip completed (~500ms).
The bug landed in #1353 with the debounced-flush pattern. It surfaced after
#1356 because per-keystroke PUTs now also write the keychain, making the
round-trip latency more noticeable.
Lift the draft into `useState`. Every change re-renders the modal
immediately. Keep the 300ms debounce on the PUT side. Initialize the draft
from the server-list entry only when the modal opens to a new target (not
on every `servers` change) so background refreshes don't clobber the user's
in-progress edits. Flush on close to preserve final keystrokes.
Verified live against the dev server: header/metadata rows appear on click,
OAuth Client ID / Client Secret typing echoes without lag, and the values
persist correctly (clientId in mcp.json, clientSecret in the macOS
Keychain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(servers): extract useSettingsDraft hook + tests; review polish (#1361)
Picks up the three review observations on #1362:
1. Extract the draft state + debounce + flush logic from App.tsx into
`core/react/useSettingsDraft`. Same behavior; thinner App.tsx;
testable in isolation with `renderHook`. Tests pin the regression
#1361 was about (synchronous onChange → draft update) plus the
debounce window, burst-collapse, flush-on-close, error propagation,
and the "background refresh must not clobber in-progress edits"
contract via a `resolveInitial` closure swap.
2. Hoist the empty `InspectorServerSettings` fallback to a module-scope
`EMPTY_SETTINGS` const. Both the hook's init path and the modal's
closed-state fallback share the same object identity; the inline
`useMemo` that previously re-derived it is gone.
3. Add a comment on the `resolveInitial` callback noting that we read
`servers` from this render's closure under the assumption that the
entry point is the "Settings" button on a rendered server card.
Documents the contract for future callers (e.g. a keyboard shortcut
that opens the modal pre-hydration).
Validate clean: 1756 tests pass. New useSettingsDraft.test.tsx adds 13
test cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(servers): stabilize flush identity + add unmount-cleanup test (#1361)
Two non-blocking observations from the third review on #1362:
1. `flush` identity churned on every keystroke because the useCallback
closed over `draft` + `targetId`. Read both through refs instead so
the returned callback identity is stable. The behavior is unchanged
— but the consumer's `onSettingsModalClose` (which wraps `flush`)
no longer re-allocates per keystroke, which means the modal's
`onClose` prop identity also stays stable. A new test pins this.
2. Added a comment in `onChange` calling out that callers switching
`targetId` mid-debounce must `flush()` first to preserve the
pending PUT. The only switch path today (`onSettingsModalClose` in
App.tsx) does that already — the comment is for future refactors.
3. New unmount-cleanup test pins the intentional "drop final
<debounceMs window on unmount" trade-off — flushing on unmount
could fire a PUT against an unmounting component, which is a
worse footgun than losing a few hundred ms of typing on route
change / HMR.
Validate clean: 144 files, 1758 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356)
mcp.json is designed to be tool-shareable (symlinked from Claude Desktop's
config, pasted into bug reports, synced via dotfiles), so storing OAuth
client secrets and stdio env values in plaintext there meant any of those
flows could leak them. Lift both into the OS keychain via @napi-rs/keyring
(active replacement for the archived keytar). The wire shape is unchanged:
the GET /api/servers handler rehydrates from the keychain so browser code
sees the same JSON; the on-disk file no longer contains the secret
material. Includes an idempotent migration that lifts plaintext from older
mcp.json files (or hand-edited ones) into the keychain on first read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): tolerate keychain unavailability on non-set ops (#1356)
CI on Linux failed because `@napi-rs/keyring` hard-errors without
libsecret, and the route handlers were touching the keychain even for
no-secret flows (defensive `deleteAllForServer` on POST/DELETE, sweep
on PUT). The user's intent for the Linux fallback was "hard fail when a
secret is actually involved" — not on every keychain touch.
Make `KeyringSecretStore.get / delete / deleteAllForServer` silently
degrade when the keychain is unavailable (return null / no-op); `set`
remains the one operation that hard-fails with
`KeychainUnavailableError`. The migration path catches the error and
preserves the disk plaintext rather than losing the secret.
Also inject `InMemorySecretStore` in `useServers.test.tsx` and
`servers-events.test.ts` so tests never touch a developer's real
keychain even when libsecret is present.
Adds `KeyringSecretStore` tests (vi.mock'd native bindings) and an
integration suite that simulates Linux-without-libsecret to verify the
tolerance contract end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): close GET migration race; reverse keychain/disk order (#1356)
Addresses code review:
1. **Concurrency**: GET /api/servers was reading + migrating outside the
write lock, then writing inside it — a concurrent POST/PUT/DELETE
between the read and the lock acquisition could be clobbered by the
migration write. Also affected the first-launch seed write. Fix:
take a fast unlocked peek; if the file is absent (seed branch) or
carries plaintext to migrate (migration branch), re-read inside the
write lock and decide there. Added `hasPlaintextSecrets()` as a
pure predicate so the common no-migration path stays lock-free.
2. **Write ordering**: POST and PUT wrote disk first, then keychain.
A keychain `set` failure mid-write left a half-configured entry on
disk and trapped retries at 409. Reversed to keychain-first so a
503 leaves both stores in their pre-write state. PUT in-place now
sets new fields, writes disk, then deletes obsolete fields (a
failed disk write leaves orphan keychain entries — recoverable —
rather than premature deletions of fields the user still expects
to see).
3. **Tolerance simplification**: dropped the no-entry regex in
`KeyringSecretStore.delete` — both "no entry" and "keychain
unavailable" collapse to the same desired outcome (the entry isn't
there anymore), so a uniform swallow is clearer.
4. **Parallelism**: `readKeychainEntriesFor` now `Promise.all`s the
per-field `get` calls. macOS Keychain round-trips are 10-50ms;
serializing 5+ env vars per server × N servers is a meaningful
wall-clock cost on GET.
5. **Spec doc**: reflects the tolerance contract (only `set`
hard-fails) and the keychain-before-disk write ordering rationale.
New regression test in servers-route.test.ts covers the retry-after-503
contract directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(servers): parallel keychain writes + migration partial-state comment (#1356)
Two nice-to-have observations from the re-review:
- `writeKeychainEntriesFor` now `Promise.all`s its per-field sets,
matching the read side. No ordering requirement among distinct
(id, field) keys; on unavailability every set rejects and Promise.all
surfaces the first rejection (still 503 from the route handler).
- `migratePlaintextSecrets` catch block now spells out the
partial-migration semantics: we return the original config so the
disk plaintext stays intact, and the next successful GET retries
with the keychain-wins branch absorbing already-set entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(servers): parallel keychain deletes for symmetry (#1356)
Picks up the symmetry observation from the third review: `deleteKeychainFields`
now uses `Promise.all` like its read and write siblings. Distinct (id, field)
deletes have no ordering requirement, and `secretStore.delete` is already a
silent no-op on unavailability so Promise.all has no failure-mode surprise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): flatten mcp.json settings to top-level fields (#1358)
Eliminate the nested `settings` block introduced in #1352 so each
server entry's Inspector-extension fields live as direct keys alongside
`type` / `url`, matching the `.mcp.json` shape Claude Code / Cursor /
Cline write:
- `headers` becomes a `Record<string, string>` on disk (was nested
`settings.headers` as a pair-array)
- `oauth` becomes a nested `{ clientId?, clientSecret?, scopes? }`
object (was flat `settings.oauthClientId` / `oauthClientSecret` /
`oauthScopes`)
- `metadata`, `connectionTimeout`, `requestTimeout` move to the
entry's top level as-is (Inspector-only fields)
The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings`
keeps pair-array `headers` and flat `oauth*` fields because the form
needs them in that shape to drive controlled-component editing. The
bidirectional conversion lives in `serverList.ts`
(`storedFieldsToInspectorSettings` / `inspectorSettingsToStoredFields`)
and is invoked by `mcpConfigToServerEntries` / `serverEntriesToMcpConfig`
and by the server route's `buildStoredEntry`. The `PUT /api/servers/:id`
wire envelope keeps the preserve/clear/apply semantics from #1353 — the
backend splats validated `settings.*` onto top-level disk keys when
assembling the next on-disk shape.
Hard-cutover migration per the issue's decision 4: files written by the
one pre-#1358 v2/main build have a nested `settings` block; we drop the
node on read with a one-line warn including the server id. The persisted
headers / metadata / timeouts / OAuth credentials are intentionally lost
on first read — users re-enter them via the form (or hand-edit the file
into the flat shape). v2 has not shipped a stable release with the
nested shape, so the blast radius is dogfooders only.
The smuggle-guard in `buildStoredEntry` widens from just `settings` to
all six Inspector-extension keys (`settings`, `headers`, `metadata`,
`connectionTimeout`, `requestTimeout`, `oauth`), so a caller can't
bypass `validateSettings` by nesting any of them inside `config`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR review on flatten (#1359)
- Single-source the Inspector-extension key list with `satisfies`
(finding 1). `INSPECTOR_FIELD_KEY_MAP satisfies Record<keyof
StoredInspectorFields, true>` in serverList.ts means a new field on
the type forces a compile error here. The set is exported and
re-used by the server route's smuggle guard, so adding `proxy` or
similar in the future updates all three sites in lockstep. The PUT
preserve path now uses a new `stripInspectorFields` helper that
derives from the same source instead of hand-destructuring.
- Add read-path shape validation in `normalizeMcpServers` (finding 2):
`headers: "oops"` / `metadata: 42` / `oauth: []` / non-numeric
timeouts are dropped individually with a warn, so a hand-edited
malformed file can't put garbage rows into the form. Mirrors the
write-side `validateSettings` symmetry. New integration test covers
each malformed shape with a partial fixture so a single bad key
doesn't take out the whole entry.
- Align new warns to the `fileLogger || console.warn` fallback pattern
(finding 3) via a small `logWarn` helper, so the legacy-drop and
smuggle warns are visible in `npm run dev` where the logger is
often unconfigured.
- Spec doc tweak: the CLI/TUI out-of-scope note's "writes to
`settings.headers`" reference now reads "writes to the entry's
top-level `headers` field on disk (post-#1358 flat shape)" (finding
4). Avoids confusion for future readers.
- Nits: replace `current.mcpServers[originalId]!` with explicit
narrowing; add a comment on the OAuth truthiness drop in
`storedFieldsToInspectorSettings` mirroring the write-side comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address pass-2 review on flatten (#1359)
- Tighten `isNonNegNumber` with `Number.isFinite` so `Infinity` and
`NaN` are rejected at the read boundary, matching the write-side
intent (finding D). Hand-edited `connectionTimeout: Infinity` would
have passed the prior check and propagated to the form.
- Add a one-line comment in `normalizeMcpServers` noting that the
per-entry object is mutated in place via the `delete` calls — safe
for today's callers (freshly parsed JSON, no other reference held)
but worth flagging for the next reader (finding A).
Skipping findings B (move type guards to module scope — single call
site) and C (`isOauthObject` lenient on extra keys — reviewer
confirmed this is the intended forward-compat behavior, matching
`normalizeServerType`'s unknown-key passthrough).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(servers): fix mutation-comment caller branch label (#1359)
The previous comment named the GET handler's "seed branch" as a caller
of normalizeMcpServers, but that branch returns DEFAULT_SEED_CONFIG
directly without normalizing. The actual second caller is the
file-present branch (after parseStore). Correcting the label so a
future reader looking for the seed branch caller doesn't waste time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): hot-reload server list on external mcp.json edits (#1345)
Watch ~/.mcp-inspector/mcp.json from the backend and push a change event
on a new /api/servers/events SSE channel. The web client subscribes via
fetch + ReadableStream (so the existing bearer-auth contract is
preserved — EventSource can't send custom headers) and re-fetches on
each event. The watcher is lazy: it starts on the first SSE subscriber
and stops when the last one disconnects, so tests that never open the
channel don't spin up a real fs watcher. Self-fires from the backend's
own POST/PUT/DELETE are suppressed by capturing the post-write mtime and
comparing it on each watcher event.
Chokidar is used for cross-platform editor-save semantics (temp-file +
rename produces unlink+add sequences that bare fs.watch handles poorly
on macOS/Linux); awaitWriteFinish coalesces those into a single event.
createRemoteApp now returns a close() to release the watcher, which the
standalone server and the vite dev plugin chain into their existing
close paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR review on hot-reload (#1357)
- writeMcpAndTrackMtime now stat()s the file before writing; if the
mtime differs from the last value we recorded, an external editor
slipped in between our writes and chokidar would coalesce both into
one event whose mtime matches our own write — broadcast directly so
peer subscribers learn about the external edit (review finding 1, 5).
- Split useServers.refresh into a public refresh() (loading toggle) and
an internal refreshInternal(background) the SSE handler calls; the
list now stays visible during background refreshes instead of
flashing skeleton state on every external mcp.json edit (finding 2).
- Coalesce multiple \n\n frames landing in one decode chunk to a single
refresh call so back-to-back broadcasts don't race two concurrent
GETs whose setState order is unspecified (finding 3).
- Clarify createRemoteApp's close() docblock about in-flight SSE stream
callbacks (finding 7).
- Add integration coverage for PUT/DELETE self-suppression and for the
external-edit-between-backend-writes broadcast (finding 6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address pass-2 review on hot-reload (#1357)
- Clarify writeMcpAndTrackMtime comment so a future reader can't
mistake the broadcast for a content-preservation guarantee: the
external edit's content may already have been read-around and
overwritten depending on the interleave with readMcpConfig.
- Drop the no-op GET in the PUT/DELETE self-suppression tests. The
pre-stat branch is short-circuited by `lastWrittenMtimeMs === null`
with or without it; matches the POST test's structure.
- Add explicit no-flicker regression test: tracks every `loading`
value across renders during an SSE-driven refresh and asserts none
flipped back to true. Catches a future regression that reaches for
setLoading(true) in the background path even under React batching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): persist per-server settings to mcp.json (#1352)
- Add optional `settings` node alongside the transport config in each
`~/.mcp-inspector/mcp.json` entry. Round-trips through
`mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and the
`/api/servers` POST/PUT routes.
- Drop `MCPServerConfig.headers` from `SseServerConfig` /
`StreamableHttpServerConfig` and remove the headers textarea from
`ServerConfigModal`. Custom headers are now entered only in
`ServerSettingsForm` and reach the wire via `settings.headers`.
- Wire `ServerSettingsModal` into `App.tsx`; saves dispatch through a new
`useServers.updateServerSettings` mutator. `updateServer` carries the
existing settings across config-only saves so they aren't dropped.
- Apply settings on the *first* outbound request after a server loads —
no need to open the settings form first:
- `settings.headers` → SSE / streamable-http wire headers.
- `settings.metadata` → `InspectorClientOptions.defaultMetadata`,
merged into every outgoing `_meta` (call-time keys win).
- `settings.requestTimeout` → `InspectorClientOptions.timeout`.
- `settings.connectionTimeout` → Promise.race wrapper around connect().
- `settings.oauth*` → pre-seeded OAuth client credentials.
- `RemoteConnectRequest` carries `settings` so the remote backend can
source transport headers from the same source as the local path.
- `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env`
values, both protected by the file's `0o600` permission.
- Update `specification/v2_servers_file.md` with the new shape and the
removal of the legacy headers field.
Tests: round-trip in `serverList.test.ts`; persistence on POST/PUT in
`servers-route.test.ts`; first-connect header injection in
`transport.test.ts`; `defaultMetadata` merging in
`inspectorClient.test.ts`; `useServers.updateServerSettings` +
`updateServer` settings-preservation in `useServers.test.tsx`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): rebuild InspectorClient on every connect so settings refresh
The previous onToggleConnection branch reused the existing InspectorClient
on a same-server reconnect, which froze settings at the moment the client
was first constructed. Editing headers, metadata, timeouts, or OAuth
credentials in the settings form was therefore invisible until the user
either switched servers or reloaded the page — surprising right after a
visible save.
Always call setupClientForServer on (re)connect so the latest
target.settings flow through to the transport's headers / requestInit, to
defaultMetadata, and to the OAuth manager.
Also adds remoteClientTransport tests that pin the wire-level contract:
settings round-trip through the /api/mcp/connect body when supplied, and
the field is omitted when no settings are configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(history): stop wiping pendingRequestEntries inside the connect event
State managers (ManagedToolsState, ManagedPromptsState, ManagedResourcesState,
ManagedResourceTemplatesState, ManagedRequestorTasksState) handle the
client's "connect" event by calling `void this.refresh()`. The synchronous
portion of refresh runs through SDK Protocol.request → transport.send →
MessageTrackingTransport.send → trackRequest, all before yielding to the
network — so each list request gets tracked into MessageLogState's
pendingRequestEntries inside the same connect event dispatch.
MessageLogState's own "connect" listener was registered after those state
managers (it's the last one constructed in App.tsx) and cleared
pendingRequestEntries on every connect. Listener order meant the clear ran
*after* the requests were tracked but before their responses arrived,
leaving the history with unmatched "response" entries marked PENDING.
The clear-on-disconnect (via statusChange → disconnected) already handles
session-boundary cleanup, so clear-on-connect was purely defensive and now
actively harmful. Drop the connect listener; keep the disconnect path.
Tests: updated the unit test to assert the new behavior (connect does not
clear); added an integration regression that wires a sibling connect
listener firing listTools and asserts the response folds into the request
entry instead of appearing as an orphan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: drop accidentally tracked debugging screenshot
pending-requests.png was a local debugging artifact attached to a
conversation, not source. Remove it from the tree (the working copy is
kept so the user still has it locally) and rely on the existing untracked
behavior going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(servers): add e2e check that settings.headers reach upstream MCP
Stitches the full v2 pipeline together in one assertion:
browser-style RemoteClientTransport
→ POST /api/mcp/connect (settings in body)
→ backend createTransportNode applies settings.headers to requestInit
→ SDK StreamableHTTPClientTransport
→ upstream MCP test server
Captures the upstream HTTP request via FetchRequestLogState and asserts
both `X-Tenant` and `X-Trace` are present on the outbound POST. Catches
any regression that breaks the wire between the form and the upstream
server in a single integration test, instead of the previous coverage
which exercised each hop in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ServerSettingsModal): invert ListToggle compact prop so icon matches state
ListToggle's `compact` prop represents the list's *current* compact state:
true means "currently compact" and renders the expand icon to advertise
the action (open up). The settings modal was passing
`compact={allExpanded}` — exactly inverted. When all sections were open
it showed the expand icon (suggesting "open more") and when collapsed it
showed the collapse icon, opposite of every other ListToggle caller in
the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(HistoryListPanel): match ServerListScreen Export button — rename + default variant
Renames "Export JSON" to "Export" and switches from the subtle ToolbarButton
to the same `Button variant="default"` used by ServerListControls so the
two Export affordances look identical across screens. Also moves the
button to the right of the ListToggle (mirroring the order on the server
list screen) and disables it when there are no entries to export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(HistoryListPanel): move Clear button to top toolbar, match Export style
Moves the Clear affordance out of the "History (N)" section header and
up to the panel toolbar alongside Export, where it now uses the same
`Button variant="default"` styling. Order in the toolbar is now:
ListToggle · Clear · Export. Clear is disabled when there are no
unpinned entries to remove (pinned entries are intentionally preserved).
ToolbarButton helper is unused after this change and dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: ignore local debugging screenshots at repo root
Stops `git add -A` from re-staging the `*.png` files Claude Code drops
at the repo root from attached conversation screenshots. The pattern
only matches the top-level (`/*.png`) so legitimate image assets in
subdirectories (e.g. `clients/web/public/`) still get tracked when
explicitly added.
Also untracks the two screenshots that slipped through previously
(initialize-request.png, pending-requests.png).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(LogStreamPanel): match HistoryListPanel button styling
Clear / Export / Copy All on the Logging screen were rendered with the
subtle ToolbarButton helper while History's equivalents use
`Button variant="default"`. Switch all three to the default variant so
the toolbars look identical across screens and mirror the disabling
behavior — each button is now disabled when `entries.length === 0`.
ToolbarButton helper is unused after this change and dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR review — debounce save, preserve-on-omit PUT, validate shape, lift connect timeout
Round-up of fixes from https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4526861959:
1. ServerSettingsModal saves were firing on every keystroke (one PUT +
full mcp.json refresh per character). App.tsx now debounces by 300ms
and flushes on modal close so a burst of edits coalesces into one PUT
and the form value can't flicker back mid-typing.
2. `PUT /api/servers/:id` with an omitted `settings` field used to
silently wipe the persisted settings node. The route now treats the
three intents distinctly:
- field omitted → preserve existing on-disk settings
- explicit null → clear the settings node
- settings object → validate + apply
`useServers.updateServer` is simplified to drop the client-side
carry-through hack, and the test that documented the broken behavior
is flipped + augmented with explicit-clear and bad-shape cases.
3. `connectionTimeout` Promise.race is moved from App.tsx into
`InspectorClient.connect()`, reading `this.serverSettings?.connectionTimeout`.
TUI/CLI consumers now get the same behavior; on timeout the client
internally disconnects so the next connect starts clean.
4. Custom Headers hint in `ServerSettingsForm` notes that `Authorization`
is owned by the OAuth flow when OAuth is configured — avoids the
sharp edge where the SDK's authProvider silently overwrites a
user-set Authorization header.
5. New `validateSettings` on the server route rejects malformed shapes
(`settings: []`, `settings.headers: "oops"`, non-numeric timeouts,
etc.) with 400 instead of letting them persist and crash the UI.
7 (nit). Renamed `settings` → `savedSettings` inside `setupClientForServer`
so the variable holding the persisted settings doesn't shadow the
later locally-derived values.
Follow-ups opened separately:
#1356 — Move oauthClientSecret out of mcp.json into the OS keychain (review #6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR review pass 2 — connect-timeout test, unmount cleanup, tagged intent, lenient settings read
Pass-2 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4527089705:
(a) New integration test pins the new client-layer connect-timeout
contract: a hanging stub transport with `connectionTimeout: 50`
rejects with the descriptive error and lands the client in "error"
status. Documents what TUI/CLI consumers should expect from the
lifted Promise.race in InspectorClient.connect().
(b) useEffect cleanup clears `pendingSettingsTimerRef` on unmount so a
route change or HMR mid-debounce can't fire one stale PUT against
an unmounted component.
(c) Refactor the PUT-route `settingsIntent` discriminator from
`"preserve" | "clear" | { value }` to a tagged union with `kind`,
and consume it with a `switch` for exhaustiveness — reads cleaner
and makes adding a fourth intent painless.
(d) `normalizeMcpServers` now runs `validateSettings` on the on-disk
settings node and silently strips it if invalid, mirroring the
existing lenient-on-read pattern from `normalizeServerType`. Closes
the read/write asymmetry where a hand-edited malformed `settings`
could survive on disk and get carried through preserve-on-PUT.
New route test pins the strip-on-GET + no-propagate-on-preserve-PUT
behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail
Pass-3 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529276897.
(b) `PUT /api/servers/:id` now treats both `config` and `settings` as
optional patches:
- field omitted → preserve the on-disk value
- explicit body value → apply
- settings: null → clear
`updateServerSettings` stops reading `existing.config` from the
in-memory `servers` snapshot, which closes the closure-capture
hazard where a refresh between debounce-schedule and flush could
silently revert a separate edit. The route is the single source of
truth for transport config now. New tests cover settings-only PUT,
rejected non-object config, and the empty-body no-op patch case.
The previous "rejects a missing config" test is rewritten to
document the new no-op-preserve semantics.
(c) Inline comment near the PUT rebuild loop documents that writing
the full mcpServers map back is what gives the route its desirable
self-healing property for malformed settings on *other* entries —
deliberate side-effect, not an accident of normalization.
(d) `flushPendingSettings` surfaces failures via @mantine/notifications
instead of swallowing them. The modal already closed by the time
the flush PUT resolves, so a silent fail would leave the user
thinking their last edits saved (especially painful for the OAuth
client secret). The notifications wiring was already mounted in
main.tsx.
Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is
acknowledged in the reply: working as intended today; the proper fix is
internal modal state when file-watching (#1345) forces the issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): pass-4 review — strip smuggled settings out of config
Pass-4 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529861349.
Main finding: `normalizeServerType` spreads unknown keys, so a body
that nested `settings` inside `config` would smuggle a settings field
onto the stored entry without ever passing through `validateSettings`.
Strip the settings key off the incoming config in `buildStoredEntry`
so `validateSettings` remains the single write path for the settings
node — the "one source of truth" invariant pass 2 set up holds again.
Two new tests pin the strip on both routes:
- POST with `config.settings = { bogus }` lands an entry with no
settings node on disk.
- PUT with `config.settings = { bogus }` + `settings: null` clears
the real settings *and* doesn't re-attach the bogus payload via
the spread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning
Pass-5 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529954490.
Main finding: when the connect-time `Promise.race` lost to the timeout,
the original `this.client.connect(this.transport)` promise was left
pending. After `disconnect()` tore the transport down, real transports
(SSE / streamable-http) would reject that promise later — with no
handler attached, producing a Node `unhandledRejection` (and a vitest
suite warning). Attach a no-op `.catch` right after creating the
connect promise so Node sees the late rejection as handled.
Nit (a): `validateSettings` was casting the raw object through to
`InspectorServerSettings`, which let unknown keys ride along onto disk.
Replaced the cast with an explicit pick-and-build over the known
fields. Unknown stowaways now silently drop on the way through. New
route test pins the behavior.
Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino
logger when an incoming `config` carries a `settings` key. The strip
is still defensive (it isn't a documented contract) but the warning
lets a misconfigured client find out about the wasted field instead
of debugging a silent drop.
Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` —
acknowledged in the review reply; left in place as belt-and-braces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): pass-6 polish — scope absorber, thread id, drop empty OAuth, guard `in`
Pass-6 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529979135. Reviewer signed off as ready; landing the four marginal cleanups since they're all small.
(a) `connectPromise.catch(() => {})` moves inside the
`if (connectTimeoutMs > 0)` branch. The await-only path propagates
rejection cleanly through the outer try/catch — the absorber is
only needed when the race leaves the original promise unattended.
Code now reads as "absorber is tied to the race," which is the
real invariant.
(b) Thread `id` (POST id, PUT newId) through `buildStoredEntry` and
include it in the smuggle warning's pino bindings. Two clients
smuggling simultaneously are distinguishable in the log instead
of indistinguishable.
(c) `validateSettings` coerces empty-string OAuth fields to absent.
The form emits `""` when the user clears an input, and an empty
`oauthClientId` on disk would later be misread as "OAuth configured."
New route test pins it.
(d) `buildStoredEntry` adds a null/object guard before `"settings" in
configObj`. The route layer pre-checks this on POST and PUT, but
the helper's `unknown` parameter is a weak contract; the guard
makes it safe to call from anywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(spec): reflect settings persistence + patch-PUT contract in v2_servers_file
The doc was authored when `mcp.json` only stored the server list. Bring it
forward to match what shipped in #1352 / PR #1353:
- Summary calls out that the file also stores per-server settings and
links into the Per-server settings section.
- On-disk format note explains that `settings` is an optional Inspector-
specific extension that other MCP tools ignore.
- New top paragraph on the Per-server settings section explains the
basic-config / settings UI split rationale (simpler form for the
common case, advanced settings tucked behind a second dialog).
- UI bullet is rewritten to match the patch-style PUT semantics that
emerged across the review cycle: settings-only and config-only
PUTs preserve their counterpart inside the write lock, so neither
modal can silently clobber the other half. The earlier "must
re-send settings on PUT" hack is gone.
- New bullets capture the save cadence (300ms debounce + flush-on-close
+ failure toast), the patch-PUT contract (omit-preserve / null-clear /
rename), and the write-path / read-path validator gates.
- Secret storage bullet links to #1356 (OS-keychain follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(spec): show a third entry with the optional settings node
The On-disk format example demonstrated only basic stdio entries. Add a
streamable-http entry with a populated `settings` node so readers can
see the shape inline rather than having to scroll to the Per-server
settings section. Inline comment in the block cross-links there for the
full contract and notes that other MCP tools ignore the extension key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): export server list as mcp.json download (#1346)
Adds an Export button to the Servers screen, next to "Add Servers".
Clicking it downloads the current server list as a canonical mcp.json
file (2-space indent, same shape serializeStore writes), so the user
can hand it off, sync it to another machine, or symlink into Claude
Desktop / Cursor / Cline.
Wiring:
- ServerListControls: new Export Button (variant=default), disabled
when serverCount === 0 (nothing to download).
- ServerListScreen / InspectorView: thread `onExport` / `onServerExport`
through.
- App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory
servers list, JSON.stringify with 2-space indent, and triggers a Blob
download via a temporary anchor (appended to body for Firefox
compatibility, then removed; object URL revoked).
Tests:
- ServerListControls unit tests cover button-count (toggle hidden vs
shown, Export always present), disabled-on-empty, click fires onExport.
- ServerListControls.stories grows play functions for both stories —
WithServers asserts Export is enabled + click fires the spy in real
Chromium, WithoutServers asserts Export is disabled.
- Existing screen + view tests / stories updated for the new prop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR #1351 review (extract helpers + exception safety + guard)
Substantive items from the review:
1. Serialization format parity (review #1): added
`serializeMcpConfig(entries)` to core/mcp/serverList.ts — a
browser-safe helper that runs `serverEntriesToMcpConfig` then
stringifies with 2-space indent. Single source of truth for export
formatting; can't drift from serializeStore's on-disk shape.
2. Exception safety on the anchor cleanup (review #2) + a direct test
for the App.tsx download wiring (review #6): extracted
`downloadJsonFile(filename, json)` to clients/web/src/lib/
downloadFile.ts. removeChild + revokeObjectURL run in a `finally`
so a thrown click() doesn't leak the DOM node or the object URL.
Unit-tested under happy-dom with URL.createObjectURL mocked
(happy-dom doesn't ship it).
3. Empty-list guard (review #5): App.tsx onServerExport bails when
`servers.length === 0`. The button's disabled prop already prevents
the UI path, but the handler is now locally correct against any
future programmatic caller (storybook, keyboard shortcut, etc.).
Acknowledged but not changed:
- Tooltip on the disabled Export button (review #3) — Mantine's
disabled-button + Tooltip wart wants a span wrapper + custom events
config. Optional per the reviewer; deferred.
- Repeated exports → `mcp (1).json` browser suffix (review #4) — a
deliberate trade-off since `mcp.json` is the conventional name a user
would expect to symlink into Claude Desktop / Cursor / etc.
Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343)
Replaces the hardcoded SEED_SERVERS in App.tsx with a file-backed list at
~/.mcp-inspector/mcp.json. Full CRUD round-trips through new /api/servers
routes; first launch writes the two existing seeds if the file is absent.
Backend (core):
- store-io: getDefaultMcpConfigPath() — sibling of getDefaultStorageDir()
- core/mcp/serverList.ts: pure converters MCPConfig <-> ServerEntry[],
DEFAULT_SEED_CONFIG, and normalizeServerType (moved from node/config.ts
to keep serverList.ts Node-free)
- core/mcp/remote/node/server.ts: GET/POST/PUT/:id/DELETE/:id /api/servers
routes, plus RemoteServerOptions.mcpConfigPath defaulting to
getDefaultMcpConfigPath(). Ids validated with validateStoreId.
Frontend:
- core/react/useServers.ts: { servers, loading, error, refresh, addServer,
updateServer, removeServer } over the new endpoints
- App.tsx: drops SEED_SERVERS, wires useServers, disconnects on remove of
the active server, follows id on rename of the active server
- ServerConfigModal: Add/Edit/Clone form (stdio: command/args/env/cwd;
sse/streamable-http: url/headers as one-per-line text)
- ServerRemoveConfirmModal: confirm-on-remove with id + transport summary
Tests (52 new):
- unit: getDefaultMcpConfigPath env-var permutations, serverList round-trip,
hook flows against real Hono app.fetch (no TCP), modal forms + validation
- integration: /api/servers routes against a real createRemoteApp + tmp file
- stories with play functions for both new modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(servers): address PR #1349 review (correctness + nits)
Bugs:
- normalizeServerType: validate against the allowed set and default
unknown / non-string types to "stdio" (matches the missing-type case).
Prevents a hand-edited {"type":"websocket"} from leaking past `as
ServerType` and surprising downstream narrowing.
- onConfirmRemove: when removing the active server, also destroy() the 9
state managers and null them + the client out so they GC immediately
instead of waiting for the next server switch.
- /api/servers mutating routes: serialize POST/PUT/DELETE through an
in-process write-lock so concurrent read-modify-write cycles can't lose
updates. New integration test fires 25 parallel POSTs and asserts every
entry lands.
- readMcpConfig comment: update to match actual behavior (valid-JSON file
with no `mcpServers` → empty; invalid JSON → 500 from the outer try, so
corruption surfaces rather than silently emptying the list). New
integration tests pin the 500 path on both GET and POST.
Nits:
- Add --inspector-surface-subtle token, use it in ServerRemoveConfirmModal
instead of the raw Mantine var.
- Rename useServers buildHeaders boolean param to includeJsonBody.
- Add a comment on the env-value vs header-value trim asymmetry (env
values preserve trailing whitespace; header values follow RFC 7230).
Tests: 1630 unit + 404 integration + 306 storybook all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ServerConfigModal): null currentTarget in functional setState updater
React nulls SyntheticEvent.currentTarget after the synchronous handler
returns. Reading e.currentTarget.value inside `setForm((f) => ...)`
worked under happy-dom (which keeps currentTarget around) but threw
"Cannot read properties of null (reading 'value')" on the first
keystroke in a real browser. Capture the value before the functional
updater closes over it.
All seven onChange handlers in ServerConfigModal had the bug. Extended
the AddEmpty storybook play to type into Server ID and Command — the
real-Chromium runner now exercises this path so a future regression
fails the test suite instead of the user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(remote-server): surface stderr + transport_error when subprocess crashes during startup
A stdio subprocess that spawns successfully but exits before the browser
opens the SSE events stream used to vanish into a bare "Session not
found" 404. The fix has three parts:
1. RemoteSession.markTransportDead always pushes the transport_error
event (via pushEvent's queue) rather than dropping it when no
consumer is attached. The queued event drains when the consumer
eventually arrives.
2. /api/mcp/connect's transport.onclose handler no longer deletes the
session immediately when no consumer is attached. Instead it holds
the session (with queued stderr + transport_error) for a 30s grace
window so the events endpoint can drain it. A timeout sweeps if no
consumer ever connects.
3. /api/mcp/events detects isTransportDead after attaching the consumer
(which just drained the queue), yields once to flush, then closes
the stream and deletes the session.
End result: the user sees the actual error (e.g. "Cannot find module"
from stderr + the transport-closed reason) instead of a confusing 404.
Tests:
- New unit tests (clients/web/src/test/integration/mcp/remote/remote-session.test.ts)
cover the queueing behavior, live delivery, and clearEventConsumer
signaling under all transport-state permutations.
- New integration test (connect-crash.test.ts) spawns a node subprocess
that writes to stderr and exits, then verifies the events stream
delivers both the stderr line and the transport_error event before
closing — the exact scenario from PR #1349.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(state): gate listRequestorTasks on the server's `tasks` capability
ManagedRequestorTasksState was calling tasks/list on every connect.
Servers that don't advertise the `tasks` capability (the common case)
reply with -32601 "Method not found", which then surfaces in the
inspector's console for every connect.
Skip the call when the server doesn't declare `tasks` — empty tasks
list is the right semantics. Existing tests opt into capabilities so
the live-path coverage is preserved; two new tests pin the gated
behavior (direct refresh + connect-event-triggered refresh).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(App): clear activeServerId on disconnect so other server cards re-enable
ServerCard dims any card whose id differs from `activeServer` (and marks
it inert), so cards stay non-interactive after a disconnect until the
user reconnects elsewhere. activeServerId was set on connect but never
reset on disconnect, leaving the whole screen locked to the just-
disconnected card.
Subscribe to InspectorClient's `disconnect` event and clear
activeServerId there. Covers all three disconnect paths uniformly:
- user toggles the connected card off
- user clicks the header Disconnect button
- mid-session transport failure (subprocess exit / network drop)
Event-based instead of effect-based on connectionStatus to avoid the
first-render-clobbers-new-id trap (status starts as "disconnected" for
a freshly-created client before connect() runs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design for replacing the hardcoded SEED_SERVERS in App.tsx with a
file-backed list at ~/.mcp-inspector/mcp.json, reusing the v1.5-ported
storage primitives and the canonical mcp.json parser. Ties the new doc
into the Storage section nav as the first sub-menu item.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>