* web: add Tasks to the monitoring sidebar (#1680)
Make Tasks a pinnable panel in the monitoring sidebar alongside
Logs / Protocol / Network / Console, so a live task list can be watched
beside any screen.
- InspectorView: add TASKS_TAB, extend the MonitorTab union / MONITOR_TABS
/ isMonitorTab, and add a monitorScreens[TASKS_TAB] entry built from a
shared tasksScreenProps object (so the embedded and full-size instances
can't drift), with the shared column search injected into its ui.search.
The full-size Tasks screen now uses the same shared props.
- TasksScreen / TaskListPanel: add an embedded prop mirroring
LoggingScreen/LogStreamPanel — when embedded the screen fills its column
height and drops the controls sidebar, and TaskListPanel scrolls via
EmbeddableScrollArea (whose full-size cap matches the old viewport calc,
so the standalone screen is unchanged).
Tasks is capability-gated: it only appears in the monitor group when the
server advertises the tasks capability (via availableTabs), so no change
is needed to the failed-connect subset.
Closes#1680
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: embedded task list ignores the status filter (review #1681)
Mirror the sibling monitor panels: the embedded column exposes only the
shared search box (its status filter lives in the full-size sidebar,
which the embedded view drops), so a stale status filter must not
silently hide tasks with no visible control to clear it. Thread an
`ignoreStatus` flag into TaskListPanel.matchesFilters, set from the
`embedded` prop — matching LogStreamPanel's `ignoreLevels`,
NetworkStreamPanel's `ignoreCategories`, and ProtocolListPanel's
`ignoreDirectionAndMethod` (#1616). Add tests for the ignored-status /
still-applied-search embedded behavior, plus a direct assertion that the
Tasks radio appears in the monitor column.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: accessible light/dark color palette + dark-mode legibility fixes (#1678)
Ports the design system's accessible palette. Colors are defined once as Mantine
color tuples in theme.ts (shade 0 lightest → 9 darkest); every `--inspector-*`
token in App.css references `--mantine-color-*`, so improving the tuples cascades
through the whole UI.
- theme.ts: full accessible tuple set (inspector/gray/dark/green/red/yellow/blue/
teal/violet), WCAG-AA at the primary shades. Tuples are the one place literal
hex belongs — the token source. Primary stays inspector-7 (light) / -8 (dark).
- theme/ActionIcon.ts: default `color: "gray"` so chrome icons (settings, theme
toggle, sidebar, pin, sort, replay) render neutral grey instead of inheriting
the blue primary. Icons that set their own color/variant still override.
- App.css: fix light surfaces (`surface-body` gray-0, `surface-card` white — the
body token pointed at near-black dark-9); add direction/method badge tokens;
lift the semantic status/log/task/history colors 1–2 steps in the dark scheme
(the shared `*-6` shade sat too dark on charcoal). Light `:root` keeps `*-6`.
- Badges: MessageDirectionBadge + new MethodBadge element driven by
`--inspector-badge-*` tokens (deep fill + light text in dark, tinted in light)
via Mantine `bg`/`c` style props with `autoContrast={false}`; swaps
`<Badge color="dark">{method}</Badge>` in ProtocolEntry/NetworkEntry (their
Badge import stays — both still use it for status/category chips). Stories +
test added for MethodBadge (≥90 coverage gate).
Domain badge semantics live in the element components, not theme files. No raw
hex outside the tuples; no inline styles. Supersedes the reverted #1677.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: WCAG AA accessibility pass — contrast, labels, names, roles (#1678)
Drives the axe-core ruleset (the engine behind Storybook's a11y addon) to
zero violations across all stories in light and dark mode.
Contrast (805 → 0 hits):
- Darken Mantine `*-light-color` + `dimmed` in light mode (and lift dark
`dimmed`) via `!important` App.css overrides — Mantine injects these at
runtime at matching specificity, so specificity alone loses the tie.
- `filledBadgeColor` pins amber (orange/yellow) filled badges to shade 5 so
`autoContrast` (JS-computed from the theme shade, not CSS vars) picks black.
- `accessibleTextColor` routes colored log/filter text through the scheme-aware
`-light-color` token so it clears AA on light and dark surfaces.
- `status` Badge variant: amber statuses use black-on-amber (orange text can't
reach AA on light at any Mantine shade).
- Resource URIs use `-light-color` (blue-4 was 4.38:1 in dark).
Names / roles:
- `aria-dialog-name`: custom-header modals use `Modal.Title` (wires the
dialog's `aria-labelledby`) instead of a bare `Title` + a root `aria-label`
(which also tripped `aria-prohibited-attr`).
- `button-name`: a `Modal` theme default labels every built-in close button.
- `label`: `aria-label` on the previously-unlabelled Textarea/Select inputs.
- `scrollable-region-focusable`: keyboard-focusable content Code/ScrollArea
viewports; long OAuth values wrap instead of scrolling.
- `heading-order`: correct heading levels (keeping visual size via `size`) so
outlines don't skip a level under the h2 modal/section titles.
Scrollbars: `type="scroll"` ScrollArea theme default — scrollbars auto-hide and
show only while scrolling (a deliberate product choice over the always-visible
a11y default).
`region` is disabled in the Storybook a11y config: isolated component/screen
stories render outside the app's `AppShell` landmarks, so it's a page-level
concern the components can't own.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: let the resource template form fill the main-area width (#1678)
Drop the maw=40% cap on the template-form pane so the URI preview and
variable inputs use the full width beside the sidebar, matching the
resource preview pane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: left-align + reverse the input-panel action rows (#1678)
Move the footer action buttons to the left (justify flex-start) and put
the primary action first on the Tool / Prompt / Resource-preview /
Resource-template panels — Subscribe, Refresh, View Source on the
preview panel; Execute before Cancel on the tool panel. Shortens the
pointer travel from the sidebar controls / the form fields above to the
actions, which matters most when the monitoring sidebar is hidden and
the form is wide.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: match the dark-mode body background to the header tab bar (#1678)
Set the dark `--inspector-surface-body` to `dark-8` (the Mantine
SegmentedControl fill used by the header tab bar) instead of `dark-9`,
mirroring light mode where the body and the tab bar share `gray-1`.
Cards (`gray-9`) still sit a step above the body, and the contrast audit
stays at zero AA violations on the slightly lighter background.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: enforce the a11y gate + fix modal-overlay heading-order (review #1679)
Address the code review:
- Flip the Storybook a11y addon from test: 'todo' to 'error' so axe
violations fail the play-function tests in CI — the zero-violation
state is now gated, not point-in-time.
- Enforcing it surfaced a real heading-order skip: the Tools screen's
h4 'Tools'/'Results' sit under an h2 Modal.Title when a sampling/
elicitation request modal opens over them. Lift both to h3 (size h4
preserves the look) so h2→h3 is valid in any DOM order.
- Comment the intentional color+c pairing on the Remove/Disconnect
danger buttons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: brighter warning alerts + pin the elicitation form's actions (#1678)
- Add a 'warning' Alert variant: a bright light-yellow fill (yellow-1)
with crisp near-black text and a bright yellow-5 left accent, instead
of the muddy brown a tinted yellow alert produced once
yellow-light-color was darkened to yellow-9 for contrast. Dark mode
keeps the light-on-dark-tint treatment. Applied to all five yellow
warning/notice alerts.
- Elicitation form: scroll only the form fields (capped ScrollArea),
keeping the quoted message, warning, and Cancel/Decline/Submit buttons
in view. The modal has no other close affordance, so the actions must
never scroll out of reach.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: robustly pin the elicitation form's actions (#1678)
The previous approach capped only the fields' ScrollArea with a fixed
rem reserve for the pinned chrome, which underestimated it on some
viewports — the whole modal then overflowed and the buttons clipped
half-off-screen. Instead cap the whole panel below the modal height and
lay it out as a flex column: the message and the warning + buttons are
pinned (flex: 0 0 auto) and only the fields (flex: 1) shrink and scroll.
Verified the buttons stay within the panel and the modal fits the
viewport down to very short heights.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: fix elicitation form scrolling — fields scroll, actions pinned (#1678)
The prior fix capped the panel with a bare max-height, but a flex:1
ScrollArea's viewport won't bound against a max-height (not fixed-height)
parent, so a tall form's fields overflowed unscrollably (clipped) rather
than scrolling. Adopt ToolDetailPanel's pattern instead: the modal gives
its body a bounded flex column, the panel fills it, and the fields use a
flex: "0 1 auto" ScrollArea — which sizes to content when the form is
short (modal stays compact, nothing scrolls) and shrinks + scrolls once
the panel is capped at the modal height, keeping the message, warning,
and Cancel/Decline/Submit pinned in view. Add a ManyFields story (with a
bounded-height decorator mirroring the modal) to cover the scroll case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: fix elicitation scrolling in the real modal (cap fields, not flex)
The flex-column approach (panel flex:1 in a flex-column modal body, fields
flex:"0 1 auto") worked in a fixed-height Storybook decorator but NOT in the
real modal: the modal body is bounded by max-height, not an explicit height,
so the fields' ScrollArea viewport still couldn't bound — a tall form's
fields overflowed and pushed the warning + Cancel/Decline/Submit off-screen.
Drop the flex plumbing (and the modal-body styles) and cap the fields
directly with ScrollArea.Autosize mah=calc(90dvh - 22rem): it sizes to
content when the form is short (modal compact, nothing scrolls) and scrolls
once the fields hit the cap. The 22rem reserve = the measured non-field
chrome (modal header/padding + message + warning + buttons + gaps), so the
whole modal stays within 90dvh and the actions are always in view. Verified
in the real PendingClientRequestModal (new ElicitationFormTall story) for
both short and tall forms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: pin the sampling modal's actions too (#1678)
Apply the elicitation-form treatment to SamplingRequestPanel: wrap the
request/response content in a ScrollArea.Autosize capped at
calc(90dvh - 10rem) so it scrolls while the Reject / Send Response buttons
stay pinned in view (the modal has no other close affordance). The 10rem
reserve is the pinned chrome (modal header/padding + the actions + gaps) —
smaller than the elicitation form's 22rem since there's no warning banner.
Add a SamplingTall story (8 messages + preferences) to cover the scroll
case in the real PendingClientRequestModal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate the deep-link connect phase on 'not already connected' rather than on
activeServerId identity: if a prior session restored activeServerId to the
'deep-link' id while disconnected, the old guard marked the ref consumed and
never connected, so reloading the same deep-link URL silently didn't auto-
connect. onToggleConnection only disconnects when the id is active AND status
is connected, so the new condition also avoids toggling a live connection off.
Also documents (rollup review #3) that pre-selecting a deep-linked app
deliberately bypasses AppsScreen.handleSelect's no-input auto-launch — a URL
must not fire a tool call without the token-gated autoOpen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- Root README no longer links to docs/mcp-server-configuration.md (not yet
ported to v2); keep only the mcp-app-review.md pointer (review #3).
- Collapse the Local-fixture parenthetical onto one line so prettier no longer
renders '- resource preset …' as a mid-sentence bullet (review #5).
Findings #1/#2 (documents unmerged #1576/#1577/#1665 behavior) and #4 (the
web-README #deep-link-auto-connect anchor) resolve in the Wave 4 rollup, which
lands this doc together with those code PRs — the reviewer noted the same.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
DeepLink.autoOpen is a non-optional boolean, so the `?? false` was dead once
openApp was truthy. Collapse to Boolean(deepLink?.openApp && deepLink.autoOpen)
— still false when there's no deep link, still gated on openApp so a malformed
autoOpen-without-openApp link can't fire.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- The JSDoc referenced a non-existent `resolveServerConfig`; name the real
`resolveServerConfigs` (core/mcp/node/config.ts) in plain text (it isn't
imported here, so an @link wouldn't resolve).
- Hoist the doubly-computed `normalizeServerUrl(serverUrl)` in buildHandoff to
a single `normalizedUrl` reused for the query param and the serverUrl field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- Refresh failure now falls back to injecting a still-present stored access
token instead of hard-exiting 3, so a transient auth-server hiccup doesn't
regress a previously-working invocation (review #1). Only a failure with no
access token to fall back on surfaces as exit 3.
- Persist rotated tokens via the shared writeStoreFile (0o600 + mkdir -p)
instead of a raw writeFile, so the secrets file keeps owner-only perms
identical to the web backend (review #2).
- Missing clientInformation now throws the distinct no_client_information
envelope code rather than reusing no_stored_token (review #5a).
- Docs: --wait-for-auth no longer claims parity with --use-stored-auth (it
injects the freshly-landed token without refreshing); noted the
always-refresh rotation/concurrency caveat (review #3, #4).
- Tests: discovery-returns-undefined metadata path, no_client_information
code, and an e2e refresh-failure → access-token fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- The ensure-phase addServer catch now records non-409 failures (read-only
catalog, backend 5xx) on data-error-message instead of swallowing every
error and leaving the deep link permanently stuck at the guard with no
signal (review finding #1).
- Split the update and connect phases across renders (new deepLinkUpdateRef):
connect only once servers reflects the updated config, so onToggleConnection
(which reads serversRef, synced by an earlier passive effect) builds the
client from the fresh transport rather than the pre-update config (finding
#2 — the exact case deepLinkConfigEquals was added for).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1578
Adds docs/mcp-app-review.md — the CLI-first → one-shot-web recipe for automated
MCP App review: `--app-info` probe → deep-link navigate → rendered widget, plus
OAuth-gated servers (stored-auth handoff, now honoring refresh_token per #1665),
SSH port-forward (both ports), and HTTP proxy support.
Adapted from PR #1510's reference doc to only document what actually merged and
to match the current implementation:
- Correct driver contract: `data-testid="connection-status"` + `data-status`
/`data-error-message`/`data-deeplink`, `data-testid="open-app"`, and the
`data-app-status="ready"` app lifecycle (not the reference's stale selectors).
- The zero-click `autoOpen` path (#1577) alongside the click-to-open variant.
- `--use-stored-auth` now refreshes a stored refresh_token (#1665), so the
"token is not refreshed" caveat is replaced.
The per-client READMEs (web deep-link scheme + data-* contract, CLI handoff +
refresh + proxy) were updated in their own Wave 4 PRs; this adds the root README
pointer to docs/. Proxy/undici is already documented in the CLI README.
Part of the #1579 decomposition (Wave 4, docs — lands last).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1665
`--use-stored-auth` previously injected the stored access token blindly, so an
expired token failed even with a valid refresh_token on disk. Now, when a
`refresh_token` is stored, the CLI runs the OAuth refresh grant and injects the
fresh access token, persisting the rotation.
- New exported `refreshStoredAuthToken(serverUrl, statePath, deps?)`: reuses the
SDK's `refreshAuthorization` (not a hand-rolled token request) with the stored
`clientInformation` + `serverMetadata` (discovered via core
`getAuthorizationServerUrl` + SDK `discoverAuthorizationServerMetadata` when
the metadata wasn't persisted). Rotated tokens are written back through
`serializeOAuthPersistBlob` under the same `{servers,idpSessions}` key, so web
and CLI stay consistent. Missing refresh_token/clientInformation or a failed
grant throw `CliExitCodeError(AUTH_REQUIRED)` with a clear message + envelope
code (`no_stored_token` / `refresh_failed`).
- `--use-stored-auth` prefers refresh when a refresh_token is present (covers
both absent and expired access tokens — the stored blob has no expiry, so the
refresh token is the durable credential); falls back to injecting the stored
access token otherwise (unchanged).
- `deps` are injectable so the refresh/discovery are unit-tested without a live
token endpoint; an e2e test drives the real SDK path against a mock token
endpoint and asserts the refreshed token is injected + the rotation persisted.
- CLI README updated (removed the "blind injection" caveat).
CLI coverage gate green (cli.ts 100/92/100/100).
Part of the #1579 decomposition (Wave 4, CLI lane).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1666#1575 shipped `--print-handoff` with an interim deep-link shape (hardcoded
`transport: "http"`, marked `TODO(#1576)`). Now that #1576 owns the canonical
`?serverUrl&transport&autoConnect` format, `--print-handoff` emits exactly it:
- New `deepLinkTransport(serverUrl, transport)` helper derives `http`|`sse`
from the resolved server — explicit `--transport` wins, else auto-detect from
the URL path (`/sse` → sse), else default `http`. Unlike the core resolver it
defaults instead of throwing (the handoff is best-effort; the web parser
defaults an unknown transport to http too).
- `buildHandoff` takes the transport, emits the normalized serverUrl +
derived transport in the query, and keeps `autoConnect=<token>` (which #1576
adopted as the canonical CSRF gate, so the interim guess was already correct).
- Removed the `TODO(#1576)` marker; updated the doc comment + CLI README.
Tests: handoff emits `transport=http` for `/mcp`, `transport=sse` for `/sse`
and for explicit `--transport sse`; `deepLinkTransport` unit tests cover the
explicit/auto/default/stdio/unparseable branches. CLI coverage gate green
(cli.ts 100/92.8/100/100).
Part of the #1579 decomposition (Wave 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1577
Extends the deep link (#1576) to land on a rendered MCP App:
- `&openApp=<toolName>` — once connected and the tool appears in the app list,
InspectorView switches to the Apps tab and pre-selects it.
- `&appArgs=<base64url(JSON)>` — pre-fills the tool form; values are merged
**over** `collectSchemaDefaults()` so required-with-default fields don't
disable "Open App".
- `&autoOpen=<token>` — token-gated auto-click of "Open App".
Adapted to the current architecture (differs from PR #1510's reference):
- Tab state is lifted to App.tsx (#1417), so the openApp effect lives in
InspectorView and drives `onActiveTabChange("Apps")` + `onAppsUiChange` seed
+ `onSelectApp`, guarded by `deepLinkOpenAppRef` to fire once.
- AppsScreen owns the `running`/iframe state, so `autoOpen` is threaded to it
as a prop; a fire-once effect defers the open one microtask past the
synchronous effect body (satisfying `react-hooks/set-state-in-effect`,
matching App.tsx's connect effect). `resetAppChannels` memoized so the
effect's deps don't churn.
Tests: InspectorView (tab-switch + pre-select + appArgs-over-defaults merge +
non-app openApp ignored), AppsScreen (autoOpen fires once with seeded values;
no-selection stays idle). README documents the params. Coverage gate green.
Part of the #1579 decomposition (Wave 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1576
Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven
auto-connect so a driver can reach a connected inspector with one navigate.
- `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only
serverUrl; `autoConnect` CSRF-gated on the per-launch API token),
`deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID`
(stable `deep-link` row so reloads reconnect to the same catalog row).
- App.tsx: ensure/connect effects (two-phase upsert-then-connect against the
hydrated list), `serversRef` read in `onToggleConnection`,
`connectErrorMessage` / `recordConnectError` recording connection-level
failures.
- InspectorView header: `data-testid="connection-status"` with `data-status`,
`data-error-message`, `data-deeplink` so drivers can `waitForSelector` and
read failure reasons without scraping toasts.
- Docs: deep-link scheme + `data-*` contract in clients/web/README.md.
Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417
lifted-tab architecture and dropping the non-existent `normalizeServerUrl`
import in favor of `URL.href` canonicalization. Coverage gate green
(deepLink.ts 97.8/95.1/100/97.4).
Part of the #1579 decomposition (Wave 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* feat(cli): programmatic ergonomics — initialize, tool-args-json, connect-timeout, env-var fixes
Adds the #1573 programmatic-ergonomics surface to the CLI, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts:
- `--method initialize` — connect-only probe emitting
`{serverInfo, protocolVersion, capabilities, instructions}`.
- `--tool-args-json '{"k":"v"}'` — tool args passed verbatim as JSON (no
key=value coercion); mutually exclusive with `--tool-arg`, with clear
errors for combined use, malformed JSON, and non-object values.
- `--connect-timeout <ms>` — default 15000 for ad-hoc `--server-url`/target
runs, file-level timeout otherwise; `0` disables. Backed by the exported
`withConnectTimeout()` helper.
- MCP_CATALOG_PATH is ignored when an ad-hoc target is given, so a shell that
exports it can still run one-off ad-hoc invocations.
- MCP_STORAGE_DIR is honored by core `getStateFilePath` (`<dir>/oauth.json`),
below the per-file MCP_INSPECTOR_OAUTH_STATE_PATH override.
Tests: new programmatic-ergonomics.test.ts (CLI, in-process runCli + direct
withConnectTimeout unit tests) and getStateFilePath resolution cases in the
web storage integration suite. Per-file coverage gate stays ≥90.
README: documents the new flags and env-var semantics.
Closes#1573
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* feat(cli): --app-info probe + --format json envelope
Adds the #1574 app-probing and machine-readable output surface, re-implemented
informed by PR #1510 and adapted to the v2/main OAuth-wired cli.ts.
- `--method tools/call --tool-name <t> --app-info` — prints one JSON line
describing the tool's app posture (hasApp, resourceUri, csp, permissions,
domain, …) WITHOUT invoking the tool; exit 0 when it has an app, 2 (no_app)
when it doesn't. A missing tool exits 5 (tool_not_found) — distinct from the
no-app exit so a typo isn't mistaken for "no app".
- `--method tools/list --app-info` — one NDJSON line per tool over a single
connection (`| jq -c 'select(.hasApp)'`).
- `--format json` — a single stdout envelope (`{result}` plus `{appInfo}` as a
sibling key for App tools), no banner, so any method pipes into jq.
- A tools/call returning isError:true now exits 5 (tool_is_error) after
printing its payload, so && chains stop on a failed call.
Implementation: `collectAppInfo()` (core `extractAppInfo` + a tolerated
resource read that reports `resourceError`), an NDJSON loop in tools/list, and
a single `emitResult()` that owns format + exit-code handling. `runMethod` now
returns a `MethodOutcome` discriminated union so the NDJSON path can signal
"already emitted".
Tests: app-info.test.ts (exit-0/2/5 + NDJSON + no-invoke), format-json.test.ts
(envelope shapes + bad --format), emit-result.test.ts (direct unit coverage of
the isError→exit-5 and resourceError branches via exported emitResult/
collectAppInfo). Updated the tools.test "nonexistent tool" case to the new
exit-5 tool_not_found contract. Per-file coverage ≥90 on all four dimensions
(cli.ts 100% stmts/funcs/lines). README documents the flags, NDJSON shape, and
exit semantics.
Closes#1574
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* feat(cli): stored-auth suite — --use-stored-auth, --wait-for-auth, --list-stored-auth, --print-handoff
Adds the #1575 web→CLI OAuth handoff surface, re-implemented informed by PR
#1510 and adapted to v2/main.
- `--use-stored-auth` — read the shared oauth.json and inject
`Authorization: Bearer <token>` for --server-url; exit 3 (no_stored_token),
listing stored keys, when none matches.
- `--wait-for-auth <sec>` — poll oauth.json (500ms) until a token for
--server-url appears, then proceed; timeout → exit 3 (auth_wait_timeout).
- `--list-stored-auth` — print `{oauthStatePath, storedServerUrls}` and exit
(short-circuit, no connect).
- `--print-handoff` — emit `{serverUrl, deepLink, portForwardCmd,
oauthStatePath, apiToken, note}` for --server-url and exit.
Adaptations vs. the reference:
- State-path resolution reuses core `getStateFilePath()` (the
MCP_INSPECTOR_OAUTH_STATE_PATH → <MCP_STORAGE_DIR>/oauth.json → default
precedence added in #1573) instead of a CLI-local resolver.
- The state file is read through the shared isomorphic
`parseOAuthPersistBlob`, so both the plain `{servers,idpSessions}` layout the
web backend writes and the legacy `{state,version}` envelope are accepted.
- `parseArgs` now returns a ParseResult union so the short-circuit modes return
without a server connection.
Blind injection: the token is passed as-is; a stale one surfaces as HTTP 401 →
exit 3. Documented in the README along with all four flags, the state-path
precedence, and a follow-up note that --print-handoff's deepLink shape is
interim pending #1576's canonical handoff format.
Tests: stored-auth.test.ts drives injection, --header coexistence, missing
--server-url, no-token exit-3, URL-normalisation matching, MCP_STORAGE_DIR
resolution, listing, handoff (+note), wait-for-auth polling/timeout, and
normalizeServerUrl units — all against a real in-process HTTP test server.
Per-file coverage ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).
Closes#1575
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* docs(core): scope MCP_STORAGE_DIR comment in getStateFilePath to the OAuth backend
Address review of PR #1651: the doc comment claimed MCP_STORAGE_DIR was
honoured "across the app, so setting it once points every persist backend at
the same directory", but the branch only relocates oauth.json. Narrow the
comment to state it is OAuth-backend-only (client.json/mcp.json still resolve
from getDefaultStorageDir → HOME) and record why the resolution lives here
rather than in getDefaultStorageDir (the default-path constant is evaluated at
module load, so a runtime-set MCP_STORAGE_DIR — as the CLI stored-auth /
--wait-for-auth flows set it — must be read at call time). Comment-only; no
behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review(cli): per-tool-tolerant app-info listing + drop dead helper (PR #1653)
Address review of PR #1653:
- collectAppInfo now never throws: a malformed `_meta.ui.resourceUri`
(extractAppInfo throws) folds into `{hasApp:false, resourceError}` just like a
resources/read failure. This makes `tools/list --app-info` per-tool tolerant —
one bad tool no longer aborts the whole NDJSON listing with a generic exit-1.
Guarding inside collectAppInfo (vs. a loop-local try/catch) keeps the branch
unit-testable; added an emit-result.test.ts case for the malformed-URI path.
- Documented that `tools/list --app-info` always emits NDJSON regardless of
`--format` (the per-tool list shape is fixed; --format json only reshapes the
single-result paths) — in both the code comment and the README.
- Removed the now-unused `expectJsonError` helper (its only caller moved to the
exit-5 tool_not_found contract).
Coverage stays ≥90 on all four dimensions (cli.ts 100% stmts/funcs/lines).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review(cli): stored-auth follow-ups — TODO(#1576), wait-for-auth guard test, docs (PR #1655)
Address review of PR #1655:
- Added an explicit TODO(#1576) on buildHandoff's deepLink: `transport:"http"`
is hardcoded even for SSE, and `autoConnect=<token>` is not one of the three
token sources the web app reads (per CLAUDE.md) — both to be reconciled with
#1576's canonical handoff format.
- Added a test for `--wait-for-auth` without `--server-url`, covering the
previously-untested `--wait-for-auth` arm of the shared missing-server-url
guard's message ternary.
- Documented that `--list-stored-auth` / `--print-handoff` are mutually
exclusive short-circuit modes (list takes precedence) and that a stored
refresh_token is not yet used (noted as a follow-up in code + README).
No behavior change; coverage stays ≥90 on all four dimensions (cli.ts 100%
stmts/funcs/lines).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors
Harden the MCP Apps sandbox isolation model:
- The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin`
(default "allow-scripts allow-forms") and any server-supplied sandbox value
has `allow-same-origin` stripped case-insensitively, so the widget runs under
an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via
the parent realm.
- Widget delivery is `srcdoc`-only (no document.write), which respects the
opaque origin.
- The relay accepts the inner frame's "null" origin with
`event.source === inner.contentWindow` as the unforgeable identity check.
- sandbox-controller.ts serves the proxy with
`Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`;
fetch directives are deliberately omitted so a srcdoc iframe's inherited
policy container does not override the per-app CSP.
Updated the isolation-model comments and the sandbox-controller integration
test asserts the frame-ancestors header (and the absence of fetch directives).
Closes#1565
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures
- createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the
sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the
untrusted widget HTML with wrapSandboxedHtml so the CSP <meta> is always the
first <head> child before the HTML reaches the sandboxed iframe. The approved
(post-filter) csp + permissions are echoed back via hostCapabilities.sandbox
so the view sees what was granted. The csp is no longer sent inline through
sendSandboxResourceReady — it is enforced via the wrapped meta.
- A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating
the shared constant.
- New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource
read/post failures to the caller (also always console.error'd) instead of
leaving a silent blank frame.
Closes#1566
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: full hostContext delivery + live host-context-changed updates
- createAppBridgeFactory now seeds the full hostContext via
snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles
(Mantine design tokens), displayMode ("inline"), availableDisplayModes, and
containerDimensions — replacing the theme-only seed. The local currentTheme
helper is dropped in favor of hostContext.ts.
- AppRenderer gains `displayMode` and `containerRef` props and pushes live
host-context changes via the SDK's sendHostContextChange (partial
host-context-changed notifications), never a hand-maintained snapshot:
- theme + styles via a MutationObserver on <html data-mantine-color-scheme>,
- containerDimensions via a ResizeObserver on the host container (or the
iframe fallback), gated on `initialized`, skipping 0x0 and value-equal
repeats, plus a one-shot push on initialize once layout settles,
- displayMode whenever the prop changes (Maximize/Restore), gated on init.
Adds AppRenderer host-context tests (theme flip incl. resolved styles,
displayMode push + init gate, container-dimensions on init and resize, observer
teardown) and updates the factory + story mocks for the new hostContext shape
and sendHostContextChange method.
Closes#1567
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: honor ui/notifications/size-changed and ui/request-display-mode
AppRenderer:
- New `onSizeChange` prop forwards the view's ui/notifications/size-changed
reports (via the bridge `sizechange` event).
- New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler
that returns the mode the host actually applied; with no handler the request
is declined by returning the current host-side mode (falling back to inline).
AppsScreen:
- Adds a host-controlled RendererContainer (measured for containerDimensions)
with an inner box sized by the view-reported height (`appHeight`), so
host→view container size and view→host size-changed don't couple.
- `handleSizeChange` records the reported height (ignored while maximized);
`displayMode` is derived from the maximize toggle and pushed to the view;
`handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES
(declining e.g. "pip") and maps fullscreen↔inline onto the maximize state.
Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/
fallback) and AppsScreen tests (frame sizing on report, no-height ignore,
fullscreen/restore/decline via request-display-mode). Story mocks gain
sendHostContextChange/addEventListener.
Closes#1568
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: honor ui/download-file with confirmation and http(s) allowlist
createAppBridgeFactory now advertises the `downloadFile` host capability and
attaches an `ondownloadfile` handler:
- Requires a host-mediated confirmation (window.confirm) listing the requested
file(s); a declined prompt or an empty payload returns isError without acting.
- Inline EmbeddedResources (text or base64 blob) are written to disk via the
shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes.
- ResourceLinks are opened in a new tab, restricted to http(s) by the shared
isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected).
- Labels shown in the confirmation are sanitized (control + format chars
stripped, length clamped) so a server-supplied filename/URI can't inject
newlines or reflow the prompt.
- Partial-batch success is surfaced (isError only when nothing downloaded), with
skipped items warned.
Adds factory tests for the full download surface (text/blob/link, decline,
empty, throw, non-http rejection, label sanitization/clamping, partial batch).
Closes#1569
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: surface ui/message and app logging in panels below the running app
AppRenderer:
- New `onMessage` prop backs an `onmessage` bridge handler that surfaces the
view's ui/message content and returns the spec-required empty result;
with no handler the submission is declined (isError).
- New `onLog` prop forwards MCP `notifications/message` log entries via the
bridge `loggingmessage` event, honoring the advertised `logging` capability.
AppsScreen:
- Adds `messages`/`appLogs` state (cleared with the reported height on
select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`.
- Renders a "Messages from app" panel (data-testid="apps-messages", reusing
ContentViewer) and a default-expanded collapsible "App logs" panel
(data-testid="apps-logs", reusing LogLevelBadge) with logger names and a
Clear button, both as pinned panels below the running widget.
Adds AppRenderer tests (log forward + no-handler no-throw, ui/message
route/empty-result/decline) and AppsScreen tests (message log render + empty
result + clear-on-close, log panel expand/collapse/clear).
Closes#1570
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: stage partial input — replay tool-input-partial before final tool input
AppRenderer:
- New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials
at bridge-build time (read via a ref so prop churn never rebuilds the iframe)
and cleared on dispose. flushPending replays them via
bridge.sendToolInputPartial BEFORE the complete tool-input, per spec.
AppsScreen:
- New `partialStages` state + `handleStagePartialInput` (snapshots current form
values). resetAppChannels gains a `keepPartials` option so Open App preserves
the staged fragments (they're consumed by the renderer) while select/close/
back clear them.
- Adds a "Stage partial input" control (fielded apps only) with a staged count
and "Clear staged", wired to AppRenderer's `partialInputs`.
Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and
AppsScreen tests (stage/count/clear gated on fields; staged partials survive
Open and are replayed).
Closes#1571
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: app lifecycle status + machine-readable data attributes on the Apps screen
AppRenderer:
- New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange
callback. Fires "loading" at the start of every (re)build, "ready" on the
view's initialized signal, and "error" on a factory throw/rejection.
AppsScreen:
- appStatus/appError state; handleAppError captures the reason locally and
forwards to the parent onError. resetAppChannels clears both.
- Surfaces the contract as data-testid="apps-form" carrying data-app-status and
data-app-error, renders an error panel (data-testid="apps-error") in place of
the blank frame, and tags the stage button data-testid="apps-stage".
- AppDetailPanel's Open App button gains data-testid="open-app".
App.tsx:
- Wires the bridge factory's onResourceError to a red toast so a malformed/404
UI resource is no longer console-only (the renderer separately drives
data-app-status, so a driver times out on never-reaching-ready and reads it).
Documents the data-testid/data-* automation contract in clients/web/README.md.
Adds AppRenderer status-transition tests and AppsScreen status/error-panel
tests.
Closes#1572
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn
Addresses the claude[bot] review on PR #1650:
- sandbox_proxy.html: log a console.warn when a server-supplied `sandbox`
value lacks `allow-scripts` after the `allow-same-origin` strip, so a
fail-closed (app-never-runs) sandbox is diagnosable instead of silent.
- Restore clients/{cli,launcher,tui,web}/package-lock.json to their
v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a
dropped version field) was incidental `npm install` regeneration
unrelated to the sandbox change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: document CSP-vs-permissions filtering asymmetry (PR #1652)
Address the claude[bot] review's finding #2: add a comment explaining why
`csp` is injection-filtered (approveCspSources) while `permissions` is passed
through verbatim — permissions is a structured boolean object consumed only by
the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it
never reaches the iframe sandbox/allow attribute as raw text. No logic change.
Also merges v2/1565-sandbox-hardening (review fixes) up the stack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2)
Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after
a sandboxready run sets hostCapabilities.sandbox — guards against a silent
regression if the per-app spread copy were ever dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: gate theme observer on initialized + document seed/dep assumptions (PR #1654)
Address the claude[bot] review:
- Finding #1 (applied): gate the theme+styles MutationObserver on the view's
`initialized` signal, matching the container/displayMode pushes — a theme
flip in the construction→handshake window no longer races ui/initialize. The
factory seeds the construction-time theme, and the first post-init flip
carries the current value. Added tests: pre-init flip is dropped; the live
flip test now completes the handshake first.
- Finding #2 (applied, comment): note in snapshotHostContext that the
`displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens
inline; #1568's displayMode push carries transitions).
- Finding #4 (applied, comment): explain why `containerRef` is a build-effect
dep yet doesn't force a rebuild (sameInputs ignores it; read lazily).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656)
Address the claude[bot] review:
- #1 (applied): handleSizeChange ignores a non-positive height — a view's
ResizeObserver can transiently fire 0 pre-layout/teardown, which would
collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test.
- #2 (applied): reinstate a distinct `RendererFrame` constant for the inner
content-sized box so its role reads clearly vs. the outer RendererContainer.
- #3 (declined + comment): keep carrying `appHeight` across maximize→restore —
clearing it would flash the frame to full-card height until the next
size-changed; the carry-over restores at the prior size. Documented.
- #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"]
so the payload shape has a single source of truth with AppRenderer's prop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: distinguish links, cap batch size, comment truncation (PR #1657)
Address the claude[bot] review:
- #1 (applied): the confirmation now reads "download or open N item(s)" and
resource_links are prefixed "↗" in the summary, so a link that opens in a tab
is distinguishable from an embedded file that saves to disk.
- #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20);
an oversized batch is rejected (isError) before the prompt, with a warn.
- #3 (declined): can't check window.open's return value to detect a blocked
popup — we pass `noopener`, and per spec window.open returns null even on a
successful noopener open, so null can't distinguish blocked from succeeded.
- #4b (applied, comment): note the start-of-label truncation is intentional so a
link's scheme+host stays visible for the trust decision.
Adds tests: oversized batch rejected without confirming/acting; resource-link ↗
prefix in the summary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: skip embedded resource with neither text nor blob (PR #1657 round 2)
Address the round-2 observation: an untrusted EmbeddedResource carrying neither
`blob` nor a string `text` previously produced `new Blob([undefined])` — a file
containing the literal text "undefined", counted as success. Now such an item
is skipped (returns false, like a rejected link), so isError reflects it. Added
a test asserting no object URL is created and the batch reports isError.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658)
Address the claude[bot] review:
- #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the
`: string` return type is honest for a data-less log.
- #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via
appendCapped — a chatty widget no longer grows the panels/DOM without bound
between Clear/close; oldest entries drop.
- #3 (declined): keep the Messages panel without a Clear/collapse — messages are
sparser and already cleared on close/switch; keeping it simple.
- #4 (applied): the App-logs toggle now has aria-controls pointing at the
Collapse region (id="apps-logs-region") for assistive tech.
Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest
dropped), aria-controls assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: stable id key for Messages panel (PR #1658 round 2)
Address the round-2 nit: give AppMessage a stable `id` (assigned via
nextMessageIdRef in handleMessage) and key the Messages panel on it instead of
the array index — so the appendCapped front-drop can't renumber keys once a run
exceeds the cap, matching AppLogEntry's treatment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: note partialStages intentionally survives handleOpen (PR #1659)
Address the claude[bot] review's observation #2: add a comment at the
handleOpen call site clarifying that partialStages is intentionally NOT cleared
there — AppRenderer snapshots the fragments into its own pendingPartialsRef at
build time, and the staging UI only renders while not running, so the surviving
state is invisible until the next reset drains it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: align error-panel docs with behavior + gate data-app-error on running (PR #1660)
Address the claude[bot] review:
- #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in
place of it — the renderer stays mounted so a factory error still shows the
reason alongside the iframe (gating the frame off on error would drop the
auto-launched iframe an integration test relies on). Reworded the README row
and the test comment to say "below the frame"; added a code comment.
- #2 (applied): gate data-app-error on `running` (like data-app-status) so the
two attributes can't desync if a future path sets an error without running.
- #3 (no change): resource-read failures staying at data-app-status="loading"
(toast only) is intentional and documented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three related "fill the available height" layout fixes to the connected screens:
- Tools & Prompts sidebars: replace the fixed magic max-height on ToolControls/
PromptControls with the Resources pattern — sidebar Card `variant="sidebar"`
(full-height flex column) + `Stack flex={1} mih={0}` + a flex-filling
ScrollArea — so the list runs to the bottom of the card before scrolling.
- Logs/Console/Protocol/Network standalone screens: only override ScreenLayout
`h` when embedded. Passing `h={undefined}` clobbered the default
`h: calc(100dvh - header)` (withProps plain-spreads), collapsing an empty
screen to its controls' height. Also center the Network/Protocol empty states.
- Tools placeholder: render the "Select a tool" empty state in a full-height
`DetailCard flex={1}` (like Prompts/Resources) instead of the content-sized
preview card, so it fills the pane.
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): single header MonitoringToggle + Prompts-style Tools results (#1661)
Consolidate the monitoring column's open/close affordances into one header
toggle, and align the Tools/Prompts result panes and the sidebar log layout.
- Add a single `MonitoringToggle` in the header, to the right of the theme
icon. It opens/closes the monitoring column and only shows when a server is
connected OR a connect attempt failed (wide viewport + a monitor tab
available) — the same availability rule the column uses.
- Remove the scattered pin/close icons: delete `PinColumnButton`; drop `onPin`
from the Logs/Protocol/Network/Console screens and their stream panels; drop
`onOpenMonitor` from the server list; remove the close button from
`MonitoringControls`/`MonitoringScreen`.
- Tools screen: remove the "Results will appear here" placeholder pane. Results
now replace the input form in a single content pane (the Prompts pattern),
with a top-left X (ToolResultPanel) that dismisses them back to the form;
form values are preserved for a re-run.
- Prompts screen: the argument form fills the content pane width (drop the 40%
cap), matching the Tools input.
- LogEntry: add a `compact` two-line layout (time/level/logger on line one,
message wrapping below) used by the embedded monitoring-column Logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95
* polish(web): tooltips on icon buttons + rename "monitoring column" → "monitoring sidebar" (#1661)
- Add Mantine `Tooltip`s to the icon-only controls: Client settings, Theme
(dynamic light/dark label), the monitoring-sidebar toggle, the list-level and
per-entry Expand/Collapse toggles, the Pin/Unpin toggle, and the Replay
button. Labels mirror each control's existing aria-label.
- Rename the feature term "monitoring column" → "monitoring sidebar" across the
user-facing labels (Open/Close/Resize), comments, and tests, so the UI calls
it a sidebar consistently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95
* review: consistent sidebar wording + reuse MonitoringToggleProps (#1661)
Address the two optional notes from the PR #1662 review:
- Make the MonitoringToggle JSDoc voice consistent ("sidebar" throughout,
no lingering "column").
- Drop ViewHeader's duplicate `MonitorToggleProps` and reuse the element's
exported `MonitoringToggleProps` as the single source of truth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95
* feat(web): slide-in/out animation for the monitoring-sidebar toggle (#1661)
The header MonitoringToggle now animates in/out via a Mantine Transition whose
in/out styles drive width + margin-left (plus opacity/translateX): as its slot
grows from 0 → the 36px icon, the flex-end header row reflows and pushes the
theme + client-settings buttons one slot to the left; the exit reverses and they
slide back over. A negative margin-left while collapsed cancels the row gap so
the theme icon stays flush-right when the toggle is absent. Only appearance/
disappearance (connect / disconnect / narrow viewport) animates — opening or
closing the sidebar leaves the toggle in place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up on the re-review's two optional notes:
- Set `ENV HOME=/home/node` so the runtime-state dir (core/storage/store-io.ts
resolves it from HOME) is explicit, rather than relying on the "." → WORKDIR
fallback that happens to work because Docker doesn't set HOME on USER.
- Document that the HEALTHCHECK assumes the default --web mode; suggest
`--no-healthcheck` for --cli/--tui runs (no web server to probe).
Re-verified: build ok, HOME=/home/node, runs as node (uid 1000), `/` → 200,
health → healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Address PR #1648 review:
- Bump the GHCR action majors to match current v1 (docker/login-action v4,
docker/metadata-action v6, docker/setup-buildx-action v4,
actions/attest-build-provenance v4) to avoid Dependabot churn.
- Make the image tags explicit (`type=semver` + `flavor: latest=true`) instead
of relying on `flavor.latest=auto`, so a release lands both the version tags
and `:latest` — the README's bare `ghcr.io/…/inspector` URL won't 404.
- Run the container as the non-root `node` user with /home/node as workdir (its
writable home is where the inspector stores the catalog / OAuth state).
- Add a HEALTHCHECK that probes `/` via Node's global fetch (no curl in slim).
- Trim specification/ and docs/ from the build context. (test-servers/ stays —
`tsc -b` typechecks the integration tests that import its alias, so excluding
it breaks the build.)
Re-verified locally: `docker build` succeeds, the container runs as uid 1000
(node), serves `/` (200), and the HEALTHCHECK reports healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
v1 published a container image but v2 arrived with no Dockerfile (the
client/server/cli layout is gone). Add a v2 image for the single-package /
launcher architecture and wire the release-gated GHCR publish job.
- `Dockerfile`: two-stage build — stage 1 installs + `npm pack`s the publishable
tarball; stage 2 `npm install -g`s that tarball, so the image ships the exact
same artifact as npm with a clean `mcp-inspector` bin. Defaults to `--web` on
`0.0.0.0:6274`, browser auto-open off. `.dockerignore` keeps the context lean
and forces a clean in-container install/build.
- `publish-github-container-registry` job in main.yml (ported from v1):
release-gated, multi-arch (amd64/arm64) build+push to GHCR with a provenance
attestation. Independent of the npm `publish` job.
- README: document the image + how to run it; the release section now covers
both publish jobs.
Verified locally: `docker build` succeeds, the container serves `/` (HTTP 200
with the injected token) and dispatches `--cli` / `--tui`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Re-review nit: interpolating `${{ github.event.release.tag_name }}` directly
into the `run:` block is a script-injection surface (a crafted tag with shell
metacharacters would splice into the script). Pass it through `env: TAG` so it
arrives as a plain shell variable instead. Purely defensive; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Address PR #1647 review:
- Assert the release tag matches the root package.json version before publishing
(fail fast, before install/pack:verify) — publishing is irreversible, so a
release drafted without `npm version` or cut from the wrong commit shouldn't
silently ship a mismatched version. Tolerates a leading `v`.
- Add a `concurrency: { group: publish-npm, cancel-in-progress: false }` guard so
two releases in quick succession can't run overlapping publishes.
- Note in-comment that the triple build (build job → pack:verify → prepack) is
intentional, so a future reader doesn't "optimize" prepack away and break the
tarball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
The clients/web README was the default "React + TypeScript + Vite" template
(plus a proxy note). Replace it with a web-client-specific guide: the src/
(browser) vs server/ (Node) split and what each server/ file does, the build
pipeline (tsc -b → vite build → build:runner, producing dist/ + build/), the
component layers (elements/groups/screens/views) and theme system, the three
Vitest projects (unit happy-dom / integration node / storybook Chromium) and
the ≥90% coverage gate, Storybook (dev + play-function browser tests), and the
auth-token flow. Preserve the existing HTTP-proxy section. Points at the root
README/AGENTS.md for the repo-wide picture rather than duplicating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Wire the v2 publish pipeline into main.yml. On a published GitHub release, a new
`publish` job runs `npm run pack:verify` (the pre-publish gate — builds, packs
the real tarball, installs it into a clean consumer, and drives the bin) then
`npm publish --access public --provenance`. This is a single `npm publish` (v2
is not an npm workspace, so no v1-style publish-all/--workspaces), with a signed
provenance attestation via GitHub OIDC (id-token: write, environment: release,
NPM_TOKEN). Add the `release: types: [published]` trigger; the existing `build`
job is unchanged and the publish job is gated on the release event.
Document the single-version release/tag flow in the root README (one `npm
version` at the root; no per-client sync / check-version since only the root
package.json carries a version).
Docker/GHCR is deferred: v2 has no Dockerfile (the v1 client/server/cli layout
is gone), and a correct image needs a net-new, docker-build-tested Dockerfile.
Split into #1646.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
CI failed with an unhandled `ReferenceError: window is not defined` thrown from
a Mantine `Transition` `setTimeout` (`use-transition.mjs`) firing after the test
environment was torn down — every test passed, but the leaked timer failed the
run. happy-dom has no `matchMedia`, so `useReducedMotion` resolves to
"motion allowed" and every Transition (ScreenStage, Modal, …) schedules real
enter/exit timers; a timer that outlives its test throws post-teardown. This
surfaced now because MonitoringScreen started wrapping its screens in
ScreenStage (a Transition) where it previously rendered them directly.
Mock `window.matchMedia` in the test setup to report
`prefers-reduced-motion: reduce`, so Mantine transitions render instantly (no
timers). All other queries return `false`, matching the prior absent-matchMedia
behavior, so `useMediaQuery`-driven layout is unchanged. Full unit suite passes
with no unhandled errors across repeated runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Address PR #1641 review feedback:
- Add readInspectorVersionSafe() (non-throwing) in core/node/version.ts and use
it in the web backend, so a failure to resolve the root manifest hides the
(cosmetic) version badge instead of crashing the backend at startup. Covered
by tests for both the resolved and unresolvable paths.
- Update MonitoringScreen's docstrings: it now wraps every supplied screen in a
ScreenStage (only the active tab mounts, cross-fading) rather than rendering
just the active tab's node.
The third fetch-consolidation suggestion (one useInitialConfig instead of three
GET /api/config hooks) is tracked as a follow-up in #1643.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Make the server-list toolbar's expand/collapse toggle and open/close-monitoring
icons the same width and spacing as the header's theme / client-settings icons:
- ListToggle (default variant), PinColumnButton, and the MonitoringControls
close button now render as `ActionIcon variant="subtle" size={36}` (glyph 20),
matching the header's 36px ActionIcons, instead of wider `Button`s.
- ServerListControls uses `gap="sm"` so its buttons sit the same 12px apart as
the header's RightSection controls.
Update a TaskListPanel test that found the toggle by a brittle
`mantine-Button-root` class to select it by its accessible name instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
When a server is connected, has monitor screens to show, and the monitoring
column isn't open, show an "open monitoring column" button to the right of the
list expand/collapse toggle on the server list. It reuses PinColumnButton (the
same right-sidebar affordance the monitor screens use, now with an optional
label), so opening the column is reachable from the server list — not only from
a monitor screen's pin button. InspectorView gates it on
`canPin && monitorAvailable.length > 0 && !effectivePinned`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Extract the primary pane's per-screen enter/exit transition into a shared
`ScreenStage` element (the `fade-up` Mantine Transition: incoming screen slides
up + fades in while the outgoing fades down + out, both absolutely positioned).
InspectorView now imports it (behavior unchanged), and the pinned monitoring
column uses it too — via a new `fill` variant that stretches the stage to the
column's height — so switching Logs/Protocol/Network animates the same way as
switching the main screens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Display the single-source version (root package.json) in the web UI's
lower-right corner, in grey — matching the CLI and TUI. The browser can't read
the filesystem, so the version flows backend → browser via GET /api/config:
- Add `version?: string` to InitialConfigPayload; the web backend
(webServerConfigToInitialPayload) reads it once via the shared
readInspectorVersion() and tags it onto the payload.
- Add a `useInspectorVersion` hook mirroring useSandboxUrl/useServerListWritable.
- Add a presentational `VersionBadge` element rendered fixed lower-right, grey,
non-interactive; styling lives in a `versionBadge` Text theme variant (no
inline styles, --inspector-* tokens).
- Wire it into App.tsx.
Verified live: /api/config serves `version: "2.0.0"` and the badge renders
`v2.0.0` fixed at the lower-right in grey. Tests: useInspectorVersion (full
branch coverage), VersionBadge (unit + Storybook), web-server-config payload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Re-review found the same process.exit()-skips-finally pattern in verifyWeb: a
failure during the --web phase goes through fail() → process.exit(1), skipping
its `finally { stop() }`, so the spawned web server was orphaned — left on the
port, where a later run could fetch a stale false-200 and mask a regression.
Track the live child in a module `webChild` and have fail() SIGTERM it before
exiting, alongside the workDir cleanup; stop() clears it on the success path.
Verified: a forced web-phase failure exits 1, retains the tarball, and leaves
no listener on the port (child killed, not orphaned).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Re-review caught that the previous fix was inert: fail() calls process.exit(1),
which skips finally blocks — so `cleanupTarball = false` never took effect and,
worse, the temp work dir (a full node_modules from the real install) leaked on
every failed run.
Move cleanup into fail() itself — the single failure-exit point, called from
everywhere including deep inside the run. On failure it removes the heavy work
dir and leaves the packed tarball in place for post-mortem inspection; the
success path removes both inline before the OK log. Drop the dead
`cleanupTarball` flag entirely.
Verified both paths: success removes work dir + tarball (exit 0); a forced
failure removes the work dir, retains the tarball, and exits 1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Review follow-up: `cleanupTarball` was always true (set true at init and again
in the catch), so the finally guard was dead logic. Make it meaningful — retain
the packed tarball on failure so a packaging problem can be inspected, and log
where it is; the happy path still removes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
The Inspector ships as one package with one version, but four client
package.jsons each carried their own `version` and were read at runtime
(cli.ts, tui App.tsx), and core hardcoded a third, stale value (0.20.0) that
the TUI and web actually reported to servers. Consolidate onto the root
package.json as the single source of truth.
- Add core/node/version.ts: shared, Node-only readInspectorVersion() that walks
up to the root package.json (always shipped in the tarball). 100% covered by a
new integration test.
- CLI and TUI read the version via the shared reader instead of their own
manifest; TUI now also passes a real clientIdentity (was falling through to
the stale core default).
- Drop the `version` field from all four client package.jsons; the root keeps
the only version. TUI header name/description become local display constants.
- core default client identity: replace the stale 0.20.0 hardcode with a neutral
0.0.0 placeholder (real clients pass clientIdentity; web will source its
version from GET /api/config, see #1639).
- Stop shipping clients/cli/package.json and clients/tui/package.json (no longer
read at runtime) — supersedes the earlier "ship them" fix; update pack:verify
and the root README packaging invariants accordingly.
- Add core/node/** to the web coverage include so the new module is gated.
- Add the shared-code architecture diagram to the root README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
CLAUDE.md included @./README.md, which did not exist. Add a root README
covering project layout, setup, the @inspector/core shared package, the web
"dumb components" + Storybook approach, test-servers, build, the testing/quality
gate, publishing, and how AGENTS.md/CLAUDE.md govern contributions for humans
and agents alike.
Move the publishing/packaging documentation (single-package tarball, "files"
invariants, pack:verify) from the launcher README to the root README, where
whole-repo packaging belongs; the launcher README now points there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Prepare the single `@modelcontextprotocol/inspector` tarball for publishing
and add a script that verifies the real published package end to end.
- Add `scripts/pack-and-verify.mjs` (`npm run pack:verify`): build, `npm pack`,
install the tarball into a clean consumer, and drive the installed
`mcp-inspector` bin (web/cli/tui) — catching "works in --dev, breaks under
npx" packaging/path failures the in-repo smokes miss. Local/release tool
(install needs network), not wired into the fast validate/ci loop.
- Disable source maps in the cli/tui/web-runner tsup configs (~halves the
unpacked payload; runtime doesn't need them).
- Add `clients/web/.npmignore` so `clients/web/build` (prod web-server runner)
ships — npm's packlist was honoring the nested `.gitignore` `build/` entry
over the root "files" allowlist, dropping the runner while `dist/` slipped
through.
- Ship `clients/cli/package.json` and `clients/tui/package.json`: both bundles
read their own package.json at runtime (CLI client identity, TUI header), so
the installed CLI crashed on connect and the installed TUI crashed on launch
without them.
Document the packaging invariants and pack:verify in the launcher README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw