* test(web): settle real-timer Transitions so the coverage gate can't leak 'window is not defined' (#1786) The web coverage gate was intermittently failing with an unhandled `ReferenceError: window is not defined` thrown from a Mantine `Transition` timer that fired after happy-dom tore down `window` (the #1760 class). The three `ViewHeader.test.tsx` tests that use `renderWithMantineTransitions` (real, env="default" timers) each drove their *primary* transition to completion, but the concurrent *enter* on a sibling cell leaves no unmount to `waitFor`, so its rAF→setTimeout chain could still be pending at teardown and fire post-teardown, failing an unrelated later file's run. Fix: after each such test, flush pending transition timers against the still-mounted tree via `settleTransitions()` (a wait longer than the 300ms header duration + rAF slack), so the settling setState targets a live tree and nothing is left to fire after cleanup unmounts. Also close the related nit: `renderWithMantine`/`renderWithMantineTransitions` now accept a `colorScheme` option, so `ViewHeader.test.tsx` no longer hand-rolls a bare `defaultColorScheme="dark"` MantineProvider (the #1760 anti-pattern). AGENTS.md documents both the option and the settle requirement. Closes #1786 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): address review — export settleTransitions, derive settle from HEADER_ANIM_MS, guard fake timers, correct comments Review feedback on PR #1793: 1. Derive the settle window from the real animation duration: export `HEADER_ANIM_MS` from ViewHeader.tsx and compute `TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200` in the test, so bumping the header duration can't silently make the settle insufficient (the invariant is now structural, not a comment). 2. Move `settleTransitions` into the shared `renderWithMantine.tsx` helper (exported, optional `ms` arg) next to `renderWithMantineTransitions`, since AGENTS.md points every future caller at it — shared test scaffolding belongs in src/test/, not a single test file. AGENTS.md now references the helper. 3. Guard `settleTransitions` against `vi.useFakeTimers()`: it awaits a real setTimeout, so under fake timers it would hang to the test timeout. Throw a clear message instead. 4. Correct the diagnosis comments: the hazard isn't a Mantine timer surviving unmount (its effect cleanup cancels timers) — it's the React state update Mantine's inner rAF schedules outside `act`, resolving post-teardown. Also fix the stale setup.ts matchMedia comment: the reduce-motion stub does not disable transitions (Mantine defaults respectReducedMotion=false); env="test" is the actual #1760 protection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): tighten the review-trail comments (round 2) Comment-accuracy follow-ups from PR #1793 round 2 (test infra only, no behavior change): - DEFAULT_SETTLE_MS: drop the false "Mantine's default header transitions run ~300ms" attribution (300ms is ViewHeader's HEADER_ANIM_MS; Mantine's Transition default is 250ms). Justify 500ms generically — a typical few-hundred-ms transition plus rAF slack. - settleTransitions doc: stop asserting a single escape mechanism as fact. State what's observed (a post-teardown dispatchSetState throwing from resolveUpdatePriority, which runs synchronously inside dispatchSetState), what's known (useTransition has an unmount cleanup), and that the exact escape route isn't pinned down — the act-wrapped drain fixes it regardless. - setup.ts: happy-dom v20 does implement matchMedia, so reframe the block as a deliberate override (pinning prefers-reduced-motion: reduce) rather than a polyfill for a missing API. - Export MantineRenderOptions so callers can name the options type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): resolve the last comment-contradiction + tighten the matchMedia override (round 3) Round-3 review of PR #1793 (test infra only): - setup.ts finding 1: the inner `value:` comment still said "happy-dom doesn't implement it", contradicting the block comment two lines up (which now correctly states happy-dom v20 DOES implement matchMedia and this is an override). Dropped the false clause; kept the part that actually justifies the `as unknown as MediaQueryList` double cast (the stub omits most of the interface). - setup.ts finding 5 (folded in rather than deferred): anchored the `matches` regex to `/prefers-reduced-motion:\s*reduce/` so the override no longer also answers `true` to `(prefers-reduced-motion: no-preference)`. - renderWithMantine.tsx nit 4: the `env="test"` rationale sat on `makeWrapper`, which also builds the `env="default"` wrappers (opposite purpose). Moved it onto `renderWithMantine`, mirroring the `env="default"` rationale on `renderWithMantineTransitions`; `makeWrapper` keeps a one-liner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): make renderWithMantineTransitions own the settle (round 4) Round-4 review of PR #1793 — implements the note-2 hardening in-PR rather than deferring it, plus note 1 and the nit: - Note 2 (settle was a convention AGENTS.md had to police): the real-transitions helper now OWNS the settle. `renderWithMantineTransitions` arms an automatic post-test drain via a helper-registered `afterEach`, so a real-transitions test can no longer leak the #1760 class by forgetting to settle a concurrent enter. Verified empirically that the helper's `afterEach` runs BEFORE the global cleanup() in setup.ts (afterEach is LIFO; setup.ts's cleanup is a setupFile registered first) — the ordering the drain depends on. The three explicit `settleTransitions()` calls in ViewHeader.test.tsx are gone; each render passes `settleMs: HEADER_ANIM_MS + 200` instead, keeping the window duration-derived. The auto-settle skips under fake timers (and no transitions test uses them). `settleTransitions` stays exported for the rare manual mid-body case. - Note 1 (settle formula was duration-only): the helper doc + the new `settleMs` option doc now say the window must exceed the longest JS chain — duration PLUS any enterDelay/exitDelay — plus rAF slack. - Nit: ViewHeader.test.tsx now states why HEADER_ANIM_MS is exported (the settle window must track the animation it settles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): pin + self-check the auto-settle hook ordering; fix fake-timer doc; keep longest settle (round 5) Round-5 review of PR #1793: - Note 2 (load-bearing hook ordering was unpinned/undefended): the auto-settle's afterEach must run before setup.ts's cleanup(). Added BOTH guards the review suggested. (1) Runtime self-check: renderWithMantineTransitions captures the render container; the afterEach throws a clear error if it's already detached (cleanup ran first). (2) Pinned `sequence.hooks: "stack"` on the unit project. While verifying, found the ordering actually holds in EVERY sequence.hooks mode (stack/list/parallel) because setupFile hooks are outer relative to the helper's import-registered hook — so the comments now state that true mechanism (the pin is defense-in-depth; the container.isConnected check is the real enforcement), rather than claiming it "depends on stack". Full 3533-test unit suite passes under "stack". - Note 1 (fake-timer claim was backwards): AGENTS.md no longer says the leak "would return" under fake timers (fake clocks park Mantine's setTimeout/rAF and are discarded, not drained). The real hazard — the auto-settle silently no-ops, so real-clock work is unprotected — is now stated. The afterEach also console.warns on the fake-timer skip (was silent; the manual settleTransitions throws — this closes that asymmetry). - Note 3 (arming was last-write-wins): armedSettleMs now keeps the longest window via Math.max, so a later short-animation render can't under-settle an earlier long one. - Nit: documented `settleMs: 0` as a working opt-out for a test that drove every transition to completion itself (act flush, no wait). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): post-await self-check + correct the hook-ordering mechanism (round 6) Round-6 review of PR #1793: - Finding 1 (self-check blind in parallel): added the post-await container.isConnected re-check the review suggested (factored both checks into assertConnectedForSettle(container, "before"|"after")). While verifying it, I established the actual mechanism, which differs from what both prior comments and the review assumed: setup.ts's cleanup() is a *setupFile (outer)* hook, and Vitest runs inner afterEach hooks to completion before outer ones in EVERY sequence.hooks mode — so cleanup never races this settle. Verified: ViewHeader's real-transitions tests pass under stack/list/parallel with neither check tripping, and a same-suite `afterEach(cleanup)` probe (where the ordering CAN break) does trip them (pre-check). So sequence.hooks isn't load-bearing here; the comments in renderWithMantine.tsx, vite.config.ts, and AGENTS.md now say that. The "stack" pin stays as documentation of intent / defense-in-depth, and the two checks are the real guard against a future same-level-cleanup regression. - Nit: documented why last-write-wins on armedContainer is fine (cleanup detaches every container at once, so the last is a faithful "cleanup ran" proxy). - Nit: the fake-timer skip warning now interpolates the current test name so it's actionable at teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): disarm the auto-settle on self-unmount; concurrency + message nits (round 7) Round-7 review of PR #1793: - Finding 1 (guard misdiagnoses a self-unmount): the detached-tree check fired on ANY detachment and blamed a broken cleanup ordering, but a test calling the unmount() returned by renderWithMantineTransitions also detaches the container (a common pattern here). Fixed at the source: the helper now wraps unmount() to disarm the auto-settle (clear armedSettleMs/armedContainer) — once the test has unmounted there's nothing to settle against a live tree. The guard message also now names the self-unmount cause. Verified: a probe that unmounts a real-transitions render no longer trips the guard; ViewHeader unaffected. - Nit: documented that the module-level arming assumes sequential tests (not safe under test.concurrent/describe.concurrent). - Nit: the fake-timer skip warning falls back to "(unknown test)" when currentTestName is undefined. - Finding 3 (doc weight): trimmed the AGENTS.md bullet to the *rule* (use the transitions variant only for mid-flight assertions; pass a duration-derived settleMs; no fake timers; use the returned unmount()) and pointed at the helper's own comments for the full mechanism, which now carries it at more depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): downgrade (not disarm) the auto-settle on self-unmount; per-container liveness (round 8) Round-8 review of PR #1793: - Finding 1 (round-7 disarm was too blunt + self-contradictory): disarming on self-unmount removed the drain entirely for the mid-flight-unmount test that most needs #1760 protection, and its "useTransition cleanup applies" rationale contradicted this file's own settleTransitions doc (which says that cleanup can't be relied on to stop the rAF). Now the wrapped unmount() DOWNGRADES: the drain still runs (mechanism-independent — flushes queued rAF/setTimeout while window is alive; a setState on the unmounted tree is a React no-op), only the liveness assertion for that container is dropped. - Finding 2 (disarm undid round-5 max-wins arming): arming is now per-container (a Set of live containers) instead of all-or-nothing. Unmounting one tree drops only its own container; a second still-mounted tree keeps its longer window and its liveness check. The isConnected assertions run over the still-live set. Verified with probes: returned unmount() drains without tripping the guard; a second still-mounted tree is unaffected when the first self-unmounts; a bare mid-body cleanup() STILL trips the guard (it's live, not decoration); ViewHeader unchanged. Comments/AGENTS.md updated so the self-unmount path and the settleTransitions doc no longer contradict each other. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): make the auto-settle drain best-effort so a guard trip doesn't also leak (round 9) Round-9 review of PR #1793 (merge-verdict; optional robustness note): - Finding 1 (pre-settle throw skipped the drain): on the ordering regression the guard exists to catch, throwing before the drain meant the maintainer got BOTH the explanatory error AND an uncaught post-teardown `window is not defined` in an unrelated file. The afterEach now captures a liveness failure, always runs the act-wrapped drain (flushing queued rAF/setTimeout while window is alive), and throws afterward — so the explanatory error stands alone. Verified: a bare mid-body cleanup() still throws the guard error (now after draining); ViewHeader unchanged. - Nits: AGENTS.md now names the reachable bare-detach path (a mid-body cleanup() on a still-armed tree) rather than "a bare RTL unmount"; the settleMs doc notes arming is per-test, so in a multi-render test the longest window wins (a `0` is superseded by a longer sibling render). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): fix the regressed fake-timer doc + guarantee the liveness error survives a drain throw (round 10) Round-10 review of PR #1793 (merge-verdict): - Finding 1 (AGENTS.md fake-timer clause regressed in the round-7 doc trim): it had reverted to "silently dropping the protection", which round 5 had corrected — "silently" is now false (the auto-settle console.warns on the fake-timer skip) and "dropping the protection" reinstates a walked-back framing (fake clocks park Mantine's timers on a discarded clock, so the no-op doesn't obviously reopen #1760). Reworded to match the helper: it warns, and the hazard is that real-clock work is then unprotected, so the test depends on which clock was installed at teardown. - Finding 3 (nit): matched the console.warn string to that same corrected framing, so AGENTS.md and the warning agree. - Finding 2 (nit): a drain-time throw could discard the captured liveness error (the substitution round 9 set out to prevent, one step further). Now the drain error is captured too and the liveness error is preferred, so the explanatory error always wins. Verified a bare mid-body cleanup() still throws the liveness error after the best-effort drain; lint clean (no throw-in-finally); ViewHeader unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): preserve the drain error as cause + route the guard message to the reachable cause (round 11) Round-11 review of PR #1793 (merge-verdict; two optional polish items): - Finding 1: when both a liveness error and a drain-time throw occur, the drain error was dropped entirely. It's now attached as the liveness error's `cause` (when the liveness error is an Error), so an act-surfaced component error from the settling render is preserved without changing which error fails the test. - Finding 2: the guard's error message pointed the reader first at the `sequence.hooks: "stack"` pin, which the file itself documents as defense-in-depth and NOT the ordering guarantee. Reworded to name the actually- reachable cause first — cleanup moved to a same-level afterEach instead of the setup.ts setupFile (outer) hook — and demote the pin to a parenthetical, so a maintainer who trips the check isn't sent to investigate a non-causal setting. Verified: a bare mid-body cleanup() throws the reworded message; eslint clean; ViewHeader unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): guard message states observation before candidate causes; log the preserved drain error (round 12) Round-12 review of PR #1793 (merge-verdict): - Finding 1: the round-11 rewording had dropped the "Expected cause:" hedge and asserted setup.ts's cleanup() as the fact, but the check only observes that a still-live armed tree was detached — it can't identify what detached it. Reworded to state that observation first, then list candidate causes: (a) a bare mid-body cleanup() in the test (the reachable case today), or (b) cleanup moved off the setup.ts setupFile hook to a same-level afterEach. No single cause asserted as fact. - Nits 2a/2b: when both a liveness error and a drain error fire, the drain error is now also console.error'd (in addition to being attached as `cause`), so it's readable regardless of whether the reporter renders `error.cause` and isn't silently dropped even if the liveness error somehow isn't an Error. Verified: a bare mid-body cleanup() throws the reworded message; eslint clean; ViewHeader unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): final comment/string polish on the auto-settle guard (round 13) Round-13 review of PR #1793 (merge-verdict; string/comment polish only): - Finding 1: the console.error text claimed the drain error was "reported as the liveness error's cause" unconditionally, but the cause is only attached when the liveness error is an Error — misleading on the very non-Error path the hoisted log exists to serve. Dropped that parenthetical so the log is true on both paths. - Nit: trimmed the now-duplicated outer comment to just the priority rationale (the inner comment, which mentions the log + instanceof conditional, is the accurate one). - Nit: guard message says "drained against a dead tree" (exact for both the before- and during-settle detach) instead of "drained nothing". - Nit: the message now names renderWithMantine.tsx rather than "the ordering note here", so it's self-contained out of context. eslint clean; ViewHeader unchanged; npm run ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): correct the sequence.hooks default claim (it's "stack" in vitest 4, not "parallel") (round 14) Round-14 review of PR #1793 (merge-verdict): - Finding 1: verified against installed vitest 4.1.0 — `resolved.sequence.hooks ??= "stack"` (coverage chunk), so the runtime default is "stack"; the CLI help-text's "parallel" is stale in vitest itself. The vite.config.ts comment claimed the pin overrides a "parallel" default; corrected to say the pin is Vitest 4's own default made explicit (documents intent + guards a future default change), which is a truer reason to keep it than an override. - Nit: tied the two additions together — the post-settle check's same-level race can only arise if someone changes sequence.hooks to "parallel" (same-level hooks run sequentially under "stack"/"list"), so the pin and the post-check now reference each other rather than sitting in opposed positions. ViewHeader unchanged; npm run ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test(web): document the isolate:true dependency (empirically confirmed) + fix pre-check-blindness wording (round 15) Round-15 review of PR #1793 (merge-verdict; comment-only): - Finding 1: the auto-settle's module-level afterEach depends on isolate:true (the default) to re-register per test file; under isolate:false the module evaluates once per worker and the hook binds to only the first importing file, so other files arm with no consuming hook — the drain silently never runs and the state leaks across files. Reproduced it (vitest --no-isolate --pool=threads --no-file-parallelism over two real-transitions files leaves one file's arming unconsumed) and documented the assumption + the onTestFinished escape hatch on the arming-state comment. - Nit: the "sequence.hooks is why the pre-check can't see the concurrent detach" clause was wrong — the pre-check is blind because it runs synchronously before the await (a property of where it sits, not the hook mode). Reworded to state that, and that the concurrent case *additionally* needs sequence.hooks: "parallel". ViewHeader unchanged; npm run ci green. 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>
53 KiB
Inspector V2
This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI.
Project Structure
inspector/
├── clients/
│ ├── web/ # Web client (Vite + React + Mantine)
│ │ ├── src/ # Browser source (React app, hooks, components)
│ │ ├── server/ # Node-only dev/prod backend wiring:
│ │ │ # vite-hono-plugin.ts (Hono middleware on the Vite dev server),
│ │ │ # server.ts (standalone Hono prod server),
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
│ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails
│ │ │ # `vite build` on a browser-externalized Node built-in — #1769)
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends;
│ │ # mid-session recovery (challenge.ts WWW-Authenticate
│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts
│ │ # shared copy, mcpAuth.ts force-reauthorization)
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
│ │ │ # runner-interactive-oauth loopback callback flow)
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
│ ├── json/ # JSON utilities and parameter/argument conversion
│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header`
│ │ # annotation scan/validation + mirrored-param
│ │ # derivation, used by the Tools tab — #1632)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
│ │ # extension wire schemas + normalize/handle helpers,
│ │ # used by the raw-wire tasks/* channel — #1631)
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
│ │ │ # used by the web file-upload path + /api/import-source.
│ │ ├── node/ # Node stdio transport factory
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
│ │ └── state/ # InspectorClient state stores consumed by core/react/
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
│ │ # Tasks extension runtime + tasks/* Express interceptor
│ │ # + modern_task/modern_input_task tools — #1631)
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
│ │ # so integration tests can spawn the stdio server as a real
│ │ # subprocess via `node test-servers/build/test-server-stdio.js`.
│ └── tsconfig.json # tsc build config (NodeNext, outDir ./build).
│ # The Vite alias `@modelcontextprotocol/inspector-test-server`
│ # in clients/web/vite.config.ts points at build/index.js
│ # (not src/) so `getTestMcpServerPath()` returns a `.js` path.
│ # tsconfig.test.json keeps paths pointing at src for typecheck.
├── specification/ # Build specification
...
Development setup
v2 is not an npm workspace — each client under clients/* keeps its own package.json and node_modules (see the rationale in specification/v2_cli_tui_launcher.md). A single npm install at the repo root is still all you need: the root postinstall (scripts/install-clients.mjs) cascades npm install into clients/web, clients/cli, clients/tui, and clients/launcher.
- Fresh clone / first-time setup: run
npm installat the repo root. - After a pull that changes a client's dependencies: re-run
npm installat the root to re-sync every client (thepostinstallcascade handles it). - The cascade is dev-only: it exits early when the package is installed under
node_modules, and the published tarball ships only each client'sbuild/, so end users are unaffected. SetINSPECTOR_SKIP_CLIENT_INSTALL=1to skip it.
After installing, npm run build builds all clients. The launcher scripts (npm run web / web:dev) run the built launcher, so build first; for day-to-day web iteration use cd clients/web && npm run dev.
Repository & Project Board
- Repo: https://github.com/modelcontextprotocol/inspector.git
- Base Branches: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work.
- Project Boards:
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here)
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes)
Project Status and Direction
-
The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for.
-
The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is frozen — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it.
-
The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic.
The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations.
Web backend auth token
The dev/prod web backend protects every /api/* route with x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>. The browser recovers that token from three sources, in priority order (see App.tsx getAuthToken()):
window.__INSPECTOR_API_TOKEN__— injected intoindex.htmlon every page load by the backend (the dev Vite plugin viatransformIndexHtml, the prod Hono server on the/route), both routed throughclients/web/server/inject-auth-token.ts. This is what makes a bare-URL reload, a bookmark, or a clearedsessionStoragekeep working.?MCP_INSPECTOR_API_TOKEN=…query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs.sessionStorage— backstop for navigations that land without either of the above.
Injection is a no-op when auth is disabled (DANGEROUSLY_OMIT_AUTH), and the global name is the shared INSPECTOR_API_TOKEN_GLOBAL constant in core/mcp/remote/constants.ts.
Maintenance Rules
Keep documentation files up to date
- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md
- When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details.
- When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well.
- When rules for implementation and testing change, update this file AGENTS.md
Issue-driven Work Style
All work should be driven by items on the project board.
A v2 issue is not "created" until it is BOTH labeled
v2AND on board #28 with a Status set. Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying--label v2does not add the item to the board, and adding it to the board does not set a Status. All three are distinct steps; do all three (see the recipes below). Only issues go on the board — never PRs. A PR still gets thev2label, but it is tracked through its linked issue's card (viaCloses #N), not its own board item.
-
Before starting work, check the board for the relevant item.
-
Every board item is a real GitHub issue. Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — never create a duplicate.
-
Assign the issue to its creator. When you create an issue, assign it to the user it is created on behalf of (
gh issue create --assignee @me ..., or--assignee <login>). Board items should never be unassigned. -
Label by version. New issues and PRs must carry the label matching the target board / branch:
main→v1v2/main→v2
Set the label at create time (
gh issue create --label v2 ...,gh pr create --label v2 ...) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. -
Add the issue to the board and set Status. After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim.
-
When work begins, create a feature branch and set the item's Status to In progress (or SDK V2 + New Spec for a card in that stack).
-
When work is complete:
- Run format, lint, typecheck, build, and test — ensure all checks pass
- Open a PR against the matching base branch (
mainfor v1,v2/mainfor v2) and set the item's Status to In review - Link the PR to its issue. The PR body's first line must be
Closes #<ISSUE_NUMBER>. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's default branch (main). Because v2 PRs targetv2/main(a non-default branch),Closes #Nthere is only a cross-reference — it will not create a hard link or close the issue on merge. (There is noghflag for manual linking —gh pr edithas no--add-issue; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - On merge of a v2 PR, manually close its issue and move the board item to Done (option id
248a3910), since auto-close won't fire onv2/main. Keep theCloses #Nline anyway so the issues close automatically if/whenv2/mainis eventually merged tomain.
-
If new tasks are discovered or requested during development, create issues and add them to the board.
V2 board (#28) gh recipes
The board is an org project, so all commands use --owner modelcontextprotocol and the numeric project 28. The project node id and Status field id are stable. The Status option ids are NOT stable — they are regenerated whenever the Status field's option list is edited (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with:
gh project field-list 28 --owner modelcontextprotocol --format json \
| jq '.fields[] | select(.name=="Status") | .options'
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BJVxt |
| Status field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iI8c |
Status option IDs (--single-select-option-id) — last verified 2026-07-18 (the Building … and MCP Apps Extension columns were removed; their old IDs 4ac261ee / c28da89f / 73d0b807 are now rejected):
| Status | Option ID |
|---|---|
| Todo | fbdaf21e |
| SDK V2 + New Spec | 1bbb6f57 |
| In Progress | 195df262 |
| In Review | 159c8a02 |
| Done | 248a3910 |
Use Todo for approved-but-not-started work, In Progress for general active work (regardless of surface), SDK V2 + New Spec for cards in that stack, In Review once a PR is open, and Done on merge.
⚠️ Never add, rename, or remove a board column (Status option) with the
updateProjectV2FieldGraphQL mutation unless you pass every existing option'sid. That mutation does a full replace of the option list: if you resend options by name/color/description but omit theirids, GitHub deletes all existing options and mints new ones, which orphans the Status of every card on the board (all items go blank) and invalidates every option id in the table above. This has happened once (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference:
- Add/rename/remove a column in the GitHub web UI (Project #28 → Status field settings). This preserves ids of untouched options and never orphans cards.
- If you must script it, first
gh api graphqlthe current options with theirids, then callupdateProjectV2Fieldechoing back every existing option including itsid, appending only the new one. Verify afterward that no card lost its Status.
gh project item-addandgh project item-editare always safe — they set a card's value and never touch the field schema. When option ids change for any reason, re-verify and update the table above (and the248a3910/195df262references in the recipes below and the merge step above).
# 1. Add an issue to the board — prints the item id (PVTI_…); capture it.
gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json
# 2. Set its Status (here: In Progress). Use the option id from the table above.
gh project item-edit \
--project-id PVT_kwDOCt2Azc4BJVxt \
--id <item-id-from-step-1> \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \
--single-select-option-id 195df262
The one-liner that does both, capturing the item id (use the option id for the status you want):
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 195df262
Always test new or modified code
- Ensure all code has corresponding tests
- Ensure test coverage for each file is at least 90%
- In unit tests that expect error output, suppress it from the console
- Run unit tests with
npm run test(ornpm run test:watchduring development) fromclients/web/ - Run CLI tests with
npm run testfromclients/cli/(builds test-servers + CLI bin first viapretest) - Run TUI tests with
npm run testfromclients/tui/ - The repo root has no aggregate
testscript — each client self-validates, so runnpm run validatefrom the root (all clients, fast) orcd clients/<name> && npm run validate(one client). Each client still exposes its owntest/test:coveragefor quick iteration. validateis fast: it runstest, nottest:coverage. The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a separate top-levelnpm run coverage(and per-clientcoverage:web/coverage:cli/coverage:tui/coverage:launcher, each delegating to that client'stest:coverage). Runnpm run coveragewhen you want to reproduce the gate locally before pushing. CI runscoverageon every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runsvalidate(fast) for format/lint/build/unit tests, thencoveragefor the instrumented gate. Because web'stest:coveragealready runs the integration project, CI has no separatetest:integrationstep — the integration paths are exercised inside the coverage gate.- Each client's
test:coverageenforces a uniform per-file gate of ≥ 90 on all four dimensions — lines, statements, functions, and branches — acrossclients/web,clients/cli,clients/tui, andclients/launcher(CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are not waved through by lowering the gate — they are annotated at the source with a justified/* v8 ignore … -- <reason> */comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points,useMediaQueryfallbacks,typeof windowSSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a?? fallbackfor a value the types guarantee non-null, or aSelect.onChangereceiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justifiedv8 ignoreonly when a branch is genuinely impossible to exercise. The web coverageinclude(inclients/web/vite.config.ts) covers the sharedcore/runtime consumed by the browser —core/mcp,core/react,core/auth,core/storage,core/logging,core/node,core/json, andcore/client(the last two folded in by #1689). When adding acore/json/*orcore/client/*module, its tests live underclients/web/src/test/core/…and are gated the same ≥90 way. - The same per-file gate is enforced for the CLI and TUI (#1484), not just web:
- CLI (
clients/cli): tests run in-process by importingrunCli()(see__tests__/helpers/cli-runner.ts) soclients/cli/srcis measured under v8 instrumentation. A thin out-of-process layer (__tests__/e2e.test.ts+scripts/smoke-cli.mjs) still spawns the built binary for the shebang/process.exitpaths;src/index.ts(binary bootstrap) is the only coverage exclusion.commanderuses.exitOverride()so a parse error throws instead of tearing down the test worker. - TUI (
clients/tui): the gate covers the non-React logic only —logger.ts,components/tabsConfig.ts, andutils/*(server resolution lives incore/and is measured by the web suite). The Ink components,App.tsx, andhooks/are an interim exclusion inclients/tui/vitest.config.tspending the renderer-based follow-up (#1501). When adding new non-React logic underclients/tui/src, it falls under the gate automatically — add tests for it.
- CLI (
- Run
npm run test:integration(also fromclients/web/) for the InspectorClient + transport + auth integration suite. It runs under a separateintegrationvitest project in node env (no happy-dom) with 30s timeouts. The script buildstest-servers/first viatsc -p ../../test-servers --noCheckso the stdio MCP test server can be spawned as a real subprocess. CI does not runtest:integrationas its own step — the integration project is covered by the CIcoveragegate, whose webtest:coverageruns--project=unit --project=integration --coverage. - Test files live alongside the source as
<Name>.test.tsx(or.test.tsfor non-React modules). Integration tests live underclients/web/src/test/integration/, mirroring thecore/source layout (mcp/,mcp/node/,mcp/remote/,auth/,auth/node/,storage/). Any test file under that folder is automatically picked up by theintegrationvitest project (node env, 30s timeouts) via the folder glob invite.config.ts— placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in theunitproject (happy-dom). When adding a new test for, e.g.,core/mcp/remote/foo.ts, put it atsrc/test/integration/mcp/remote/foo.test.ts. - Test placement: side-by-side by default,
src/test/only for what can't be co-located. These look like competing conventions but aren't — the split is: tests live beside their source, except tests for the repo-rootcore/package (which lives outsideclients/web/) and shared test scaffolding — both of which live undersrc/test/, withcore/tests mirroring thecore/layout and integration tests undersrc/test/integration/.- Side-by-side (
<Name>.test.tsxnext to the source) — the default for web's ownsrc/code. Components, hooks,lib/,utils/. This is the overwhelming majority; a web-owned test living undersrc/test/instead of beside its source is a bug (fixed one such straggler,downloadFile.test.ts, in #1776). src/test/— the three things that cannot be co-located: (1) tests of the repo-rootcore/package (src/test/core/…, mirroring thecore/folder layout —core/physically lives at/coreoutsideclients/web/, is consumed via the@inspector/corealias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) theintegrationvitest project (src/test/integration/…, node env, 30s — placement is the manifest, see above); (3) shared test infrastructure (renderWithMantine.tsx,setup.ts,fixtures/,scrollAreaStoryAssertions.ts) — not tests of a source file, so nothing to sit beside.
- Side-by-side (
- Use
renderWithMantinefromsrc/test/renderWithMantine.tsxto render components — it wraps inMantineProviderwith the project theme. It setsenv="test"so Mantine renders transitions synchronously (no internalsetTimeout); this prevents aTransition/Modaltimer from firing after happy-dom tears downwindowat end-of-run and failing the whole run with an uncaughtReferenceError: window is not defined(#1760). Always render throughrenderWithMantine; do not hand-roll a bareMantineProviderin a test (that reintroduces the leak class). To exercise a forced color scheme (e.g. theuseComputedColorSchemedark branch) pass thecolorSchemeoption —renderWithMantine(ui, { colorScheme: "dark" })— instead of hand-rolling adefaultColorScheme="dark"provider (#1786). Only when a test must assert mid-flight transition state (e.g. adata-anim="out"cell during an exit crossfade) userenderWithMantineTransitions(real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does not settle a concurrent enter (a completed enter leaves no DOM signal towaitFor), so the helper automatically drains the in-flight animation after the test. The rule for using it: passsettleMsderived from the component's real animation duration — itsTransitionduration/exitDurationplus anyenterDelay/exitDelayplus rAF slack — e.g.renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })(so the window can't silently become insufficient when that duration changes); do not also usevi.useFakeTimers()in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the real clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use theunmount()the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-bodycleanup()on a still-armed tree would trip the check). The mechanism behind all three — why the drain isact-wrapped, the fake-timer hazard, theafterEach-before-cleanup()ordering and itscontainer.isConnectedself-checks, and the exportedsettleTransitions(ms)for manual mid-body settling — is documented at length on the helper inrenderWithMantine.tsx; read there before changing it.
Responding to Code Reviews
- When asked to respond to a code review of a PR,
- it is not necessary to implement all suggestions
- you are free to implement suggestions in a different way or to ignore if there is a good reason
- after making the changes, respond to each review comment with what was done (or why it was ignored)
Mandatory pre-push gate
- ALWAYS do
npm run formatbefore committing — the rootformatauto-fixescore/(format:core), the rootscripts/tooling (format:scripts), the root "shared" surface (format:shared—test-servers/src/**,vitest.shared.mts, the rooteslint.config.js), and every client's scope in one shot. Every client format glob uses the uniform extension set*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(#1792) so a new-extension file can't slip the gate;core/stays{ts,tsx}and the shared surface{ts,tsx,mts,cts}(their surfaces can't hold the other extensions), andnpm run verify:format-coverage(the first step ofvalidate, #1792) is the backstop — it fails if any tracked source file is left uncovered by aformat:checkglob regardless of which glob was expected to catch it.validaterunsformat:check(the non-fixing variant, includingformat:check:core,format:check:scripts, andformat:check:shared) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than lettingformat:checkcatch it. npm run ciis the mandatory pre-push command — it mirrors.github/workflows/main.yml(minusnpm install):validate→coverage→verify:build-gate(the #1769 browser-externalized-builtin build gate) →smoke→ Storybook play-function tests (installs Playwright chromium if needed). It now runsnpm run coverage, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — sonpm run ciis a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes.npm run validateremains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is NOT an acceptable substitute fornpm run cibefore pushing:validaterunstest, nottest:coverage, so it does zero coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression).- ALWAYS do
npm run formatbefore committing, thennpm run cibefore pushing. From the repo root,validaterunsverify:format-coveragefirst (the #1792 guard — asserts every tracked source file is covered by aformat:checkglob), then thecore/gate (validate:core), then chains the four per-client validations (validate:web→validate:cli→validate:tui→validate:launcher); each client delegates to its ownnpm run validatein its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).validate:coreis the root-owned format + lint gate (#1689, widened in #1778 and #1767). Each client'sprettier/eslintis scoped to its own dir, so nothing reachedcore/, the rootscripts/, or the root "shared" surface before —validate:corecloses that: it runsformat:check:core(prettier --check "core/**/*.{ts,tsx}") +format:check:scripts(prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", the root build/verify tooling — #1778) +format:check:shared+lint:core(eslint "core/**/*.{ts,tsx}"via the rooteslint.config.js) +lint:shared. Usenpm run format:core/npm run format:scripts/npm run format:sharedto auto-fix (all folded into the rootformat). The shared surface (#1767) istest-servers/src/**/*.{ts,tsx,mts,cts}, the rootvitest.shared.mts, and the rooteslint.config.js— first-party code no client'seslint ./prettierreaches; it is both prettier-gated (format:check:shared) and eslint-gated (lint:shared, via a secondfilesblock in the rooteslint.config.jsscoped to Node globals). Thescripts/gate is prettier-only — the root has no eslint config for.mjs. The root carries prettier/eslint as devDependencies for this;core/is isomorphic (browser + Node globals, no JSX today — the{ts,tsx}glob future-proofs against acore/**/*.tsx). The rooteslint.config.jshonors an_-prefix as the intentionally-unused marker (argsIgnorePattern/varsIgnorePattern/caughtErrorsIgnorePattern: '^_'). prettier is pinned to an exact version (not a caret) in all fivepackage.jsons (#1790) so the gate's verdict can't shift with an in-range patch bump.- cli and tui now typecheck their
src(#1689). Theirbuild/testrun through esbuild (no type check), so each has atypecheckscript (tsc --noEmit -p tsconfig.json) folded intovalidate. Theirtsconfig.jsonmatchesclients/web/tsconfig.app.json's module/lib resolution options — DOM lib,moduleResolution: bundler, and nonoUncheckedIndexedAccess(web's app config does not extendtsconfig.base, so re-enabling it would surfacecore/issues web never gates) — so the importedcore/sources are validated the same way web validates them. It does not mirror web's extra strictness flags (noUnusedLocals,verbatimModuleSyntax, ES2023 target, …), so cli/tui's ownsrcis checked slightly more loosely than web's.core/itself still typechecks through web'stsc -b. - The one CLI nuance:
clients/cli's out-of-processe2e.test.tsspawns the built binary, so itstestbuilds first viapretest(test-servers:build && build). To avoid building it twice,clients/cli'svalidatefolds that in — it isformat:check && lint && typecheck && testwith no separatebuildstep (the other clients, whose tests don't spawn their bundle, keep an explicitbuild).validate:web/validate:tui/validate:launcherare the uniformformat:check && lint && (typecheck &&) build && test. (#1778, #1789, #1792)clients/web'sformat/format:checkcoverssrc,server,.storybook, and its top-level configs (the uniform*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}glob —vite.config.ts,tsup.runner.config.ts,eslint.config.js, …), not justsrc, so the Node backend, Storybook config, and Vite/build config are prettier-gated too;clients/launcher's coverssrc,__tests__,scripts, and its top-level configs (the*.top-level glob is non-recursive, so each nested dir —.storybook,scripts— is named explicitly). Theverify:format-coverageguard (#1792) enforces that this coverage stays complete. npm run coverageis the per-file ≥90 gate and is now part ofnpm run ci— never treat it as optional before a push. It supersedes the old standalonetest:integrationstep: web'stest:coverageruns theunitandintegrationprojects under v8 instrumentation, socoverageboth enforces the ≥90 gate and exercises the same web integration paths CI covers.
smokeis NOT part ofvalidate— it is included innpm run ci. It runssmoke:launcher(--helpdispatch) plus the prodsmoke:cli/smoke:tui/smoke:web/smoke:web:browser, and contains no build commands — it assumes the cli/tui/launcher bundles already exist (a fullvalidatebuilds them;smoke:webbuildsclients/web/diston demand). CI runsvalidate, then thecoveragegate (which also covers the web integration project), thenverify:build-gate(the #1769 build gate — see below), thensmoke(with Playwright chromium installed just before it, sincesmoke:web:browserneeds it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below).smoke:launcher(scripts/smoke-launcher.mjs) runs the built launcher with--help,--cli --help, and--tui --help, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.smoke:web(scripts/smoke-web.mjs) startsmcp-inspector --web(prod, no--dev) against the builtclients/web/distand assertsGET /serves the SPA (HTTP 200) with the injected__INSPECTOR_API_TOKEN__. Prod--webserves fromclients/web/dist, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (build:client=vite build) on first launch, or exits with an actionable error if that build can't run (seeclients/web/server/ensure-web-build.tsand the launcher README).--devruns Vite directly and never needsdist. It shares the spawn/readiness/teardown helper (scripts/lib/prod-web-server.mjs) withsmoke:web:browser, so the two can't drift.smoke:web:browser(scripts/smoke-web-browser.mjs, #1615) goes a step further thansmoke:web: it boots the same prod--webserver and then actually runs the bundle in headless Chromium (Playwright — already aclients/webdevDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with no uncaught error.smoke:webonly checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a class (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first call into it (e.g.fs.readFileSync(...)during a transitive module's init) throws aTypeErrorthat aborts app mount. A synchronous such throw firespageerror; its async twin (the sameTypeErrorviaawait/.then(), or a failed dynamic import) is logged on the console channel asUncaught (in promise) …/Failed to fetch dynamically imported module— the smoke hard-fails on both. The literalModule "…" has been externalizedtext is, in a prod build, a build-time warning (vite build/npm run build), not a runtime message, so the browser never sees it (undernpm run devVite's stub is instead aProxythatconsole.warns that string at runtime); and an externalized import that is never called ships a harmless{}and is invisible here by design. Every other console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warningconsole.errordoesn't flake CI). Playwright is resolved viacreateRequirebased atclients/web/package.json— a bareimport("playwright")would resolve relative toscripts/, not the cwd, so it can't be reached that way (it only appears to work when an ancestornode_modulescarries playwright, and fails in CI, which has none). The npm script'scd clients/webexists only sonpx playwright install chromiumfinds the local playwright bin (a no-op when already installed).- The build gate for the browser-externalized-builtin class (#1769) is the earlier, more complete companion to
smoke:web:browser. A Vite plugin inclients/web/vite.config.ts(logic inclients/web/server/browser-externalized-builtin-gate.ts, unit-tested) turns Vite 8's browser-externalization warning (Module "node:*" has been externalized for browser compatibility) into a hardvite builderror, so a Node built-in in the browser graph now failsnpm run build/validateinstead of shipping a{}stub. This catches both the called-at-init case (whichsmoke:web:browseralso catches, but later/at runtime) and the imported-but-never-called case (the{}stub that is invisible to the runtime smoke "by design" — see above). Because rolldown swallows a throw insideonLog(the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin records the warning inonLogand re-throws inbuildEnd. There is no stable logcode, so the gate keys off the documented message phrasing;npm run verify:build-gate(scripts/verify-build-gate.mjs, innpm run ciand the GitHub workflow) runs a real build with anode:fsprobe forced intosrc/main.tsxand asserts the build fails via the gate — the only check that catches the message phrasing drifting in a future Vite bump and silently disabling the gate. The gate is scoped tovite build(apply: 'build') — nevervite devor the vitest projects — and to the browser (client) environment (applyToEnvironment), so a future SSR/node environment built from this config isn't failed for a legitimatenode:*import; the Node runner build (tsup,build:runner) is a separate config where built-ins are legitimate.smoke:web:browserstays as the runtime backstop for crashes the build can't reason about. smoke:cli(scripts/smoke-cli.mjs) drivesmcp-inspector --clithrough the built launcher against the bundled stdio test server via a temp--catalog: it assertstools/listreturns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only--configerrors without seeding, and--catalog+--configis rejected.smoke:tui(scripts/smoke-tui.mjs) launchesmcp-inspector --tui --catalog <temp>and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction.smoke:tuiis local-only: it self-skips whenprocess.env.CIis set, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (vianpm run smoke) on your own machine before pushing. Both buildtest-servers/buildon demand if it's missing.- Storybook play-function tests (
clients/webtest:storybook) run in headless Chromium via@vitest/browser-playwright(~10s). They are part ofnpm run ci(which installs Playwright chromium first); kept out ofvalidatebecause they need the browser binary and are slower than the unit suite.
Typescript instructions
- Use TypeScript for all new code
- Follow TypeScript best practices and coding standards
- NEVER use 'any' as a type
- NEVER suppress error types (e.g., no-unused-vars, no-explicit-any) in the typescript or eslint configuration as a way of satisfying the linter or compiler.
- AVOID double casts (
as unknown as T). They erase all type safety and usually signal that the real type is being worked around. Prefer a type guard, a narrower singleascast, or fixing the underlying type. When a double cast is genuinely unavoidable (e.g. a documented gap in a third-party type, or bridging a structurally-identical shape TS can't relate), it MUST carry an inline comment justifying why it is safe and why no better option exists — an unjustifiedas unknown asis not acceptable in review. - Utilize type annotations and interfaces to improve code clarity and maintainability
- Leverage TypeScript's type inference and static analysis features for better code quality and refactoring
- Use type guards and type assertions to handle potential type mismatches and ensure type safety
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
Web source layout: src/lib vs src/utils
The web client keeps two grab-bag directories under clients/web/src, split by a real (now codified) 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. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors:jsonUtils,schemaUtils,toolUtils,maskSecrets,inspectorTabs,deepLink,mcpNetworkHeaders.) Carve-outs that are stillutils:- Domain types. Pure shared domain types plus their pure constructors/transforms live here (
customHeaders—CustomHeader+headersToRecord/migrateFromLegacyAuth, a shape staged forServerSettingsForm, seespecification/v2_ux_interfaces_plan.md, so it currently has no importer but its own test). There is notypes/sub-bucket insidelib/utils— removinglib/types/is what thecustomHeadersmove settles. - Diagnostic logging.
console.warn/console.errordoes not count as a side effect for this rule — a validator that warns on bad input is still "pure" here (sandbox-csp,jsonUtils,schemaUtilsall warn). - Importing from
@inspector/core. Two forms are fine: a type-only import is not a subsystem dependency (pendingReauthis pure type declarations), and re-exporting pure functions or constants from core is not subsystem ownership either (oauthUx/oauthFlowre-export core copy/predicates). What makes a modulelibis wrapping core's stateful runtime, not merely importing from it.
- Domain types. Pure shared domain types plus their pure constructors/transforms live here (
src/lib/— infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the@inspector/coreruntime (not just its types), touch the DOM /window/sessionStorage, or otherwise produce side effects. (Anchors:environmentFactorycomposesInspectorClientEnvironment;remoteOAuthStorageis an adapter class overcore/auth;oauthResumereads/writessessionStorage;browserTabVisibilityregistersvisibilitychangelisteners;clearServerOAuthStatedrives the liveInspectorClient/OAuthStorage;downloadFiletriggers browser downloads.)
The top-level src/types/ is a sibling of both and is not the place for new domain types — it's now purely the home for ambient .d.ts module stubs (e.g. the react-syntax-highlighter shims wired through tsconfig.app.json paths). The last plain-.ts domain type there, the dead navigation.ts InspectorTab, was removed in #1785, so a pure domain type belongs in utils/, not src/types/.
Cross-directory imports point one way, lib → utils (infra depends on pure helpers, never the reverse). Keep it that way: if a utils/ module needs a type currently exported from a lib/ module, declare the type in utils/ and re-export it from lib/ (as pendingReauth owns OAuthResumeAuthKind and oauthResume re-exports it), rather than importing "up" from utils into lib.
Nothing enforces the boundary: no path alias keys off it, and the coverage include in clients/web/vite.config.ts lists both src/lib/** and src/utils/**, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that include is a whitelist — it names components/hooks/theme/lib/utils/server (plus the core/* runtime; hooks and theme were added in #1787), so a module placed outside those directories (types/, App.tsx, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The deliberate, documented top-level-file exceptions are src/App.tsx — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the src/main.tsx / src/index.ts bootstraps (browser createRoot render and the bin runWeb re-export, the analog of clients/cli's excluded src/index.ts). All three are called out in a comment on the include array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. downloadFile bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (lib) rather than splitting hairs.
React instructions
- UI Components
- We are using the Mantine component library for UI.
- Instructions are at https://mantine.dev/llms.txt
- Avoid using div and other basic HTML elements for layout purposes.
- Prefer Mantine's Box, Group, and Stack components for layout.
- Use Mantine's theme and styling utilities to ensure a consistent and responsive design.
- NEVER use inline styles on a component.
- NEVER use raw hex values (
#ddd,#94a3b8, etc.) orrgba()literals for colors in component props or theme files. Use--inspector-*CSS custom properties defined inApp.css :root(e.g.,c: 'var(--inspector-text-primary)'). If no existing token fits, add one to:rootfirst. - NEVER add a CSS class to a Mantine component when the styles can instead be expressed as component props or a theme variant. CSS classes are a last resort.
- PREFER component props (via
.withProps()) to CSS for behavioral and visual styles. - PREFER defining styles as theme variants (via
Component.extend()insrc/theme/<Component>.ts) over CSS classes. Each Mantine component with custom variants has its own file insrc/theme/, exporting aTheme<Name>constant. The barrelsrc/theme/index.tsre-exports them all andtheme.tsimports from the barrel. Flat CSS properties (margin, padding, background, border, color, font-size, etc.) belong in the theme. Only pseudo-selectors, nested child selectors, keyframes, and native HTML element styles belong in App.css. - App.css must contain ONLY styles that cannot be expressed in the Mantine theme:
@keyframes, pseudo-selectors (:hover,:focus), cross-component hover relationships, nested child-element selectors for third-party HTML output (e.g. ReactMarkdown), and styles for native HTML elements (img,iframe). When refactoring a component, actively move any flat CSS properties out of App.css and into theme variants or.withProps()constants. - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable.
- In a component's file, for sub-components:
- ALWAYS use Mantine components for layout and content, configured with props for styling and behavior.
- ALWAYS declare a meaningfully named subcomponent as a constant using
.withProps()if an inline Mantine element carries two or more static props. A static prop is one whose value is a literal that configures the element's styling, layout, or behavior (size="sm",c="dimmed",fw={500},gap="xs",justify="space-between",variant="light",withBorder,readOnly,striped, …); dynamic props (value,onChange/on*,children,key,ref, and anything whose value is a variable/expression) do not count toward the two and are passed at the call site, not baked into the constant. Purely per-instance content/accessibility literals —label,description,placeholder,title,aria-label,role— likewise do not count toward the two (a<Checkbox label="…" description="…">with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in all cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the.withProps()constant and pass the dynamic ones where it's rendered. - The following cannot be expressed via
.withProps()and so stay inline (likeBoxbelow), each with a one-line comment saying why:Accordion(a compound,multiple-discriminated generic —.withProps({ multiple: true, … })loses its JSX call signature and fails to type); headless, non-factory()Mantine components such asTransition(plain function components with no Styles API — they have no.withPropsstatic at all, e.g.Transition.withPropsis a TS2339); anddata-*attributes (not part of a component's typed props object, so excess-property-checked out of awithPropsliteral — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (areact-iconsglyph, another library's component) and a first-party component that isn't a Mantine factory (a dumbexport functionlikeContentViewer, which has no.withPropsstatic of its own). - NEVER use
Boxfor subcomponent constants —Boxdoes not support.withProps(). UseGroup,Stack,Flex,Text,Paper,UnstyledButton, orImageinstead. Pick the component that best matches the purpose:Paperfor bordered/surfaced containers,Textfor any text or content wrapper,Stack/Group/Flexfor layout. ABoxthat genuinely needs a non-flex primitive it can't provide —component="iframe", ordisplay="grid"(no Mantine flex primitive is a CSS grid) — stays aBoxinline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in
src/theme/<Component>.tsusingComponent.extend({ styles: (_theme, props) => { ... } })and reference them withvariant="variantName"on the component or in.withProps(). - CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (
:hover,:focus), cross-component hover relationships (.parent:hover .child), nested child-element selectors (.wrapper p,.wrapper code),@keyframesdefinitions, and native HTML elements (img,iframe) that are not Mantine components. - When a theme variant needs a CSS class for nested/pseudo selectors, use
classNamesin the theme extension to auto-assign it — never addclassNamemanually in JSX for theme-styled components. - Example — subcomponent constant with
withProps:
const CardContent = Group.withProps({ flex: 1, align: 'flex-start', justify: 'space-between', wrap: 'nowrap', }); return <CardContent> ... </CardContent>- Example — theme variant with auto-assigned className for nested selectors:
// src/theme/Paper.ts export const ThemePaper = Paper.extend({ classNames: (_theme, props) => { if (props.variant === 'message') return { root: 'message' }; return {}; }, styles: (_theme, props) => { if (props.variant === 'message') { return { root: { padding: '1.5rem', borderRadius: 12 } }; } return { root: {} }; }, }), // Component.tsx const MessageContainer = Paper.withProps({ variant: 'message' });
- Theme files vs. Storybook element components
- Theme files (
src/theme/<Component>.ts) and element components (src/components/elements/) serve different purposes and both are needed. - Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by
MantineProvider. - Element components add domain-specific semantics on top of Mantine primitives. For example,
AnnotationBadgemaps domain concepts (audience, destructive, longRun) to Mantine's styling primitives (color, variant). Storybook documents these domain components for designers and developers. - Element components MUST import from
@mantine/core, NOT fromsrc/theme/. The theme layer is applied transparently by the provider — elements do not need to know aboutTheme<Name>constants. - NEVER push domain-specific variant logic (e.g., annotation types, transport types) into theme files. Domain variants belong in the element component that owns those semantics. Theme files are for styling that applies to the Mantine primitive globally.
- Theme files (