- TypeScript 90.8%
- JavaScript 8.8%
- CSS 0.2%
- HTML 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
* 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> |
||
| .claude | ||
| .github/workflows | ||
| clients | ||
| core | ||
| docs | ||
| scripts | ||
| specification | ||
| test-servers | ||
| .dockerignore | ||
| .gitignore | ||
| .npmignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| Dockerfile | ||
| eslint.config.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
| vitest.shared.mts | ||
MCP Inspector
A developer tool for inspecting Model Context Protocol (MCP) servers. It ships as a single package, @modelcontextprotocol/inspector, that provides three ways to inspect a server:
- Web — a Vite + React + Mantine single-page app with a Node backend.
- CLI — a scriptable command-line client for automation, CI, and fast agent feedback loops.
- TUI — an interactive terminal UI built with Ink.
All three run through one global mcp-inspector binary:
npx @modelcontextprotocol/inspector # web UI (default)
npx @modelcontextprotocol/inspector --cli # CLI
npx @modelcontextprotocol/inspector --tui # TUI
Repo status. This is the v2 line of the Inspector (branch
v2/main). Themainbranch is the legacy v1 implementation (bug fixes only). v2 will eventually replacemain. SeeAGENTS.mdfor branch/board conventions.
Project layout
v2 is not an npm workspace. Each client under clients/* keeps its own package.json and node_modules; shared code lives in core/ and is consumed via a @inspector/core build-time alias (no package.json of its own). A single npm install at the root cascades installs into every client (see Setup).
inspector/
├── clients/
│ ├── web/ # Web client (Vite + React + Mantine). src/ = browser app; server/ = Node dev/prod backend
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
│ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
│ ├── json/ # JSON + parameter/argument conversion utilities
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers for the OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify)
├── specification/ # Design/build specifications
├── AGENTS.md # Contribution rules for agents AND humans (see below)
└── README.md # You are here
Each client has its own README with client-specific detail: web · cli · tui · launcher.
Task-oriented guides live under docs/ — see Reviewing an MCP App, the CLI-first → one-shot-web recipe for automated App-tool review: --app-info probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support.
Setup
Requires Node >=22.19.0.
npm install # root install; postinstall cascades into every client
- Fresh clone: 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.
The cascade (scripts/install-clients.mjs) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's build/, so end users are unaffected. Set INSPECTOR_SKIP_CLIENT_INSTALL=1 to skip it.
Running during development
For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed):
cd clients/web && npm run dev
The launcher-driven scripts below run the built launcher, so build first (npm run build):
npm run web # prod web launcher against clients/web/dist
npm run web:dev # web launcher in --dev mode (Vite)
The @inspector/core shared package
core/ holds the logic shared by all three clients so that web, CLI, and TUI behave identically. Its entry point is the InspectorClient class (core/mcp/), which owns the connection to an MCP server, the request/response lifecycle, and a set of state stores; core/react/ exposes React hooks over those stores that both the web and TUI (Ink) React trees consume. OAuth (core/auth/) is factored into isomorphic logic plus browser/node/remote backends so the same flows work in the browser, in Node, and against a remote backend.
core/ intentionally has no package.json — it is not published on its own. Each client bundles it in via a @inspector/core alias:
- CLI / TUI:
esbuildOptions.aliasin theirtsup.config.tsmaps@inspector/core→ the repocore/directory, andnoExternal: [/^@inspector\/core/]inlines it into the bundle. - Web: the same alias in
clients/web/vite.config.tsfor the browser app and the Node backend runner.
Publishing core/ as its own package (e.g. for third parties to build on) is deliberately deferred — see issue #1636.
Web client: "dumb components" + Storybook
The v2 web client is built from presentational ("dumb") components — they accept data and callbacks as props and contain only display logic, with no direct data fetching or client state. State comes from the @inspector/core hooks, wired in near the top of the tree. This keeps components isolated, testable, and documentable.
That approach is what makes Storybook first-class here: every screen and element component has a *.stories.tsx file (96+ stories) that renders it against fixture props. Storybook play functions double as interaction tests, run headless in CI (npm run ci:storybook, Chromium via Playwright).
Styling follows a strict Mantine-first convention (theme variants and component props over CSS classes, --inspector-* CSS custom properties over raw color literals). The full rules live in AGENTS.md under React instructions — read them before touching web UI. Element components live in clients/web/src/components/elements/; theme variants in clients/web/src/theme/.
Test servers
test-servers/ provides composable MCP servers used by the integration and smoke suites, so tests exercise a real server over a real transport instead of mocks. A server is assembled from presets (fixture factories in test-servers/src/preset-registry.ts — tools, resources, prompts, tasks, elicitation, sampling, OAuth, …) and can be driven two ways:
- In-process — import the factories (
createTestServerHttp,createEchoTool, …) and run the server inside the test's event loop (used by the HTTP integration paths). - As a subprocess —
test-servers/build/test-server-stdio.jsis spawned as a real stdio child (used by the CLI smoke and stdio integration tests).
Configure a server declaratively with a JSON config (see test-servers/configs/*.json) selecting presets, then load it via --config. Because the servers are spawned as real subprocesses, the build output must exist first:
npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits test-servers/build/
The Vite alias @modelcontextprotocol/inspector-test-server (in clients/web/vite.config.ts) points at test-servers/build/index.js so getTestMcpServerPath() resolves to a real .js path.
A streamable-HTTP server can also serve the modern (2026-07-28) protocol era via the SDK's createMcpHandler — set transport.modern in the JSON config (true for dual-era stateless serving, or { "legacy": "reject" } for modern-only strict), or pass modern on the ServerConfig for an in-process createTestServerHttp. This is what lets an Inspector connection negotiating protocolEra: "auto" | "modern" reach the modern leg (populated server/discover, sessionless). See test-servers/configs/modern-http.json. test-servers/configs/modern-mrtr-http.json additionally serves the mrtr_confirm tool (preset mrtr_confirm, createMrtrTool) over the modern leg: its handler returns inputRequired(...) embedding a form elicitation, so invoking it produces a real MRTR round-trip (input_required → the client fulfils the embedded elicitation and retries with a new id → complete). The Inspector drives MRTR manually (inputRequired: { autoFulfill: false }), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. test-servers/configs/mrtr-showcase-http.json bundles every MRTR preset in one modern server for manual testing: mrtr_confirm (single round), mrtr_two_step (two elicitation rounds via requestState), mrtr_sample (embedded sampling → the Sampling panel), mrtr_roots (embedded roots/list, auto-answered silently from configured roots — no modal), mrtr_edge (an inputRequests-only round then a requestState-only round), and mrtr_loop (never completes → the MRTR_MAX_ROUNDS bound trips). (The legacy collect_elicitation preset calls server.elicitInput, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.)
test-servers/configs/modern-network-http.json is the Network-tab showcase for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a get_weather tool whose city argument carries an x-mcp-header: "City" annotation (so a modern client mirrors it to Mcp-Param-City), plus four trigger_* tools that the modern leg's spec-error injector (transport.modern.injectSpecErrors: true) answers with a real HTTP status + JSON-RPC error body: trigger_header_mismatch → 400 / -32020, trigger_missing_capability → 400 / -32021, trigger_unsupported_version → 400 / -32022 (with data.supported), trigger_method_not_found → 404 / -32601. Connect to it with Protocol Era = Modern and open the Network tab to see the mirrored Mcp-* headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: Mcp-Param-* mirroring is skipped by the SDK in the browser (detectProbeEnvironment() !== "browser"), so calling get_weather from the web client omits Mcp-Param-City and the strict server answers -32020 — the same tool is callable from the Node CLI/TUI, where mirroring is active.
test-servers/configs/xmcpheader-modern-http.json is the x-mcp-header Tools-tab showcase (#1632). It serves echo, a get_weather tool with a valid x-mcp-header: "City" annotation on its city argument, an invalid_header_tool whose annotation uses the header name "Bad Header" (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a trigger_invalid_params tool that the modern leg's spec-error injector (transport.modern.injectSpecErrors: true) answers with a real -32602 Invalid params JSON-RPC error whose message is not about a missing tool. Connect with Protocol Era = Modern and open the Tools tab: get_weather's detail panel shows a "Mirrored request headers (SEP-2243)" section (city → Mcp-Param-City), and invalid_header_tool appears struck-through under an "Excluded (SEP-2243)" divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from tools/list; the Inspector surfaces why). Under SDK v2 a tools/call that rejects with -32602 now renders as a distinct error panel rather than an isError result — headed "Unknown Tool" when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or "Invalid Parameters" for any other -32602 (run trigger_invalid_params).
test-servers/configs/pagination-http.json is the page-by-page fetch showcase (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets numbered_tools / numbered_resources / numbered_prompts, count: 12) with maxPageSize of 4 for each, so every list paginates into three pages. Turn on "Fetch Lists One Page at a Time" (Server Settings — the paginatedLists setting, or the Paginated switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a Load next page control and an N pages loaded status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
test-servers/configs/advertised-extensions-http.json is the advertised-extensions showcase (#1739). It serves echo (always) and a get_weather tool that is gated on the io.modelcontextprotocol/tasks extension (extensionGatedTools): the tool is registered but starts disabled, and the server enables it on notifications/initialized only when the connected client declared that extension in its capabilities.extensions. Connect (the Inspector advertises the Tasks extension by default) and the Tools list shows both echo and get_weather. Open Server Settings → Advertised Extensions, uncheck Tasks (io.modelcontextprotocol/tasks), and reconnect: the client now advertises no extensions, the server never enables get_weather, and the Tools list shows only echo. This demonstrates the debugging knob — a server legitimately changing tool registration on what the client advertises. (Legacy stateful leg only; the modern per-request leg has no persistent oninitialized.)
test-servers/configs/logging-legacy-http.json and test-servers/configs/logging-modern-http.json are the logging era-fork showcase (#1629). Both serve logging: true plus a send_notification tool that emits a notifications/message at a chosen level; the legacy one is a plain streamable-HTTP server (logging/setLevel era) and the modern one sets transport.modern: true. Connect to the legacy server and open the Logs tab to get the session-scoped Set Active Level selector + Set button; calling send_notification streams the log into the panel. Connect to the modern one with Protocol Era = Modern and the same tab instead shows the Log Level per Request control — pick a level to opt in and the client stamps _meta["io.modelcontextprotocol/logLevel"] on every subsequent request (verify in the Network tab's request body); calling send_notification then streams the log into the panel over the request's SSE response. Set the control back to Off and the same call is silently gated — the request omits the logLevel key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit notifications/message for a request that didn't opt in") because send_notification emits through the SDK's request-scoped, threshold-aware extra.log (ctx.mcpReq.log): on the modern leg it reads the per-request logLevel opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from logging/setLevel. Because it emits through the request's notify, the modern response upgrades to SSE and the log rides the originating request's stream.
test-servers/configs/subscriptions-legacy-http.json and test-servers/configs/subscriptions-modern-http.json are the resource-subscription era-fork showcase (#1630). Both serve three numbered_resources with subscriptions: true; the legacy one also serves an update_resource tool, and the modern one sets transport.modern: true. Connect to the legacy server, open a resource in the Resources tab and click Subscribe — the client sends resources/subscribe (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call update_resource with that URI and the server updates the content and emits notifications/resources/updated, stamping the subscribed tile's last-updated time. Connect to the modern server with Protocol Era = Modern and the same Subscribe instead sends subscriptions/listen (its filter carries resourceSubscriptions + the resourcesListChanged opt-in) and resolves on notifications/subscriptions/acknowledged; the Subscriptions section then shows the stream-status badge (Connecting… → Listening) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately omits update_resource: the SDK's modern leg is stateless/per-request (createMcpHandler(() => createMcpServer(config))), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next resources/read, and its resources/updated wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's receive path is era-transparent, so a real stateful modern server that routes resources/updated onto the listen stream drives the subscribed tile the same way.)
test-servers/configs/tasks-legacy-http.json and test-servers/configs/tasks-modern-http.json are the Tasks era-fork showcase (#1631). The legacy server advertises capabilities.tasks (tasks: { list, cancel }) with the simple_task / progress_task / elicitation_task presets — connect to it, run one of those tools with Run as task on, and the Tasks tab lists it (populated via tasks/list), polls tasks/get, fetches the payload with the blocking tasks/result, and cancels with tasks/cancel. The modern server sets transport.modern: true and tasksExtension: true, advertising the io.modelcontextprotocol/tasks extension (SEP-2663) and serving the modern_task / modern_input_task tools. Connect with Protocol Era = Modern: the Tasks tab is now gated on the negotiated extension (not capabilities.tasks). Run modern_task as a task — the tools/call returns a CreateTaskResult (resultType: "task", visible in the Protocol/Network tabs), the client polls tasks/get (no tasks/list), and the completed task inlines its result (no blocking tasks/result). Run modern_input_task and the task moves to input_required, surfacing an embedded elicitation through the pending-request modal; answering it sends tasks/update with the inputResponses, and the next poll completes. SDK v2 removed all tasks support and era-gates the tasks/* spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the resultType: "task" frame is rewritten at the transport into a CallToolResult carrying the handle; tasks/get/update/cancel ride a raw-wire request channel with the full modern envelope), and the test server serves tasks/* from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them -32601). The Tasks tab's Refresh re-polls the handles already known to the client (modern has no server-side task list).
Building
npm run build # builds all clients: web → cli → tui → launcher
Individual clients: build:web, build:cli, build:tui, build:launcher. The web build produces both the browser SPA (clients/web/dist, Vite) and the Node prod-server runner (clients/web/build, tsup).
Testing & the quality gate
Each client self-validates from its own folder; the root scripts chain them. There is no aggregate root test script — use validate (fast) or coverage (the gate).
| Script | What it does |
|---|---|
npm run validate |
Runs verify:format-coverage (asserts every tracked source file is format-gated) first, then validate:core (the shared core/ format:check + lint gate), then per client: format:check + lint + typecheck (cli/tui only) + build + fast unit tests. The quick inner-loop check. |
npm run coverage |
The per-file ≥90% gate (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared core/ runtime (including core/json and core/client). |
npm run smoke |
End-to-end smokes through the built launcher (--help dispatch + prod cli/tui/web), plus a headless-Chromium boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests). |
npm run verify:build-gate |
Runs a real vite build with a Node built-in forced into the browser graph and asserts the build fails via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of npm run ci. |
npm run verify:format-coverage |
Parses the format:check globs out of every package.json (only those reachable from validate), enumerates all tracked source files, and fails listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in validate. |
npm run ci |
Mandatory pre-push command. validate → coverage → verify:build-gate → smoke → Storybook. A true superset of GitHub CI. |
npm run pack:verify |
Publish smoke — see Publishing. |
Per-client scripts exist too (validate:web, coverage:cli, smoke:tui, …), plus root validate:core / format:core for the shared core/ package, format:scripts for the root scripts/ tooling, and format:shared / lint:shared for the root "shared" surface (test-servers/src/**, vitest.shared.mts, the root eslint.config.js). Run npm run format before committing — the root format fixes core/, the root scripts/, the shared surface, and every client; validate runs the non-fixing format:check and fails CI on any unformatted file.
For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the v8 ignore policy — see AGENTS.md.
Publishing
The root @modelcontextprotocol/inspector package ships as one tarball with a single version number — no separate -web / -cli / -tui / -core packages. npm run build builds every client, then prepack runs before npm publish. Runtime dependencies are declared on the root package.json; client builds bundle @inspector/core and externalize npm packages resolved from the root install.
What ships, and the packaging invariants
The root package.json "files" allowlist is the source of truth for the tarball. A few non-obvious entries exist because they are read at runtime or were silently dropped by npm's packlist — do not remove them without re-running npm run pack:verify:
- No source maps. The client bundlers set
sourcemap: false(clients/{cli,tui}/tsup.config.ts,clients/web/tsup.runner.config.ts); Vite and the launcher'stscalready emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug vianpm run devon the source. clients/web/buildships viaclients/web/.npmignore.clients/web/.gitignorelistsbuild/, and npm's packlist honors that nested.gitignoreover the root"files"allowlist — so the prod web-server runner was silently missing from the tarball whileclients/web/distslipped through (its.gitignoreonly listsdist-ssr).clients/web/.npmignoreoverrides the.gitignorefor publishing so bothbuild/(runner) anddist/(SPA) ship. The other clients don't need this — none ship a nested.gitignore.- A single version number, read from the root
package.json. The Inspector ships as one package with one version, so only the rootpackage.jsoncarries aversion— the fourclients/*/package.jsons deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the sharedreadInspectorVersion()reader incore/node/version.ts, which walks up to the root manifest (always present in the tarball). No clientpackage.jsonis read at runtime, so none needs to ship. The web browser can't read the filesystem; it gets its version from the backend viaGET /api/config(see #1639).
npm run pack:verify — publish smoke against the real tarball
The smoke:* scripts run against the in-repo build tree, which is not the published package. npm run pack:verify (scripts/pack-and-verify.mjs) closes that gap: it builds, npm packs the publishable tarball (asserting no source maps ship and that the runtime-required files are present), installs the tarball into a clean throwaway consumer — a fresh temp directory where it runs a real npm install <tgz> (pulls runtime deps, runs postinstall), exactly as npx @modelcontextprotocol/inspector would — and drives the installed mcp-inspector bin end to end: --help dispatch, a real --cli tools/list over stdio, and a prod --web boot that must serve / from the shipped dist. It catches "works in --dev, breaks under npx …" path/packaging failures. It requires network access (the install pulls deps), so it is a local / release check, not part of the fast validate/ci loop.
Cutting a release
Publishing is automated by two release-gated jobs in .github/workflows/main.yml (github.event_name == 'release', both needs: build):
publish— the npm package. Runsnpm run pack:verifyas the pre-publish gate, asserts the release tag matches the rootpackage.jsonversion, thennpm publish --access public --provenance— a singlenpm publish(v2 is not an npm workspace, so there is no v1-stylepublish-all/--workspaces), with a signed provenance attestation via GitHub OIDC (id-token: write,environment: release,NPM_TOKEN).publish-github-container-registry— the container image (see Docker).
Because there is one version number (only the root package.json has one — the clients carry none, so there is nothing to keep in sync and no check-version step), the release flow is just:
npm version <major|minor|patch> # bumps the root package.json + tags
git push --follow-tags
# then draft & publish a GitHub Release for that tag → triggers `publish`
The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow.
Docker
A container image is published to GHCR (ghcr.io/modelcontextprotocol/inspector, linux/amd64 + linux/arm64) by the release workflow. The Dockerfile is a two-stage build: the first stage installs and npm packs the publishable tarball; the second stage npm install -gs that tarball, so the image ships the exact same artifact as npm, with a clean mcp-inspector bin.
# run the web UI (reads the auth token from the container logs)
docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector
# or build the image locally
docker build -t mcp-inspector .
docker run --rm -p 6274:6274 mcp-inspector
The image defaults to --web bound to 0.0.0.0:6274 with browser auto-open disabled; override the args to run another mode (docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …). Pass -e MCP_INSPECTOR_API_TOKEN=… to set a known token (otherwise one is generated and printed in the logs), or -e DANGEROUSLY_OMIT_AUTH=true to disable auth. The image runs as the non-root node user and has a HEALTHCHECK that probes the web UI — it assumes the default --web mode, so add --no-healthcheck when running --cli/--tui (which have no web server).
Contributing — AGENTS.md and CLAUDE.md
AGENTS.md is the contract for changing this codebase, and it applies to humans and AI agents alike. It is not agent-only boilerplate — it holds the project's real conventions: the issue-and-board workflow, branch/label rules, the TypeScript and Mantine/React standards, the testing and coverage requirements, and the mandatory pre-push gate. Read it before making changes, and keep it up to date when you change structure, tooling, or rules.
CLAUDE.md is the entry point the Claude Code agent loads automatically; it simply includes AGENTS.md and this README, so both agents and humans work from the same source of truth. If you use a different agent that reads AGENTS.md, you get the same rules.
A key rule worth surfacing here: all work is issue-driven. Before starting, find or create a tracking issue on the v2 project board; open PRs against v2/main with Closes #<issue>. The exact recipes (labels, board IDs, statuses) are in AGENTS.md.
License
MIT.
