# 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`/`ci` 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)); **`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/` | ~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](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors. ## 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`. 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`. 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). ## 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=&transport=http|sse&autoConnect= ``` | 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=&appArgs=&autoOpen= ``` | 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://:`, 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 `` 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.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme` 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 `