Inspector V2
August 2, 2026 · View on GitHub
This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI.
Project Structure
v2/main/
├── 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),
│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an
│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES;
│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost.
│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795),
│ │ │ # 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,
│ │ # issuerBinding.ts SEP-2352 callback-leg failure
│ │ # classification — separates a recoverable
│ │ # "lost authorization state" from a genuine
│ │ # cross-AS issuer mismatch — #1808)
│ │ ├── 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)
│ ├── client/ # Install-level client config (`client.json`): browser-safe
│ │ # parse/validate (config-parse.ts) + Node load/save
│ │ # (config.ts, node-persistence.ts), the remote backend
│ │ # (remote.ts), secrets (secrets.ts), and runner.ts.
│ │ # Consumed by both App.tsx trees (web + tui); gated by
│ │ # the web coverage `include`, tests in
│ │ # clients/web/src/test/core/client/.
│ ├── 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;
│ │ # plus `Mcp-Param-*` header building for the
│ │ # wire, used by both `tools/call` paths — #1846)
│ ├── 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/
│ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion,
│ │ # walks to the root package.json), hostUrl.ts (shared host
│ │ # normalization + detection — formatHostForUrl brackets IPv6,
│ │ # canonicalUrlHost canonicalizes a bind host the way a browser
│ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
│ │ # callback listener; also stripBrackets. Used across
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
│ ├── 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.
├── docs/ # Task-oriented guides (mcp-server-configuration.md,
│ # mcp-app-review.md, launcher-config-consolidation-plan.md,
│ # images/). Linked from the root README.
├── scripts/ # Root build/verify tooling: install-clients.mjs (the
│ # postinstall cascade), the smoke-*.mjs runners,
│ # verify-build-gate / verify-format-coverage /
│ # verify-typecheck-coverage, pack-and-verify.mjs,
│ # and lib/ shared helpers. Prettier-gated via
│ # `format:check:scripts`; its own pure parsers are
│ # unit-tested by `npm run test:scripts` (node --test).
├── 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.
Contributing
External contributions are accepted as issues, not pull requests — maintainers handle design and implementation through a prompt-driven workflow.
If you've already built a change locally, share the prompt you used and screenshots if applicable, not a diff. See CONTRIBUTING.md for the full policy.
This applies to org members with write access too, not just outside contributors. Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the repo maintainers only. Anyone else — including organization members whose write access makes it technically possible — opens a detailed issue instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff.
Issues are filed through the forms in .github/ISSUE_TEMPLATE/ — blank issues are disabled. GitHub serves the chooser from the default branch only, so a form edited here on v2/main has no effect on the live chooser until the next milestone merge into main — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, Bug report (1-bug_report.yml, auto-labels bug) and Feature request (2-feature_request.yml, auto-labels enhancement and v2); config.yml holds the chooser's contact links. A form's labels: is static — GitHub cannot map a reporter's answer to a label — which splits the two cases: the bug form could target either line, so it carries a required version-line dropdown and a maintainer applies the matching label at triage per Label by version; the feature form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares v2 statically. If v1 ever reopens to features, that static label is what has to change. There is deliberately no security template: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see SECURITY.md). When adding or changing a form, validate it against GitHub's issue-forms schema (markdown blocks take no id and no validations; checkboxes mark required per option, not under validations).
Every PR must reference an issue. No exceptions, regardless of who opens it. The PR body's first line is Closes #<ISSUE_NUMBER> (see the Issue-driven Work Style rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature.
Project Status and Direction
-
The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is published straight from the branch to the
v1-latestnpm dist-tag — v1 releases never pass throughmain, and v1 PRs therefore targetv1/maindirectly. -
The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the develop branch: work accumulates here continuously and is merged into
mainat milestone releases. -
The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the
latestnpm dist-tag.
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
- Mirror review-relevant changes into
.github/copilot-instructions.md. That file is what GitHub Copilot reads when it reviews a PR, and it is a hand-maintained distillation of this one — there is no generation step and nothing detects drift, so it goes stale silently and Copilot then reviews against rules we no longer hold.- AGENTS.md remains the source of truth. Never edit
copilot-instructions.mdalone to change a rule; change it here first, then mirror. - Review-relevant means anything a reviewer would cite against a diff: the TypeScript rules, the Mantine/React conventions (including the
.withProps()rule and its exceptions), thelibvsutilssplit, test placement, the ≥90% coverage gate and thev8 ignorepolicy, therenderWithMantinerequirement, and the PR hygiene rules (Closes #N, version label). Changing any of these means updating both files in the same PR. - Not review-relevant, and deliberately absent from the mirror: the board recipes and their IDs, milestone and branch-naming mechanics, release and publishing procedure, and the project-structure tree. Copying those in would double the maintenance surface for content no reviewer cites.
- Keep it a distillation, not a copy — it is read on every review, so length has a cost. Prefer tightening the summary over pasting a section wholesale.
- AGENTS.md remains the source of truth. Never edit
Issue-driven Work Style
All work should be driven by items on the project board.
A v2 issue is not "created" until it is labeled
v2, given a milestone, AND on board #28 with a Status and a Priority set. Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and 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 or a Priority. All five are distinct steps; do all five (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.
-
Label by version — every issue and every PR, no exceptions. Each one carries exactly one of
v1orv2at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible.v1— work targetingv1/main(the deprecated line: security fixes only)v2— work targetingv2/main(active development; the default for anything new) Set the label at create time —gh issue create --label v2 ...,gh pr create --label v2 ...— never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. If the target version isn't obvious, it'sv2: v2 is where all new work goes, andv1is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix for released v1 behavior and it's unclear whether v2 still has the bug. Note the label is a repo tag and is not the board — see the callout above; av2issue also needs a board card with a Status and a Priority (av1one needs a Status; board #11 has no Priority field).
-
Prioritize every new issue. Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a board field, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in Setting issue priority rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace.
-
Add the issue to the board and set Status and Priority. After creating an issue, add it to the board for its version —
v2→ board #28,v1→ board #11 — and set the fields. (PRs are never added to either 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, and take them from the section for the right board; the two projects' ids are not interchangeable.- New and untriaged →
Incoming. This is the default status for a new item on either board. An issue nobody has evaluated yet belongs in Incoming, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. - Priority is v2-only. Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else.
- New and untriaged →
-
Every new issue gets a milestone — no exceptions. Set it at create time with
gh issue create --milestone <title> .... If the user didn't specify one, default to the current milestone: the open milestone with the nearest due date. Never leave an issue unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. Get the current milestone with:# Open milestones, soonest due date first — the first row is the current one. gh api repos/modelcontextprotocol/inspector/milestones --jq \ 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"'Milestones are release buckets (
v2.1.0,v2.2.0, …), so pick by when the work ships, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. -
When work begins, create a feature branch and set the item's Status to In Progress.
-
Branch names start with the target version segment. The first path segment must be the version whose base branch the PR targets —
v2/for work onv2/main,v1/for work onv1/main— followed by the usual type and slug:v2/ci/restore-claude-workflow,v2/fix/oauth-scope-union,v1/fix/proxy-ssrf-pin. Notci/restore-claude-workflow. This keeps the two lines legible ingit branch -aand in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (v2/main,v1/main). -
When work is complete:
- Run
npm run cifrom the root — the mandatory pre-push gate (see Mandatory pre-push gate).npm run validateis the fast inner-loop check and is not a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - Open a PR against the matching base branch (
v1/mainfor v1,v2/mainfor v2) and set the item's Status to In Review - Attach screenshots as proof of functionality. Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a
pr-screenshots/folder off the repo root, creating it if it doesn't exist. That folder is gitignored — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (tools-tab-before.png,tools-tab-after.png), notScreenshot 2026-07-31 at 14.02.11.png. - Link the PR to its issue — mandatory for every PR, from anyone. No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the repo maintainers open PRs at all (see Contributing) — everyone else files a detailed 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
259d6aab), since auto-close won't fire onv2/main. Keep theCloses #Nline anyway so the issues close automatically if/whenv2/mainis eventually merged tomain.
- Run
-
If new tasks are discovered or requested during development, create issues and add them to the board.
Setting issue priority
Every issue gets a Priority on its board card, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later.
⚠️ There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under Projects → Inspector V2.
Where it appears What it is Ours? Projects → Inspector V2 → Priority The project board field on board #28 ( PVTSSF_lADOCt2Azc4BJVxtzg5iJE4). Urgent/High/Medium/Low, each option carrying its rubric band in the description.✅ Yes — this is the one this rubric sets. Fields → Priority (above Projects) A GitHub issue field, IFSS_kgDOAdAWeg. Defined at themodelcontextprotocolorg and shared by every repo in it (typescript-sdk, servers, registry, …), alongsideEffort,Start date, andTarget date. Created 2026-05-06,ORG_ONLY.❌ No. Not ours, not repo-scoped. They look identical — same name, same four option names — but nothing syncs them. Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing
Urgentin Fields andHighon the board). There is no pass-through, in either direction.Never delete the org-level field. It belongs to the whole org, so removing it would strip Priority from every other
modelcontextprotocolrepo.Don't set it either — but do read it. A value there is a reporter's opinion, not a maintainer's assessment, so it is untrusted input. It feeds the rubric as a capped +1 signal bonus and nothing more; see Trust boundary below.
Axis 1 — Severity / impact (1–5). How bad is it when it happens?
| Score | Means |
|---|---|
| 1 | Cosmetic — a typo, a misaligned control, a wording nit. |
| 2 | Minor friction with an easy workaround. |
| 3 | A real feature is broken or missing; the workaround is annoying or partial. |
| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. |
| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. |
Axis 2 — Urgency / staleness (1–5). How time-sensitive or neglected is it?
| Score | Means |
|---|---|
| 1 | No time pressure; nothing waits on it. |
| 2 | Wanted eventually. |
| 3 | Wanted this milestone, or has sat >90 days with no activity. |
| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). |
| 5 | Blocking a release, or actively hurting users on a published version right now. |
Signal indicators (bonuses, +1 each — not an axis of their own). These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone:
- Carries a
bugor security-related label - Linked to a milestone
- High engagement (many comments or reactions)
- Assigned to someone
- A sub-issue of a larger epic
- The reporter set
Fields → Priorityto Urgent or High — +1, flat, whichever of the two they picked. It does not map to a band, andUrgentearns exactly whatHighearns. See below.
Bands. Axes give 2–10 and there are six bonuses, so the total runs 2–16.
| Total | Priority | Meaning |
|---|---|---|
| 12+ | Urgent | Drop what you're doing. |
| 9–11 | High | Next up after current work. |
| 6–8 | Medium | Scheduled normally. |
| ≤5 | Low | Nice to have; may sit. |
Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands High. That's deliberate — Urgent is reserved for a severe problem that something else also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around.
Set the resulting level on the board card with the Priority recipe in the V2 board (#28) gh recipes below.
Trust boundary: who can set what
The boards are private (public: false, both #28 and #11 — verified 2026-08-01). The Status and Priority a maintainer assigns are visible only to people with project access: a reporter cannot see them, cannot set them, and will never learn how their issue was scored. Board priority is a maintainers' working queue, not a published commitment.
The org-level Fields → Priority is the opposite. It renders on the public issue page and is not part of maintainer triage, so any value there is untrusted — we didn't put it there, and it carries a preference rather than an assessment.
That asymmetry is the whole reason the reporter's value earns a flat +1 and nothing more:
- It counts for something. Someone flagging their own issue is real information about how much it hurts them. Discarding it throws away a signal we'd otherwise have to infer.
- It cannot decide an outcome. The bonus is capped, identical for
UrgentandHigh, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. - Never map the value across. A reporter selecting
Urgentdoes not make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact.
Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on who assessed the issue rather than on who was technically able to click.
Assess board Priority at boarding time, from the issue as it stands. The reporter's value is one input among several, weighted as above.
Repository & Project Boards
-
Base Branches — three branches, three distinct roles. Target the one matching the work; never open a PR against
main.Branch Role PRs target it? Publishes to v2/mainDevelop. All active v2 work lands here. Yes — every v2 PR nothing directly; reaches npm via mainmainRelease. The repo's default branch; holds the latest released v2. Not a development branch. No — it only receives milestone merges from v2/mainlatestv1/mainMaintenance. The deprecated v1 line, security fixes only, no active development. Yes — every v1 PR, directly v1-latest, published straight from this branchSo v2 flows
feature branch → v2/main → (milestone) main → npm latest, while v1 is flat:feature branch → v1/main → npm v1-latest, with no merge intomainat any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does not need to be forward-ported to reach users on v1 (npx @modelcontextprotocol/inspector@v1-latest). -
Project Boards:
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here)
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes)
Both boards start new items in
Incoming. A card only leaves Incoming when a maintainer has looked at it and approved the work — that is what Todo means on either board. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud).
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 the field ids are stable. The option ids are NOT stable — they are regenerated whenever a single-select field's option list is edited (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with:
# Swap "Status" for "Priority" to fetch the other field's options.
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 |
| Priority field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 |
Status option IDs (--single-select-option-id) — last verified 2026-08-01.
| Status | Option ID |
|---|---|
| Incoming | 721a3d4c |
| Todo | fbdaf21e |
| In Progress | 195df262 |
| In Review | 159c8a02 |
| Done | 259d6aab |
Use Incoming for newly filed, untriaged work, Todo once a maintainer has approved it and it's ready to pick up, In Progress for general active work (regardless of surface), In Review once a PR is open, and Done on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it.
Priority option IDs (--single-select-option-id) — last verified 2026-08-01. Derive the level with the rubric in Setting issue priority; don't eyeball it.
| Priority | Option ID | Rubric total |
|---|---|---|
| Urgent | 79628723 | 12+ |
| High | 0a877460 | 9–11 |
| Medium | da944a9c | 6–8 |
| Low | d67ac7ce | ≤5 |
⚠️ Never add, rename, or remove an option on a single-select board field (Status or Priority) 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 that field's value on every card on the board (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) and invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference:
- Add or rename an option in the GitHub web UI (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on other options. ⚠️ Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so. Before deleting any option, snapshot the board (see recovery below).
- 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.ProjectV2SingleSelectFieldOptionInput.idis an optionalString, so a mixed list works: echo theidfor every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshotgh project item-list … --format jsonbefore and after and diff, don't just spot-check.Both the
IncomingStatus option and the Urgent/High/Medium/LowPriorityoptions were added this way (#1891), with the before/after diff confirming all 264 cards kept their 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 the references in the recipes below and the merge step above).Always snapshot before touching a field's options
One command, and it is the difference between a five-minute restore and reconstructing statuses by inference:
gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-snapshot.jsonRecovering from a deleted option
This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the
Donecolumn, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical.The recipe below is written for a deleted Status option — it reads
.statusand writes the Status field id. For a deleted Priority option it is the same three steps with two substitutions: read.priorityinstead of.status(gh project item-list --format jsonexposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field idPVTSSF_lADOCt2Azc4BJVxtzg5iJE4instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged.# 1. Which cards lost their value, and what did they hold? gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-broken.json jq -r '[.items[]|select(.status==null)|.id]' board-broken.json > lost-ids.json jq -r --slurpfile L lost-ids.json '($L[0]) as $lost | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' board-snapshot.json # 2. Recreate the option, echoing every surviving option's id (see above). # NOTE: the recreated option gets a NEW id — the deleted one never comes back. # 3. Re-apply it to the orphaned cards. for id in $(jq -r '.[]' lost-ids.json); do gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \ --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id <NEW_OPTION_ID> sleep 0.4 doneStep 1's grouping is the safety check: confirm the orphaned set is exactly the cards that held the deleted option, so you don't overwrite a card someone legitimately moved in the meantime. And because the recreated option carries a new id, the table above and every reference to it must be updated in the same change —
grepthe old id across the repo. TheDoneid has been248a3910and is now259d6aabfor exactly this reason.
# 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 full one-liner for a new issue — add it, then set Status and Priority (both are required; here Incoming + Medium):
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
# Status → Incoming
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c
# Priority → Medium
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c
Each item-edit sets one field, so setting both takes two calls — there is no combined form.
For an issue already on the board (moving an existing card, e.g. to In Review when its PR opens, or re-scoring its Priority), look its item id up by issue number instead of re-adding it. Keep --limit above the board's item count (~265 as of 2026-08-01) — past it item-list truncates silently, select matches nothing, and item-edit --id "" fails with an opaque node-resolution error rather than saying the limit was too low:
# --limit must stay above the board's item count (~265 today) — past it the
# list truncates silently and item-edit fails with an opaque node-resolution error.
ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \
--jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02
V1 board (#11) gh recipes
The v1 line takes security fixes only, so this board sees little traffic — but a v1 issue still gets a card, and it starts in Incoming like a v2 one. Board #11 is a separate org project with its own ids; none of the #28 ids above work here.
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BA5sz |
| Status field ID | PVTSSF_lADOCt2Azc4BA5szzgzkS-g |
Status option IDs — last verified 2026-08-01.
| Status | Option ID |
|---|---|
| Incoming | 831820cf |
| Todo | f75ad846 |
| In Progress | 47fc9ee4 |
| In Review | 0439b2bf |
| Done | 98236657 |
There is no Priority field on this board — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist.
# Add a v1 issue to board #11 and put it in Incoming.
ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id 831820cf
The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to this board too — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the Incoming addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event.
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/ - Run launcher tests with
npm run testfromclients/launcher/ - Run the root tooling's own tests with
npm run test:scriptsfrom the root —node --test "scripts/**/*.test.mjs", node's built-in runner (the root has no vitest harness by design). A newscripts/*.mjshelper with pure logic gets a sibling*.test.mjs; keep the filename*.test.mjs, sincenode --testsilently skips a file its glob misses and still exits 0 (verify:typecheck-coverageguards against exactly that). - The test tiers, shallowest first: unit (
test, per client) → web integration (test:integration, real transports/servers) → out-of-process (clients/cli/__tests__/e2e.test.ts, spawns the built binary) → smokes through the built launcher (npm run smoke) → Storybook play functions (test:storybook) → the published-tarball check (npm run pack:verify, local/release only — needs network).validateruns the per-clienttestscripts — so web unit plus cli's out-of-processe2e.test.ts(it's part of cli'stest), but not web's integration project, which runs inside thecoveragegate. Everything fromsmokerightward isnpm run cionly, and is described under Mandatory pre-push gate. - 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 now covers all ofsrc/**, React surface included — the former interim exclusion of the Ink components,App.tsx, andhooks/was lifted in #1501. Components mount throughink-testing-librarywith theink-scroll-view/ink-formpassthrough doubles in__tests__/helpers/,App.tsxmounts against a controllable mock of the@inspector/coresurface, and keypresses are driven through stdin. The only coverage exclusion left inclients/tui/vitest.config.tsissrc/tui-servers.ts— a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured incore/via the web suite;tui-servers.test.tsstill exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic underclients/tui/src, React or not, is held to the gate automatically.
- 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.- The above is web only. The Node clients (cli, tui, launcher) keep all their tests in a top-level
__tests__/dir, not beside their source — theirtsconfig.jsonexcludes**/*.test.*and theirtsconfig.test.jsonincludes__tests__/**/*(plus, for launcher, its rootvitest.config.ts), so a co-locatedsrc/**/*.test.*lands in no tsconfig project and failsnpm run verify:typecheck-coverage(#1791). Put a new cli/tui/launcher test under__tests__/.
- 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), thenverify:typecheck-coverage(the #1791 guard — asserts every tracked.ts/.tsx/.mts/.ctsin each gated Node client, plus the non-client first-party TS likecore/andtest-servers/src, lands in a tsconfig project), thentest:scripts(the guard's own parser unit tests,node --test), 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 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
__tests__dirs are typechecked too (#1791). The src-onlytsconfig.jsonexcludes**/*.test.*, so each of cli, tui, and launcher carries atsconfig.test.json— extending the build config,noEmit, including__tests__/**/*(only the tests root the project; tsc pulls in thesrcthey import, and the src-only config already validates all ofsrcwithout the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves viavitest.shared.mts. The alias set differs per client: cli's is the widest (@modelcontextprotocol/inspector-test-server→test-servers/src, the@inspector/core/*deep paths, express/vitest — cli is the only one importing the test-server package); tui's carries only the@inspector/core/*+ react/vitest redirects; launcher's has nopathsat all — it's a plainrootDir: "."sibling of the build config (whoserootDir: ./srcis what rejects the tests). Each client'stypecheckscript runs both projects (tsc -p tsconfig.json && tsc -p tsconfig.test.json) so running it standalone means the same thing everywhere (launcher'sbuildalsotscssrc, buttypecheckdoesn't rely on that). cli additionally carries@types/express(devDep) so the transitively-aliased test-server source typechecks, mirroringclients/web(cli'stsconfig.test.jsonalso namestest-servers/src/server-composable.tsexplicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client config files are typechecked too: cli's/tui's (vitest.config.ts,tsup.config.ts, tuidev.ts) are folded into each srctsconfig.json'sinclude; launcher'svitest.config.tsgoes in itstsconfig.test.jsoninstead (again therootDir: ./srcreason). Note the gate checks mock implementations and return types (typing avi.fn<T>()against a real signature keeps itsmockResolvedValue/impl in sync) but nottoHaveBeenCalledWith(...)arguments — vitest types those to accept anything regardless of the mock's type parameter.npm run verify:typecheck-coverage(scripts/verify-typecheck-coverage.mjs, run as the second step ofvalidateright afterverify:format-coverage) is the durable guard for this invariant: it runs each client'stypecheckprojects withtsc --listFilesOnly, unions them, and fails on any tracked.ts/.tsx/.mts/.ctsthat lands in no project — for every gated Node client, which it discovers from disk (eachclients/*is enrolled through itstypecheckscript's projects, or — for atsc -bclient likeclients/webwith notypecheckscript — through itstsconfig.jsonreferences), so a new client is covered without editing the guard — the typecheck analog ofverify:format-coverage, since a project only reaches the files itsincludenames plus their transitive imports, so a new top-level file (launcher especially, whose buildrootDir: ./srcrejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is wired (each client's typecheck pass is reachable from itsvalidate— itstypecheckscript for cli/tui/launcher, or a realtsc -bfor web — and the root chain runs each client'svalidate), so it can't stay green while measuring a pass nothing invokes. It asserts the same oftest:scripts— its own parser tests — on three axes: reachable from the rootvalidate, a non-empty trackedscripts/**/*.{test,spec}.*set, and every one of those files matched by a glob harvested across the scripts reachable fromtest:scripts(so a delegatingtest:scriptsstill measures correctly). The third axis exists becausenode --testsilently skips a file its glob misses and still exits 0 — a rename to*.spec.mjswould shrink the suite with a green run. Beyond the clients it also covers, deny-by-default, the first-party TS no client owns — everything tracked outsideclients/*(test-servers/src/**, the rootvitest.shared.mts, all ofcore/, and any new top-level TS location) must land in the global union of client projects (cli aliases the test-server source; web's enrolled projects includecore/). So acore*.tsxweb'sincludedoesn't reach, or an unimportedtest-servers/srcbin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file// @ts-nocheck— is owned by a different gate:@typescript-eslint/ban-ts-commentrejects it across every surface (lint:core,lint:shared, and each client'seslint .). The guard's own pure parsers (scripts/lib/npm-scripts.mjs+ the exported helpers ofverify-typecheck-coverage.mjs, whose execution is behind amain()so importing it for tests doesn't run it) are unit-tested —npm run test:scripts(node's built-innode --test, invalidate; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - 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' });
- State and effects
- NEVER reset or re-sync local state from a prop inside a
useEffect.useEffect(() => setX(prop), [prop])renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error underreact-hooks/set-state-in-effect, which the web client'seslint-plugin-react-hooksrecommended set enforces. - Use
useValueChange(value, onChange)(clients/web/src/hooks/useValueChange.ts) instead. It is React's documented "adjusting state during render" pattern: it comparesvalueagainst the previous render's withObject.isand callsonChange(next)during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does not fire on the first render — seed the dependent state withuseStateinstead. Because the comparison isObject.is, the value you pass must be referentially stable across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop. - The
onChangeyou pass runs during render, so it must be pure —setStatecalls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times. - An effect is still the right tool for genuine synchronization with an external system (DOM measurement,
requestAnimationFrame, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general.NetworkEntryshows the split: the reveal's force-open is a state update and usesuseValueChange, while itsrequestAnimationFramescroll stays auseEffect.
- NEVER reset or re-sync local state from a prop inside a
- 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 (
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.