inspector/clients/web
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Cliff Hall 73d9b181fe
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
History: MRTR conversation grouping + modern message vocabulary (#1705)
* History: MRTR conversation grouping + modern message vocabulary

Teach the Protocol/History view the modern (2026-07-28) message vocabulary,
correlating it off the frames the transport already captures — no change to how
MRTR is driven (manual driving is deferred to #1704).

- Correlation helpers in protocolUtils: extractResultType, extractRequestState,
  extractSubscriptionId, isModernFrame, and groupProtocolEntries — clustering the
  contiguous rounds of one logical operation (original call -> input_required ->
  retried call with a new id -> final result) that share a requestState token.
- New MrtrConversation group renders that cluster as one expandable unit with a
  status derived from the final round, reusing ProtocolEntry for each round.
- ProtocolEntry: resultType badge (input required / complete), a "modern" frame
  badge for server/discover, subscriptions/listen, and the acknowledged
  notification, and a copyable subscriptionId.
- ProtocolListPanel folds each section through the grouping pass and shows an era
  badge in the header. Era is threaded from the negotiated protocolEra
  (App -> InspectorView -> ProtocolScreen -> panel) and never inferred from
  frames, respecting the transcript-classification caveat.
- Shared EraBadge element (+ eraUtils); ConnectionInfoContent reuses it.
- test-servers/configs/modern-mrtr-http.json serves an elicitation tool over the
  modern leg for eyeballing a real MRTR round-trip.
- Pre-bundle @modelcontextprotocol/client in optimizeDeps.include so the
  Storybook vitest project doesn't re-optimize mid-run once protocolUtils pulls
  the client barrel into the browser graph.

Closes #1627

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: real MRTR tool + live-verified History grouping

While driving the modern flow in the browser, the elicitation-based config
proved unable to produce MRTR: the modern (2026-07-28) SDK server rejects
`server.elicitInput` — server→client requests aren't allowed on that leg; a
tool must instead return `inputRequired(...)`. So the shipped
`modern-mrtr-http.json` (and its README claim) were wrong.

- Add a genuine MRTR tool preset (`mrtr_confirm` / `createMrtrTool`): its handler
  returns `inputRequired({ inputRequests: { confirm: elicit(...) } })` on the
  first call and the final result once the client echoes the answer back on the
  retry.
- Teach the composable test-server to support it: the tool wrapper forwards an
  `InputRequiredResult` verbatim (instead of coercing it to a CallToolResult),
  and `toHandlerExtra` surfaces `inputResponses`/`requestState` so a handler can
  branch between the original call and the retry.
- Point `modern-mrtr-http.json` at the new preset; correct the README to
  describe the real mechanism.
- Add an integration test that drives the full round-trip through InspectorClient
  (auto-fulfilling the embedded elicitation) and asserts the final result is only
  reachable after the input_required round + retry — regression cover that would
  have caught the original config bug.

Verified live in the browser: the History view renders the round-trip as one
grouped "MRTR conversation" (Round 1 input_required → Round 2 complete), keyed by
the real requestState, under the Modern era badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Address review notes: dedupe status badge, unique MRTR token, import alias

Non-blocking review feedback on #1705:

- ProtocolEntry: suppress the redundant green "OK" status badge when a
  `resultType` badge already conveys the outcome (a modern success shows only
  `complete`/`input required`). Errors/pending have no `resultType`, so their
  status badge still shows.
- createMrtrTool: mint a unique `requestState` per original call (closure
  counter) so two same-action invocations don't fold into one conversation
  during manual eyeballing. Real SDK tokens are already unique per operation.
- ProtocolEntry: align the `MessageEntry` import to the `@inspector/core` alias
  used by its siblings (was a deep relative path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review round 2: green `complete` badge, lighter embedded MRTR container

Non-blocking observations from the second review:

- ProtocolEntry: `resultType` "complete" now renders green (was gray) so a modern
  success keeps the at-a-glance green success affordance a legacy "OK" had, now
  that the redundant OK badge is suppressed. `input_required` stays yellow.
- MrtrConversation: in the narrow monitoring (embedded) column, drop the outer
  container border and tighten padding — the per-round entries are already
  bordered cards, so this avoids a busy border-in-border nesting. The full-width
  view keeps the bordered surface that sets a conversation apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review round 3: module-scope MRTR counter; revert embedded border change

- test-servers: hoist the MRTR `requestState` counter to module scope. The
  modern (2026-07-28) leg is stateless — `createMcpHandler` rebuilds the server
  (and every tool closure) per request — so the previous per-closure counter
  reset to 0 on every request and every token ended in `:1`, defeating its
  purpose. A module-level counter survives the per-request factory recreation
  and increments monotonically across the process, so same-action invocations
  get distinct tokens and don't fold into one conversation. (Review finding.)
- MrtrConversation: revert the embedded borderless/tighter-padding change — per
  maintainer feedback from manual testing, dropping the border didn't improve
  the narrow-column visual, so the group keeps its border in all layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:02:11 -04:00
..
.storybook web: Accessibility — accessible palette + WCAG AA contrast, labels, names, roles (#1679) 2026-07-13 18:20:55 -04:00
public feat(connection-info): wire Connection Info modal to the connection handshake (#1377) 2026-05-30 16:42:37 -04:00
server History: MRTR conversation grouping + modern message vocabulary (#1705) 2026-07-17 20:02:11 -04:00
src History: MRTR conversation grouping + modern message vocabulary (#1705) 2026-07-17 20:02:11 -04:00
static web: Wave 2 — spec-conformant MCP Apps host (rollup of #1565–#1572) (#1660) 2026-07-11 22:14:20 -04:00
.gitignore feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452) 2026-06-14 20:27:49 -04:00
.npmignore build(publish): fix v2 single-package bundle and add pack:verify 2026-07-10 12:36:26 -04:00
chromatic.config.json add chromatic.config.json 2026-03-22 15:42:09 -04:00
eslint.config.js test(coverage): bootstrap vitest unit infra and add tests at 90%+ per file (#1245) (#1269) 2026-05-01 15:15:32 -04:00
index.html feat(connection-info): wire Connection Info modal to the connection handshake (#1377) 2026-05-30 16:42:37 -04:00
package-lock.json SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624) 2026-07-15 10:44:24 -04:00
package.json SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624) 2026-07-15 10:44:24 -04:00
README.md web: deep-link openApp/appArgs/autoOpen — pre-select & pre-fill an app from the URL (#1577) 2026-07-11 22:58:34 -04:00
tsconfig.app.json Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage. 2026-07-04 21:56:45 -07:00
tsconfig.json test(coverage): bootstrap vitest unit infra and add tests at 90%+ per file (#1245) (#1269) 2026-05-01 15:15:32 -04:00
tsconfig.node.json feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452) 2026-06-14 20:27:49 -04:00
tsconfig.storybook.json fix dark mode, storybook config, and minor UI issues 2026-03-23 13:12:37 -04:00
tsconfig.test.json Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage. 2026-07-04 21:56:45 -07:00
tsup.runner.config.ts SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624) 2026-07-15 10:44:24 -04:00
vite.config.ts SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624) 2026-07-15 10:44:24 -04:00
vitest.shims.d.ts * Added base project to clients/web 2026-03-16 22:47:03 -04:00

MCP Inspector Web Client

The browser incarnation of the Inspector: a Vite + React + Mantine single-page app backed by a small Node (Hono) server. The SPA is presentational — it renders data and fires callbacks; all MCP state comes from the shared @inspector/core hooks. The backend proxies MCP connections, serves the built SPA, and exposes /api/*.

This README covers what's specific to the web client. For the repo-wide picture (the @inspector/core shared package, the "dumb components" philosophy, the top-level validate/coverage/ci scripts, and publishing), see the root README.

Two halves: src/ (browser) and server/ (Node)

Path Runs in Purpose
src/ browser The React SPA — components, hooks, theme, entry (main.tsx).
server/ Node The dev/prod backend wiring (never imported by the browser).

The server/ directory holds the Node-only backend:

  • vite-hono-plugin.ts — mounts the Hono /api/* middleware onto the Vite dev server (so npm run dev has a live backend).
  • server.ts — the standalone Hono production server (serves dist/ + /api/*).
  • run-web.ts / start-vite-dev-server.ts — entry points the launcher calls for prod --web and --web --dev.
  • web-server-config.ts — env parsing, the GET /api/config payload, the startup banner.
  • inject-auth-token.ts — embeds the API token into the served index.html (see Auth token).
  • sandbox-controller.ts — the MCP Apps sandbox HTTP server; ensure-web-build.ts — builds dist/ on demand for prod --web; vite-base-config.ts — shared optimizeDeps exclusions.

Development

npm run dev        # Vite dev server + Hono /api middleware, HMR

For the launcher-driven prod/dev flows (npm run web / web:dev), see the root README — those run the built launcher.

Build

npm run build      # tsc -b  →  vite build  →  build:runner

Two artifacts come out, both of which ship in the published package:

  • dist/ — the browser SPA (vite build). Served statically by the prod backend.
  • build/ — the Node prod-server runner (build:runner = tsup --config tsup.runner.config.ts), which bundles server/ + @inspector/core into one ESM file and externalizes npm deps.

build:client runs only the vite build half when you just need fresh dist/.

Component layers

Components live under src/components/ in four layers, smallest to largest:

Layer Count What it is
elements/ ~31 Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives.
groups/ ~63 Composite pieces (cards, panels, modals, control bars).
screens/ ~11 Full tab screens (Tools, Resources, Servers, monitoring screens…).
views/ 1 InspectorView — the top-level layout that composes the screens.

Every screen and element has a *.stories.tsx (see Storybook). Styling follows the Mantine-first rules in AGENTS.md — theme variants and component props over CSS, --inspector-* tokens over raw colors.

MCP Apps screen automation contract

The Apps screen exposes a small, stable set of data-testid / data-* attributes so an automated driver (deep-link auto-open, CI review harness) can waitForSelector on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:

Attribute Where Meaning
data-testid="apps-form" Apps content card The container that carries the status/error attributes below.
data-app-status on apps-form Renderer lifecycle: idle (nothing running) → loading (bridge building / ui/initialize in flight) → ready (view fired notifications/initialized) → error (bridge factory threw/rejected). Poll for ready.
data-app-error on apps-form The failure reason string when data-app-status="error" (e.g. no connected client); absent otherwise.
data-testid="apps-error" error panel Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame.
data-testid="open-app" Open App button Launches the selected app.
data-testid="apps-stage" Stage-partial button Snapshots the current form values for progressive-render testing.
data-testid="apps-messages" messages panel ui/message submissions from the running view.
data-testid="apps-logs" app-logs panel notifications/message log entries (default-expanded).

The renderer lifecycle itself is AppRendererStatus (loading | ready | error) reported via AppRenderer's onAppStatusChange; the screen maps it to data-app-status. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's onResourceError; because the app never reaches ready in that case, a driver times out on data-app-status and reads the toast.

A driver (launcher, CLI --print-handoff, CI review harness) can reach a connected inspector with a single navigate by encoding the target in the URL query string. Parsing + security gating live in src/utils/deepLink.ts (parseDeepLink), and a returned DeepLink is proof the link passed validation.

http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
Param Meaning
serverUrl The MCP server URL. Restricted to http: / https: (a crafted javascript: / data: / file: value is rejected). Canonicalized via URL.href so it matches the OAuth store's key form.
transport http (streamable-HTTP, the default) or sse. Unknown values fall back to http.
autoConnect CSRF gate. Must equal the per-launch MCP_INSPECTOR_API_TOKEN. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing ?MCP_INSPECTOR_API_TOKEN= param. Without a match the link is ignored.

The deep link upserts a stable deep-link catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the AppShell.Header as a machine-readable contract, so a driver can waitForSelector and read the failure reason without scraping a transient toast:

Attribute Where Meaning
data-testid="connection-status" header The element carrying the attributes below.
data-status on connection-status The live ConnectionStatus (disconnected → connecting → connected / error). Poll for connected.
data-error-message on connection-status Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error.
data-deeplink on connection-status parsed (a valid deep link drove this load), rejected (deep-link params present but the token/serverUrl gate failed), or none. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave data-status idle.

Landing on a rendered app

Three further params extend the deep link to pre-select — and optionally auto-open — an MCP App, so a driver reaches a rendered widget with zero clicks:

…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
Param Meaning
openApp The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it.
appArgs base64url(JSON) object of form values. Merged over the tool's schema defaults (collectSchemaDefaults) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to {}.
autoOpen Same CSRF gate as autoConnect — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened.

The app-side render lifecycle is observable through the MCP Apps screen automation contract above (data-app-status="ready"), so a driver can waitForSelector the whole connect → open → ready chain deterministically.

Theme (src/theme/)

Each customized Mantine component has a Theme<Name>.ts file (Button.ts, Text.ts, …, ~21 total) exporting a Theme<Name> constant; the barrel index.ts re-exports them and theme.ts assembles the MantineProvider theme. Theme files hold app-wide defaults and variants (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in App.css. Element components import from @mantine/core (never from theme/) — the theme layer is applied transparently by the provider.

Testing

Tests run under three Vitest projects (configured in vite.config.ts), each in the right environment:

Project Env Scope Script
unit happy-dom Components, hooks, utils (*.test.tsx beside the source) npm test
integration node @inspector/core + transports + auth, spawning the real stdio test server (src/test/integration/**) npm run test:integration
storybook real Chromium Story play functions as interaction tests npm run test:storybook
  • npm test runs the fast unit project (happy-dom). test:watch for the loop.
  • Integration tests run in a real Node env (no happy-dom, 30s timeouts) and spawn test-servers/build/test-server-stdio.js as a subprocess, so pretest/the coverage script build the test servers first (test-servers:build).
  • npm run test:coverage runs unit and integration under v8 instrumentation and enforces the per-file ≥90% gate (lines/statements/functions/branches) — the same gate CI runs. Genuinely-unreachable branches are annotated with a justified /* v8 ignore … */, not waved through.

Integration tests live under src/test/integration/ mirroring the core/ layout; anything placed there is picked up by the integration project automatically. Render components with renderWithMantine (src/test/renderWithMantine.tsx) so they get the project theme.

Storybook

npm run storybook        # dev server on :6006
npm run build:storybook  # static build
npm run test:storybook   # run every story's play function in headless Chromium

Storybook is first-class here because the components are presentational — each renders against fixture props. Play functions double as interaction tests and run headless in real Chromium via @vitest/browser-playwright + @storybook/addon-vitest (the storybook Vitest project). They're part of npm run ci (which installs the Chromium binary first) but kept out of the fast validate loop since they need the browser.

Auth token

The dev/prod backend guards every /api/* route with x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>. The browser recovers the token, in priority order (see App.tsx getAuthToken()): the window.__INSPECTOR_API_TOKEN__ global injected into index.html on every page load (server/inject-auth-token.ts), then a ?MCP_INSPECTOR_API_TOKEN=… query param, then sessionStorage. Injection is a no-op when auth is disabled (DANGEROUSLY_OMIT_AUTH). See the root AGENTS.md for the full rationale.

HTTP proxy support

The web backend connects to remote MCP servers through the shared Node transport (core/mcp/node/transport.ts), which honors the conventional proxy environment variables: HTTPS_PROXY / HTTP_PROXY (and their lowercase forms) select the proxy, and NO_PROXY exempts hosts. Routing is powered by undici's EnvHttpProxyAgent, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.