inspector/clients/web
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Cliff Hall ec185f1053
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
Logging: per-request log level on modern servers (era fork) (#1629) (#1724)
* Logging: per-request log level on modern servers (era fork) (#1629)

On modern (2026-07-28) servers `logging/setLevel` and the session-scoped
level are gone: the client opts into logs per request via the
`io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not
attach it automatically, so server logs are silently absent until stamped.

Core (`InspectorClient`):
- `setModernLogLevel(level | undefined)` / `getModernLogLevel()` store the
  opt-in level; `mergeMeta` stamps it on every outgoing request, gated on
  the negotiated modern era so legacy connections are untouched.
- Cleared on disconnect so it can't leak into the next connection.

Web UI:
- `LogControls` forks on `protocolEra`: legacy keeps the `logging/setLevel`
  selector + Set button; modern shows a per-request opt-in Select (Off +
  levels, applied immediately) with copy explaining logs arrive on the
  originating request's stream.
- Threaded `protocolEra` / `modernLogLevel` / `onSetModernLogLevel` through
  LoggingScreen and InspectorView from App.

Tests: unit coverage for the modern LogControls/LoggingScreen fork;
integration coverage proving the `logLevel` `_meta` is stamped on a real
modern server once opted in (and not on legacy).

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

* test-servers: logging era-fork showcase configs + proof screenshots (#1629)

Add two manual-testing configs and README docs for the logging era fork:

- logging-legacy-http.json: plain streamable-HTTP server with `logging: true`
  and a `send_notification` tool. The Logs tab shows the legacy session-scoped
  "Set Active Level" selector + Set button; calling the tool streams logs.
- logging-modern-http.json: same, with `transport.modern: true`. Connect with
  Protocol Era = Modern and the Logs tab shows the per-request opt-in control;
  the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every
  request (visible in the Network tab request body).

pr-screenshots/ documents the verified behavior end-to-end against both real
servers: the legacy control with logs streaming, the modern per-request
control, and the modern tools/call request body carrying the stamped logLevel
_meta.

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

* test-servers: send_notification emits via per-request notifier so modern logs stream (#1629)

The `send_notification` tool sent its `notifications/message` through the
global `server.server.notification()`. On the modern (2026-07-28) stateless
leg that isn't tied to the in-flight request, so the SDK answered the tool
call with plain JSON and dropped the log — nothing reached the Inspector's
Logs panel.

Prefer the per-request `extra.sendNotification` (rebuilt from
`ctx.mcpReq.notify`): scoped to the current request, it makes the SDK upgrade
the response to SSE and stream the log back on the originating request's
stream. Falls back to the global notifier (legacy path / no per-request
context). Verified end-to-end: opting in via the Logs-tab "Log Level per
Request" control and calling send_notification now streams the entry on the
modern server; legacy behavior unchanged (server-notification integration
tests green).

README documents the modern logging showcase and the caveat that this test
server streams whenever a tool emits — the SDK beta strips the per-request
envelope from the handler context, so the tool can't gate on the client's
logLevel opt-in (the client-side `_meta` stamp #1629 owns is still fully
visible in the Network tab).

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

* test-servers: send_notification uses SDK request-scoped log() → spec-faithful modern gating (#1629)

The previous fix routed `send_notification` through `extra.sendNotification`
(`ctx.mcpReq.notify`), which streams on the modern leg but emits
unconditionally — the log showed up even when the client hadn't opted in via
the per-request `logLevel`.

The SDK already provides the correct primitive: `ctx.mcpReq.log(level, data,
logger)` (added in `McpServer.buildContext`). It applies the era-correct
threshold gating itself — on the modern (2026-07-28) leg it reads the
per-request `logLevel` opt-in from the request envelope and drops the message
when the client didn't opt in or the level is below the requested severity; on
legacy it honors the session level from `logging/setLevel` — and emits through
`notify`, so the modern response still upgrades to SSE and the log rides the
originating request's stream.

Surface it as `extra.log` (`toHandlerExtra` maps `ctx.mcpReq.log`) and have
`send_notification` prefer it, falling back to the global notifier for callers
without per-request context. Verified end to end: opting in via the Logs-tab
"Log Level per Request" control stamps `logLevel` in the request `_meta` and
streams the log; setting it back to Off omits the key and the server gates the
log (plain JSON response, no notification). Legacy server-notification
integration tests still green. (No SDK change needed — the earlier apparent
gap was our test server using the wrong low-level API.)

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

* web+core: add "Log Level per Request" as a per-server setting, defaulting to on (#1629)

Adds the modern per-request log level as a persisted Server Settings option
(Options → "Log Level per Request", below Protocol Era), so a server can opt
into logs by default instead of the Logs-tab control starting at Off.

- types.ts: `ModernLogLevel` (`"off"` | the 8 levels), `DEFAULT_MODERN_LOG_LEVEL`
  = "debug" (default-on so a modern connection surfaces server logs out of the
  box), `MODERN_LOG_LEVELS`, and an `isModernLogLevel` guard. Field added to
  `InspectorServerSettings` and `StoredMCPServer`.
- serverList.ts: round-trip the field (lift on read, omit-on-default on write,
  drop unknown literals) mirroring `protocolEra`; add to the persisted field
  allowlist.
- remote/node/server.ts: validateSettings accepts/validates it and the
  normalize path drops a malformed literal.
- inspectorClient.ts: the constructor seeds `modernLogLevel` from the setting
  (`"off"` → not opted in), so a modern connection stamps the level from the
  first request without UI interaction. mergeMeta still gates on the modern era.
- ServerSettingsForm/Modal: a Select (Off + 8 levels) with a modern-era
  description. App seeds the live Logs-tab control from the setting on connect
  so the two stay in sync.

Tests: form Select (default/onChange/off/reflect), modal handler, serverList
round-trips, route validation (accept/reject/drop), and constructor seeding
(seeded level stamped from the first request; "off" opts out). The prior
modern-era "stamps once set" test is updated for the new default-on behavior.
Coverage gate green.

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

* Fix CI + review: format ServerSettingsForm, drop stray network export, broaden ignore

- Prettier-format ServerSettingsForm.tsx (the format:check failure that broke
  CI — my pre-push `npm run format` from the repo root was a silent no-op since
  there is no root format script; formatting is per-client).
- Remove `inspector-network-logging-modern-2026-07-19T15-11-27.013Z.json`, a
  Network-tab export debugging artifact accidentally committed in ca2aa526
  (flagged by review).
- Broaden the .gitignore debugging-artifacts section with
  `/inspector-network-*.json` so future network exports are ignored (the
  existing `/inspector-history-*.json` didn't match the network export naming).

Verified with the full `npm run ci` (validate → coverage → smoke → storybook),
green across all clients.

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

* test: add modern-era log delivery + gating e2e tests (#1629)

The modern-era suite covered the client-side `_meta` logLevel stamp but not
that a log actually flows back — the legacy suite already has that
(`should receive server-initiated notifications via SSE/streamable-http`), so
the modern side was the gap.

Add two live e2e tests against a modern (`createMcpHandler`) server serving the
`send_notification` tool with `logging: true`:
- opted-in (server setting = a level): calling the tool delivers a
  `notifications/message` over the request's SSE stream, with the right
  level/logger/data.
- not opted in (setting "off"): the tool still returns a result, but the server
  gates the log — no `notifications/message` is received.

This exercises the full path end to end: the client seeding + `_meta` opt-in,
and the server's `send_notification` → `ctx.mcpReq.log` era-correct gating.

Verified with the full `npm run ci` (green across all clients).

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

* web: hide the modern "Log Level per Request" setting on legacy connections (#1629)

The per-request log level is meaningless on a legacy server (which uses
logging/setLevel), so hide it in Server Settings when the era is legacy:
- protocolEra pinned to "legacy" → hidden (no connection needed), or
- protocolEra "auto" that negotiated legacy at connect time → hidden.

It stays visible for a pinned "modern" server, and for an "auto" server that is
either not yet connected (era unknown) or resolved to modern.

Threads the negotiated era down: App passes `negotiatedEra` to
ServerSettingsModal only when the settings target is the live-connected server
(`connectionStatus === "connected" && settingsModalTargetId === activeServerId`);
the modal forwards it to ServerSettingsForm, which gates the control on
`configuredEra === "modern" || (configuredEra === "auto" && negotiatedEra !== "legacy")`.

Tests: visibility matrix on the form (legacy hidden; auto+legacy hidden;
auto+unconnected and auto+modern and modern shown) and a modal hide case; the
existing modern-log tests now base on a modern-era settings object. Verified in
the browser (legacy hides, switching to modern reveals it reactively) and with
the full `npm run ci` (green across all clients).

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-19 17:08:22 -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 Logging: per-request log level on modern servers (era fork) (#1629) (#1724) 2026-07-19 17:08:22 -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.