inspector/clients/web
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Cliff Hall 07d6316fba
build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777)
* build(web): fail vite build on a browser-externalized Node built-in (#1769)

Vite 8 (rolldown) externalizes a node:* / Node built-in that reaches the
browser graph with a warning and ships a `module.exports = {}` stub — the
build still succeeds, shipping a broken bundle. The runtime browser smoke
(smoke:web:browser, #1615) only catches the subset where that stub is *called*
at module init; a stub imported but never called ships silently and nothing
gates it.

Add a Vite build plugin that turns that specific browser-externalization
warning into a hard `vite build` error, moving detection upstream into
`npm run build` / `validate` and additionally catching the never-called case
the runtime smoke can't see.

- clients/web/server/browser-externalized-builtin-gate.ts: Vite-agnostic
  detection + error + per-build state machine (unit-tested to the ≥90 gate).
- vite.config.ts: thin Vite wiring. A throw inside rolldown's onLog is
  swallowed (the one hook where it doesn't abort — verified against vite@8.0.0),
  so the gate records the warning in onLog and re-throws in buildEnd. Scoped to
  `vite build` (apply: 'build'); the Node runner build (tsup) is unaffected.
- scripts/verify-build-gate.mjs + verify:build-gate (in npm run ci and the
  GitHub workflow): runs a real build with a node:fs probe and asserts it fails
  via the gate — the only check that catches the warning phrasing drifting in a
  future Vite bump and silently disabling the message-keyed gate.

There is no stable log `code` on the rolldown warning (only message + plugin),
so the gate keys off the documented message phrasing; the drift guard above is
what keeps that safe across Vite versions. smoke:web:browser stays as the
runtime backstop.

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

* build(web): harden the browser-externalized-builtin gate per review (#1769)

Review follow-ups on PR #1777:

- Per-build state: add gate.reset() called from the plugin's buildStart, so a
  `vite build --watch` rebuild doesn't inherit a prior build's recorded warning
  (the plugin instance is reused across rebuilds). Makes the docblock true.
- buildEnd(error) now only asserts on the success path — rolldown also calls
  buildEnd(error) when the build already failed for another reason, and throwing
  then would mask that real error with the #1769 message.
- verify-build-gate.mjs: drop the shell `rm -rf` cache-clear entirely (verified
  the gate fires reliably without it — the earlier "caching" symptom was the
  onLog-swallow behavior, not a stale cache); add a result.error spawn-failure
  check so a missing `npx` isn't misdiagnosed as "not via the gate"; add
  SIGINT/SIGTERM handlers so an interrupt during the multi-minute build still
  restores the mutated entry; add a progress log before the spawn.
- Match the double-quote style of the sibling server/ files.
- Docs: list browser-externalized-builtin-gate.ts in the AGENTS.md server/ block
  and the clients/web/README server/ list (with why a build helper lives under
  server/), and add verify-build-gate to the root README scripts/ line.

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

* build(web): round-2 review hardening for the #1769 build gate

- Scope the gate to the browser (`client`) environment via applyToEnvironment,
  so a future SSR/node environment built from this config isn't failed for a
  legitimate node:* import — browser-only intent is now structural.
- Report ALL externalized built-ins in one build, deduped, instead of only the
  first offender (N leaks no longer means N fix-and-rebuild cycles).
  browserExternalizedBuiltinError now takes string[]; gate uses a Set.
- verify-build-gate.mjs: pin Vite with `npx --no-install` so the drift check
  can never silently run a registry-fetched Vite instead of the repo-pinned one
  (the whole point is version fidelity); append the probe instead of prepending
  (ES imports hoist, and appending won't demote a future leading directive);
  clarify the signal-handler comment (handlers are a backstop — a blocked
  spawnSync tears down via the process group first).
- AGENTS.md: update both `ci` pipeline enumerations to include verify:build-gate
  and drop the now-false "Storybook is the only CI step left out" claim.

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

* build(web): round-3 review polish for the #1769 build gate

- Error message: name the transitive-dependency remedy (a resolve.alias to a
  browser shim) — "remove the import / gate behind the backend" doesn't apply
  when the node:* import lives in node_modules.
- isBrowserExternalizedBuiltinLog is now a `message is string` type guard, so
  recordLog drops its redundant `!== undefined` clause while still typing the
  Set.add() — a real one-liner, not a coverage-only removal.
- verify-build-gate.mjs: docblock now states WHY it builds the real config +
  entry (catches config-level regressions — plugin removed, onwarn suppression
  added — that a temp config would miss), so the minute-long build isn't later
  "optimized" into a temp entry and silently lose that coverage. Guard the
  entry read so a renamed main.tsx fails through fail() with an actionable
  message. On a botched restore, save the captured original to a sidecar .bak
  and point there instead of recommending `git checkout --`, which would
  discard uncommitted edits.
- AGENTS.md: fix the muddled CI-steps sentence (GitHub CI runs the whole chain
  as separate steps, Storybook last) and note the applyToEnvironment client-only
  scoping in the gate prose.

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

* build(web): round-4 review polish — sharper verify-build-gate diagnostics (#1769)

All in scripts/verify-build-gate.mjs's failure paths:

- Split the "build SUCCEEDED" failure into three diagnoses keyed off the
  captured output, so it names the right file: warning present but build passed
  → plugin not applying (removed from `plugins`, `applyToEnvironment` env-name
  mismatch, or an `onwarn` suppression); node:fs present but phrase absent →
  phrasing drift, update the constant; neither → probe never reached the graph.
  The old message hard-coded "phrasing drifted", which misdirects for the
  config-regression class this script exists to catch. Added applyToEnvironment
  to the docblock's config-regression list too.
- Bound the build with a 10-min spawnSync timeout (+ SIGKILL); a timeout sets
  result.error (ETIMEDOUT) and reports via the existing branch, instead of
  burning to the GitHub job's 360-min default with no output.
- Guard the probe write so a read-only/EACCES checkout fails through fail()
  with an actionable message instead of escaping as a raw stack.
- SIGTERM now exits 143 (was 130, which is SIGINT's code).

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

* build(web): round-5 review polish — self-consistent verify-build-gate (#1769)

All in scripts/verify-build-gate.mjs:

- Drift guard: assert the gate source file still contains the mirrored
  KNOWN_PHRASE literal, failing fast if the two drift. Without it, a stale
  KNOWN_PHRASE after a phrasing update would make the three-way diagnosis
  misreport a plugin-not-applying regression as a phrasing drift — the exact
  misdirection the diagnosis exists to avoid.
- Make the .bak safety net reachable on the path that needs it most: restoreEntry
  now catches its own write failure and routes to a shared saveBackupAndFail
  helper, instead of letting the write escape the finally as a raw stack (which
  skipped the .bak fallback and left the probe injected in the entry).
- Add the fourth "plugin isn't applying" cause to the diagnosis: a future Vite
  emitting the warning before the client env's buildStart reset clears it (same
  symptom as the other three).
- Flag once, on the failing-check path, that clients/web/dist holds a probe
  build so a local debugger doesn't serve it unaware.

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

* build(web): round-6 review polish — verify-build-gate literal contracts (#1769)

All in scripts/verify-build-gate.mjs; one theme — the literals the script
asserts on can each go stale independently of what they mirror:

- Success key: assert on the gate's distinctive thrown-error prefix
  "Build failed (#1769)", not a bare "#1769". The issue number also appears in
  vite.config.ts comments, so a code frame from an unrelated build error there
  could otherwise make the check report OK with the gate dead — the one
  direction it must never get wrong (a false pass hiding a dead gate).
- Drift guard now anchors on the BROWSER_EXTERNALIZED_BUILTIN_PHRASE assignment
  (regex) instead of a whole-file substring match, so the old wording lingering
  in a comment can't mask a changed constant; and its readFileSync of the gate
  module is guarded so a moved file fails through fail() actionably.
- Extract PROBE_MODULE ("node:fs") used to build both the PROBE and the
  diagnosis check + messages, so changing the probe module can't silently
  misroute a phrasing-drift failure into "probe never reached the graph".
- Comment the load-bearing diagnosis order (phrase before module-name).

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

* build(web): round-7 review polish — drift-guard both mirrored literals (#1769)

- Extract ERROR_PREFIX ("Build failed (#1769)") and drift-guard it against the
  gate module too, and use it as the success key. Previously only KNOWN_PHRASE
  was guarded; the prefix is mirrored across the same TS/.mjs boundary for the
  same reason, so rewording the gate's thrown error would make a correctly-firing
  gate report "the build broke for another reason" (benign direction, but the
  same misdirection the other guards close).
- Write the restore-failure .bak under the OS temp dir, not next to the entry:
  a sidecar inside src/ is untracked and a `git add -A` (likely while recovering)
  would sweep it into a commit.
- Branch-(b) remediation now says update BOTH the gate constant AND KNOWN_PHRASE,
  so a phrasing-drift fix lands in one pass instead of tripping the drift guard
  on the next run.
- Note next to BROWSER_EXTERNALIZED_BUILTIN_PHRASE that it must stay a single
  string literal (the script's drift guard anchors a regex on the assignment).

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

* build(web): round-8 review polish — safe recovery path + structural onLog order (#1769)

- saveBackupAndFail no longer claims a backup it may not have written: it tracks
  whether the write succeeded and only points the user at the .bak in that case
  (otherwise it says the probe is still injected and to restore from VCS). Uses
  mkdtempSync for a unique dir instead of a fixed tmp filename, so a
  pre-existing/foreign-owned /tmp file can't make the recovery message point at
  someone else's content.
- Guard the restore-verification readFileSync too, routing a failure through
  saveBackupAndFail so the whole restore path is uniformly safe (a raw throw
  there would skip the backup net).
- vite.config.ts: enforce: 'pre' on the gate plugin so it runs ahead of the
  normal-plugin group in the onLog chain — a plugin whose onLog returns false
  filters the log for later plugins, so trailing the array would let a future
  log-filtering plugin silently blind the gate. Makes "the gate sees every log"
  structural rather than array-position-dependent.

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

* build(web): round-9 review polish — prove the gate fired on the probe (#1769)

The success key confirmed the #1769 gate fired, but not that OUR probe tripped
it. If the repo already leaked a Node built-in, the gate would fire on that and
the script would report OK even if the probe was tree-shaken or entryPath went
stale — branch (c)'s failure silently inverted into a pass. Add an assertion
that PROBE_MODULE appears among the gate's embedded offender warnings. Unreachable
inside `npm run ci` (validate's build:web fails first on a pre-existing leak),
but this script is a documented standalone command — run exactly when debugging
a leak, the case where this mattered.

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

* build(web): round-10 review polish — roll back a partial probe write (#1769)

The probe writeFileSync uses the default 'w' flag (truncates at open), so a
mid-write ENOSPC/EIO could leave clients/web/src/main.tsx empty or half-written
— and its catch called fail() directly, the one path on this script that could
destroy the entry rather than merely dirty it (every other read/write is already
routed into the recovery net). Call restoreEntry() in that catch to roll the
partial write back (falling through to the .bak net if the restore itself can't
write), and correct the comment that claimed a write failure happens "before any
mutation". Also widen the enforce:'pre' comment to note it orders ahead of Vite's
core plugins too (harmless — the gate defines no resolve/load/transform).

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

* build(web): round-11 review polish — don't let the rollback swallow the write error (#1769)

Round 10's restoreEntry() in the probe-write catch swallowed the primary error:
restoreEntry() on its own write failure calls saveBackupAndFail → fail → exit,
so the "could not write the probe" diagnosis never ran when the rollback also
failed — which is exactly the read-only-checkout / ENOSPC case (both writes fail
at open). On a read-only checkout nothing was ever written, yet the developer
was told the restore failed and pointed at a /tmp backup of an untouched file.

Roll back inline instead: attempt the restore without exiting, track whether it
succeeded, then always report the PRIMARY write error, appending "restore from
version control" only when the rollback didn't take. Also pin the
"Build failed (#1769)" error prefix as contiguous in a single string fragment
(a comment symmetric with the phrase pin), since verify-build-gate.mjs
drift-guards on it as ERROR_PREFIX.

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

* build(web): round-12 review polish — don't let the pin comment defeat its own guard (#1769)

The round-11 pin comment quoted "Build failed (#1769)" verbatim, so the prefix
appeared twice in the gate module and verify-build-gate.mjs's whole-file
includes(ERROR_PREFIX) drift guard passed on the comment alone — rewording the
thrown error would have kept the guard green while a correctly-firing gate
reported "the build broke for another reason." Reworded the comment to not repeat
the full lead (matching how the phrase pin avoids repeating its phrase); the
literal now occurs exactly once, restoring the guard's meaning.

Also make the probe-write recovery message factual instead of inferred: read the
entry back and only warn "restore from version control" when it actually differs
from the original. On a read-only checkout both writes fail at open, so the file
is untouched and the old hedge needlessly implied damage.

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-25 19:45: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 build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777) 2026-07-25 19:45:11 -04:00
src build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777) 2026-07-25 19:45: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 chore: close quality-gate coverage gaps (#1689) (#1766) 2026-07-24 17:20:12 -04:00
package.json Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632) (#1735) 2026-07-22 11:42:54 -04:00
README.md build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777) 2026-07-25 19:45:11 -04:00
tsconfig.app.json Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725) 2026-07-19 22:36:21 -04: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 Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725) 2026-07-19 22:36:21 -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 build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777) 2026-07-25 19:45:11 -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.
  • browser-externalized-builtin-gate.ts — Vite-agnostic build-gate logic that fails vite build when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in vite.config.ts. It sits under server/ (rather than src/) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other vite-* config helpers here.

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.