inspector/clients/web/README.md
cliffhall 1dcb112987 docs: add the public-address vars to the environment-variable reference (#1862)
Document MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS in
docs/environment-variables.md, note that ALLOWED_ORIGINS is now also the
sandbox proxy's embedder allow-list, and list public-address.ts among the
web server files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-14 00:38:58 -04:00

443 lines
54 KiB
Markdown

# MCP Inspector Web Client
The browser incarnation of the Inspector: a **Vite + React + [Mantine](https://mantine.dev)** 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`/`local:gate` scripts, and publishing), see the [root README](../../README.md).
## 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, the default origin allow-list.
- **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list).
- **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)).
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`app-origin-controller.ts`** — the dedicated app-origin server for `_meta.ui.domain` (see [MCP App dedicated origins](#mcp-app-dedicated-origins-metauidomain)); **`public-address.ts`** — validates `MCP_SANDBOX_FULL_ADDRESS` / `MCP_APP_ORIGIN_FULL_ADDRESS`, the public addresses those two advertise behind a reverse proxy; **`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
```bash
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
```bash
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/` | ~48 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
| `groups/` | ~64 | 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](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
## Tool-schema portability (`#1005`)
The Tools tab flags tools whose advertised `inputSchema` / `outputSchema`
carries a construct that is legal JSON Schema and is refused or mishandled by
real MCP clients — the reported case being Go's `jsonschema` package emitting a
bare `true` for an `interface{}` field, which Claude Code rejects with an
opaque `"Invalid input"`.
Two surfaces, one verdict: the sidebar row (`ToolListItem`) carries a
hover-labelled icon — red when something is refused outright, yellow when it is
merely handled unevenly — and selecting the tool renders a **Schema
portability** section (`SchemaFindingsList`) above the argument form, one block
per finding with its path, the problem, and a concrete fix.
That section **opens collapsed**, behind a count badge reading
`N error(s), M warning(s)` (`#2205`). The findings address the *server author*
but render in the panel the *caller* fills in, so on a server whose schemas are
broadly unportable they used to put the same wall of text ahead of every tool's
first input —
`test-servers/configs/unportable-schemas-many-http.json` is 26 findings over
four tools, none of which a caller needs in order to fill the form. Expanding it
is one click, and the choice is **global rather than per tool**
(`useSchemaFindingsExpanded`, stored as `inspector.schemaFindings.expanded`):
this panel is reused across selections, so a per-tool disclosure would
re-collapse on every click and reproduce the same scrolling. The badge stays
visible either way, so nothing about a tool's standing is hidden by the closed
state.
Both read [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), which is
also what backs the TUI's detail pane and the CLI's `--strict` report — so the
three clients cannot disagree about whether a schema is portable. That module's
header explains why it is a portability lint rather than a JSON Schema
validator. `test-servers/configs/unportable-schemas-http.json` is a server that
exercises every rule, and `unportable-schemas-many-http.json` the same rules at
volume.
## Non-component code: `src/lib` vs `src/utils`
Two grab-bag directories, split by one rule: **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.
- **`src/utils/`** — pure, side-effect-free functions (no DOM/`window`/`sessionStorage` I/O, no subsystem ownership), trivially unit-testable with no mocks. Examples: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`, `errorFormat`, `stepUp`, and the toast ids/formatters under `utils/toasts/`. Carve-outs that stay `utils`:
- _Diagnostic logging_ (`console.warn`/`console.error`) doesn't count as a side effect.
- _Importing from `@inspector/core`_ — neither a type-only import nor re-exporting core's pure functions/constants is a subsystem dependency (what makes a module `lib` is wrapping core's stateful runtime).
- _Pure domain types + their constructors_ (`customHeaders`) — there is no `types/` sub-bucket inside `lib`/`utils`.
- **`src/lib/`** — infrastructure / stateful adapters: modules that compose subsystems, wrap the `@inspector/core` **runtime**, or produce side effects. Examples: `environmentFactory`, `remoteOAuthStorage`, `oauthResume` (sessionStorage), `browserTabVisibility` (DOM listeners), `clearServerOAuthState`, `downloadFile`, `authToken` (`window.location` + `sessionStorage`), `protocolReplay` (re-issues through the live client).
The top-level `src/types/` is a separate sibling — ambient `.d.ts` module stubs, not the place for new domain types (the one that lingers there, dead `navigation.ts`, is tracked for removal in [#1785](https://github.com/modelcontextprotocol/inspector/issues/1785)).
Nothing _enforces_ the boundary — no path alias keys off it, and the coverage `include` in `vite.config.ts` lists both directories, so a move between them is coverage-neutral. It's a human-legible import-time signal. See [`AGENTS.md`](../../AGENTS.md) for the full rule (including the whitelist caveat — a module placed outside `components`/`lib`/`utils`/`server` falls out of the ≥90 gate).
## Core tab automation contract
The Tools, Resources, Prompts and Skills screens each expose a `data-testid` plus a
small set of `data-*` attributes, so a headless driver can `waitForSelector` on
a deterministic signal rather than on visible copy. `scripts/smoke-web-tabs.mjs`
drives the first three of those four against `test-servers/configs/web-tabs-http.json`
([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)); Skills
publishes the same contract but is not smoked yet ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)).
Treat them as a public contract, for the same reason as the Apps ones below:
| Attribute | Where | Meaning |
| ------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="tools-screen"` | Tools screen root | The element carrying the two attributes below. |
| `data-tool-count` | on `tools-screen` | How many tools the list holds. `0` is a *populated screen with an empty list* — distinct from the screen being absent, which is what a driver waits out. |
| `data-call-status` | on `tools-screen` | `idle` → `pending` → `ok` / `error` for the current `tools/call`. Always a value; an absent call state reads `idle`, never empty. |
| `data-testid="structured-output"` | result panel | The `structuredContent` section of a tool result ([#1908](https://github.com/modelcontextprotocol/inspector/issues/1908)). Absent when a result carries none. |
| `data-testid="resources-screen"` | Resources root | The element carrying the three attributes below. |
| `data-resource-count` | on `resources-screen` | Entries from `resources/list`. |
| `data-template-count` | on `resources-screen` | Entries from `resources/templates/list` — a **separate** call that can fail on its own, so it is reported separately. |
| `data-read-status` | on `resources-screen` | `idle` → `pending` → `ok` / `error` for the current `resources/read`. |
| `data-testid="resource-preview"` | preview panel | The read resource's **rendered** contents. Assert this *and* `data-read-status`: the status flips as soon as the RPC resolves, so it alone stays true with the panel removed. |
| `data-testid="prompts-screen"` | Prompts root | The element carrying the two attributes below. |
| `data-prompt-count` | on `prompts-screen`| Entries from `prompts/list`. |
| `data-get-status` | on `prompts-screen`| `idle` → `pending` → `ok` / `error` for the current `prompts/get`. |
| `data-testid="prompt-messages"` | messages panel | The fetched prompt's **rendered** messages — the `prompts/get` counterpart of `resource-preview`, and asserted alongside `data-get-status` for the same reason. |
| `data-testid="skills-screen"` | Skills root | The element carrying the two attributes below ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234)). Not driven by `smoke-web-tabs.mjs` yet — the attributes exist so it can be, and so a rename fails in the screen's unit test rather than later. |
| `data-skill-count` | on `skills-screen` | Entries accumulated from `skills/list`. |
| `data-skill-page-count` | on `skills-screen` | Pages the last `skills/list` walk took — a **separate** fact from the count, and the one that shows pagination actually happened. |
| `data-testid="skill-manifest"` | detail pane | The selected skill's resource manifest. Absent for a `resources: "dynamic"` skill, which has no manifest to render. |
Why attributes rather than text: a smoke that waited on a label fails the next
time the label is reworded, which is noise rather than signal — and it fails as
an opaque timeout, because there is nothing to compare against. The screen tests
pin each attribute name for the same reason: a rename should fail loudly in a
unit test, not silently in a five-minute gate.
## 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). |
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
| Attribute | Where | Meaning |
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
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.
## Deep-link auto-connect
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](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
## MCP App dedicated origins (`_meta.ui.domain`)
By default an MCP App is rendered by handing its HTML to the sandbox proxy as
`srcdoc`, inside an iframe sandboxed **without** `allow-same-origin`. That is
the isolation model from #1565 and it stays the default — but it gives the app
document an *opaque* origin, so every request it makes carries `Origin: null`.
An app whose backend allowlists origins (CORS, an OAuth callback, an API-key
allowlist) can't work that way, which is what the spec's `_meta.ui.domain`
exists to solve: a server uses it to ask its host for a stable, dedicated
origin.
**The Inspector's host-specific contract.** The spec makes `domain`'s format
host-dependent ("servers MUST consult host-specific documentation"), and the
Inspector owns no domain infrastructure — it cannot serve
`my-app.example.com`. So it treats the field as a **request, not an address**:
- Any **non-empty** `domain` string opts the resource in. The value itself is
not parsed, matched, or reserved — declare whatever your production host
expects.
- The Inspector answers with a real HTTP origin on loopback:
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6278`** — in the same
`627x` family as the web port `6274` and the sandbox port `6275`, so the
three forward together. It is **not** `6276`: that is the fixed loopback
OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and
which therefore cannot move. `6277` is skipped as v1's retired proxy port.
- The app document is served from there under an unguessable path, its
per-app CSP delivered as a real response **header** (stronger than the
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
only the two origins in its real ancestor chain — the sandbox proxy that
frames it, **and** the Inspector page that frames the proxy. Both are
required, not belt-and-braces: `frame-ancestors` is checked against every
ancestor, so omitting the Inspector's own origin blocks the frame outright
(see `appDocumentEmbedders`).
- The inner iframe is granted `allow-same-origin` on this path **only** —
that is what makes the origin real rather than opaque. It is not a
weakening of #1565: the listener is on its own port, so the app is
cross-origin to both the sandbox proxy and the Inspector, and same-origin
policy blocks the reach either way. The proxy refuses the grant outright if
the URL's origin equals its own, and the grant is never reachable from the
server-supplied `sandbox` string, which is still stripped.
**It is one shared origin, not one per app.** Every domain-declaring app is
served from the same port, keyed by path. That delivers the property the field
is *for* — a real, allowlistable origin — without minting a port or a DNS name
per app, at the cost of not being a per-app isolation boundary: two such apps
share `localStorage`, `sessionStorage`, and cookies for that origin.
**And the separate port does not isolate cookies from the rest of loopback.**
A distinct port makes a distinct *origin*, so the origin-scoped surfaces —
DOM/scripting access, `localStorage`, `sessionStorage`, IndexedDB — are isolated
from the sandbox proxy and from the Inspector page. **Cookies are not
origin-scoped**: they are keyed by host and path, ignoring port. An app served
here therefore shares the `127.0.0.1` cookie jar with the Inspector on `6274`
and with anything else on loopback — it can read any non-`HttpOnly` cookie set
for that host, and set `Path=/` cookies those services will receive.
This is not a hole in the Inspector's own auth: the API token travels in the
`x-mcp-remote-auth` header and lives in a `window` global and `sessionStorage`,
neither of which another origin can read. Closing the cookie gap properly needs
a distinct *host*, which this listener cannot mint (the Inspector owns no DNS,
and a second loopback address is not portable) — so it is stated rather than
papered over. Don't run the Inspector beside a loopback service whose session
cookie you would not hand to an app under test.
**Every failure falls back rather than blanking the app.** No app-origin
listener, a port that never bound, an older backend with no
`POST /api/app-document` route, a network error — each renders the app the
default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`.
Losing the real origin degrades what the app can reach; losing the app itself
would be worse.
**One failure is outside that guarantee, deliberately: a published document the
browser cannot reach.** Every case above is one the *host* observes — publishing
returned nothing, so it falls back before choosing a render path. If publishing
succeeds and the browser then cannot load the URL (the port is not forwarded, or
a collision moved the listener to a dynamic port that is not), the frame stays
blank instead. There is no honest signal to fall back on: the navigation is
cross-origin, so `onerror` never fires for an HTTP error, `onload` fires for the
error page too, and nothing about the document is readable. The alternatives are
a timeout — which races an app that is merely slow, and "recovers" it by
re-running it at an opaque origin, executing its side effects twice — or a
reachability probe on every render, which proves the port answers rather than
that this fetch will. So the cause is removed instead of guessed at: every
documented remote workflow forwards the port (see below, the Docker section of
the root README, and the SSH recipe in `docs/mcp-app-review.md`).
**Forward `6278` too** if you need this off loopback (a container, a tunnel).
As with the sandbox port, a taken port falls back to an OS-assigned one with a
loud warning — the app still renders, but the origin an app's backend was told
to allowlist is then wrong, which is what the warning tells you.
## 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.
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
## JSON editing and display (`JsonEditor`)
Every surface in this client where JSON is **typed**, plus `ContentViewer`'s read-only JSON branch, renders one element: **`elements/JsonEditor`**, an **Ace** editor (`react-ace` + `ace-builds`, declared in this client because they render React). Ace brings code folding, line numbers, brace auto-closing, and per-line error annotation from its JSON worker — the last three are why hand-writing a nested payload in a bare textarea was the actual pain ([#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)).
`JsonEditor` is deliberately **text in, text out**: it never parses. The editing contracts above it disagree about what an unparseable draft means, and neither can be expressed by a component that decides for them:
| Consumer | While the draft is invalid |
| --- | --- |
| `elements/JsonObjectInput` (Server Settings → Request Metadata) | Parent is **not** told; the last valid object stands. There is no Save button to gate — `onChange` writes straight through — so emitting `{}` would discard configured metadata on a stray keystroke ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). |
| `SchemaJsonField` (the object/array/union fallback in `groups/SchemaForm`) | Parent is told `undefined`, **and** invalidity is reported up through `onValidityChange` so Execute / Open App / Submit are disabled ([#2020](https://github.com/modelcontextprotocol/inspector/issues/2020)). |
| `SchemaForm`'s **Edit as JSON** switch | Same as above, for the whole arguments object — the v1 escape hatch, restored. Seeded from what the form holds, so a root-union switch's pruning ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)) is not undone by a round trip. |
| `groups/EditReplayModal` (Protocol → Edit and replay) | Send is disabled. Unlike the metadata editor this modal *has* a commit gesture to gate. |
| `groups/ImportServerJsonPanel`, `groups/ExperimentalFeaturesPanel` | The panel owns the text and validates it itself. |
| `elements/ContentViewer`'s JSON branch | Read-only — see below. |
**A raw-JSON draft is refused when the client would retype it.** `callTool`
converts every *string-valued* argument to the type the tool's `inputSchema`
declares, because the widget form hands everything over as text — so `"2"`
against a numeric field has to become `2`. A JSON draft already carries its own
types, so a value that conversion would touch is one whose visible text is not
what the wire would carry, and showing one payload while sending another is the
one thing an inspector must not do. Both JSON-authoring surfaces therefore
refuse such a draft and name the value to rewrite
([#2171](https://github.com/modelcontextprotocol/inspector/issues/2171)):
**Edit as JSON** in the Tools and Apps tabs (which had been sending it, coerced
and silently), and **Edit and replay**, which already did.
One helper decides it for both — `coercedArgumentNames` in
[`core/json/jsonUtils.ts`](../../core/json/jsonUtils.ts), which runs the real
conversion and compares rather than restating its rules, so a surface cannot
drift from what would actually be sent. `coercedArgumentsError` is the sentence
they share, so the same refusal cannot be worded two ways.
The check is **opt-in** (`SchemaFormProps.enforceToolArgumentTypes`), not
inferred from the schema: an elicitation renders through the same `SchemaForm`
and its values are never converted, so enforcing there would refuse a draft for
a reason that is not true of it. Only the Tools and Apps panels pass it.
**Read-only mode is what `ContentViewer` renders JSON as**, and through it every payload that reaches the app that way: Protocol and Network entries, tool results, structured output, resource previews, server cards. Highlighting is *not* what that buys — JSON already highlighted, via the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is **folding**, line numbers and a gutter on a large payload. The Prism `json` grammar was dropped in the same change rather than kept beside it: two highlighters for one language drift, and nothing else asks for `json`. Two cases stay on the plain renderer — a `wrap={false}` caller (the server card's fixed-height, single-line box) and untyped text that only *looks* like JSON but does not parse, which in an editor would frame a server's prose as a malformed document.
Two read-only JSON displays deliberately do **not** go through `ContentViewer`, and so are not on this editor — worth knowing before assuming it is the route for all of them. `ExperimentalFeaturesPanel` renders its JSON-RPC **response** in a read-only `Textarea` (its *request* box is on the editor), and `ConnectionInfoContent/OAuthTokenField` renders a decoded JWT in a `Code` block beside the raw token, where the decoded/raw toggle and the copy affordance belong to the field rather than to a viewer. Both are candidates for the same treatment; neither is in [#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)'s scope.
Five integration details are load-bearing:
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations. It is registered at **module scope** — the registration is global to Ace and idempotent, so it must not move into a per-mount effect.
- **Ace fires two change events for a replace** (a remove, then an insert), so a select-all-and-retype passes through a momentarily *empty* document. `JsonEditor` coalesces the pair in a microtask and reports only the settled text; acting on the first event reports the empty document as the user's answer.
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden. The read-only caret is hidden the same way (`.json-editor-readonly`), since `readOnly` has no option that removes it.
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
- **`ariaLabel` is required.** Ace names its hidden textarea "Cursor at row N", which is a position readout rather than a name — and it only recomputes that label when the cursor moves, so the option has to be applied *and* recomputed in `onLoad` to reach the DOM before the user clicks in.
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts` (`setAceText` / `getAceText`, plus the `*ByLabel` variants for a screen holding more than one editor); real keyboard behaviour lives in the Storybook play functions, which run in Chromium. The same split applies to *reading* a payload back: Ace virtualizes its lines, so a read-only editor's text is not fully in the DOM and assertions go through the editor rather than `getByText`.
## 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
```bash
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). GitHub CI runs them directly (`npm run test:storybook`); the local pre-push gate runs them through `npm run local:storybook`, which installs the Chromium binary first. Either way they are 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](../../AGENTS.md) for the full rationale, and [Environment variables](../../docs/environment-variables.md) for every variable the backend reads.
## Host binding & the origin allow-list
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice.
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
**Running the Inspector behind a `*.localhost` proxy.** A common local-dev shape gives each service a friendly name — `my-api.localhost`, `my-app.localhost`, `inspector.localhost` — instead of a set of ports. The Inspector works there, but the origin allow-list does not include those names by default, so set it explicitly:
```sh
ALLOWED_ORIGINS=http://inspector.localhost,http://localhost:6274,http://127.0.0.1:6274,http://[::1]:6274 \
mcp-inspector --web
```
⚠️ **List the loopback forms too, not just your proxy origin.** `ALLOWED_ORIGINS` **replaces** the default list rather than merging with it, so an entry of only `http://inspector.localhost` silently breaks browsing at `http://localhost:6274`. Add the port to the proxy origin if it is not on `:80`. The MCP Apps sandbox `frame-ancestors` is derived from the same list, so one entry covers the Apps tab as well.
Without this you get a confusing failure rather than an obvious one: the page loads, because a same-origin `GET` carries no `Origin` header and so never reaches the guard, while **every state-changing request is rejected with a 403** — browsers attach `Origin` to anything that is not a `GET`/`HEAD`. Adding and connecting a server (`POST`) are the most visible, but saving settings, reordering and deleting a server (`PUT`/`DELETE`) fail the same way. The result reads as a connection problem rather than a configuration one.
⚠️ **Only the browser resolves `*.localhost` for free.** Chrome and Firefox map those names to loopback internally per [RFC 6761 §6.3](https://www.rfc-editor.org/info/rfc6761/); the OS resolver on macOS does **not**, and Safari does not resolve them at all. That is fine for reaching the Inspector, but an **MCP server** URL on such a host is dialled by the Inspector's Node backend, so it still needs an `/etc/hosts` entry or dnsmasq. Note also that an MCP server on a `*.localhost` host **using OAuth** is currently refused by the SDK, which exempts only `localhost`, `127.0.0.1` and `::1` from its TLS requirement — see [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591).
### Hosting on a network
The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet:
- **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://<that-host>:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.)
- **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`.
- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 127.0.0.1:6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`.
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is built from the bind, so it is always `http://<bind host>:<port>/sandbox` — which is wrong **behind a reverse proxy or TLS**, where the browser reaches the listener at a public hostname (and an `https://` app page blocks an `http://` iframe as mixed content).
**Behind a reverse proxy: `MCP_SANDBOX_FULL_ADDRESS` and `MCP_APP_ORIGIN_FULL_ADDRESS`** (#1862). These set the address the browser is _told_ to use; the process still listens on `MCP_SANDBOX_PORT` / `MCP_APP_ORIGIN_PORT`, and routing the public address to that listener is the proxy's job.
```sh
ALLOWED_ORIGINS=https://inspector.example.com \
MCP_SANDBOX_FULL_ADDRESS=https://inspector-sandbox.example.com/sandbox \
MCP_APP_ORIGIN_FULL_ADDRESS=https://inspector-apps.example.com \
mcp-inspector --web
```
- **Each needs its own origin** — its own hostname, or its own port. The MCP Apps spec requires the sandbox origin to differ from the host's, so the natural single-hostname layout (`https://inspector.example.com/sandbox` routed to `6275`) is **refused** at boot with a warning naming the collision, as is an app origin equal to the Inspector's or the sandbox's. A refused or malformed value (no `http(s)` scheme, credentials, a query, a wildcard, a bracketed IPv6 literal, or a path on the app origin) is warned and ignored, and the bind-derived address is advertised instead.
- `MCP_SANDBOX_FULL_ADDRESS` may be a bare origin; `/sandbox` is appended. `MCP_APP_ORIGIN_FULL_ADDRESS` must be an origin only — documents are served at `<origin>/app-document/<id>`.
- **`ALLOWED_ORIGINS` must name the public Inspector origin.** It already had to for connects; it is now also the sandbox proxy's embedder allow-list — the proxy checks the embedding page's origin against exactly the list its `frame-ancestors` header admits, rather than a hardcoded `http://localhost` pattern that no `https://` page could match. It **replaces** the defaults, so add the loopback origins too if you still browse locally.
- Set **both** if a server you inspect declares `_meta.ui.domain`: with only the sandbox overridden, that app is still served from `http://<bind host>:6278`, which an `https` page blocks and a remote browser cannot reach — and that frame stays blank with no fallback. A plain-`http` address under an `https` `ALLOWED_ORIGINS` entry is advertised but warned about for the same reason.
- The web client itself still needs a secure context off loopback (`crypto.randomUUID` is unavailable over plain HTTP on a non-`localhost` host), so serve the Inspector over HTTPS when it is not on `localhost`.
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.
## HTTP proxy support
The web backend connects to remote MCP servers through the shared Node fetch (`core/mcp/node/proxyFetch.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 uses [`undici`](https://www.npmjs.com/package/undici)'s own `fetch` bound to its `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
The proxy sits at the **bottom** of the fetch stack rather than wrapping the transport's fetch, and both undici halves must come from the same copy — see [HTTP proxy support](../cli/README.md#http-proxy-support) in the CLI README for why ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).