This is an application for inspecting MCP servers. It has three incarnations —
Web, TUI, and CLI — over a shared core/.
This file holds the rules: the conventions a reviewer cites against a diff. It is loaded in full on every turn, so it stays resident and must stay complete enough to work from on its own.
The repo's procedures — multi-step recipes with commands and live IDs — live
in .claude/skills/. They are ordinary committed Markdown,
so any agent or human can read one directly; Claude Code loads them on demand and
users invoke them by name.
| Skill | Covers | How it loads |
|---|---|---|
local-dev |
Install and run each client; the @inspector/core alias; and the reasoning behind Dependency placement below — what each rule defends against and how to tell you have hit one (the rules themselves stay here) |
Model-invoked, or /local-dev |
project-structure |
Which client owns which surface, what is in core/, where a new file belongs |
Model-invoked only |
testing |
Where a test file goes, which command runs it, the tiers, clearing the coverage gate, renderWithMantine |
Model-invoked, or /testing |
issue-create |
The five-step create flow: version label, type label, milestone, board card, Status + Priority | Model-invoked, or /issue-create |
issue-triage |
The two-pass sweep of unboarded issues, the priority rubric and its score comment, the board audit | Model-invoked, or /issue-triage |
board-ops |
gh project recipes and the field/option IDs for boards #28 and #11; the option-deletion hazard and its recovery |
Model-invoked, or /board-ops |
pr-flow |
Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or /pr-flow |
pre-push-gate |
Running npm run local:gate and diagnosing a failing stage |
Model-invoked, or /pre-push-gate |
release |
Cutting a release: bump on v2/main, milestone merge, tag origin/main, publish |
/release |
test-servers |
Picking and running a showcase test server; the stale-build hazard | Model-invoked, or /test-servers |
Longer-form human documentation lives in docs/ — see the table in the
README.
inspector/
├── clients/
│ ├── web/ Vite + React + Mantine SPA with a Node backend
│ │ ├── src/ Browser app: components, hooks, lib/, utils/, theme/
│ │ ├── server/ Node-only dev/prod backend wiring
│ │ └── static/ sandbox_proxy.html — served for the MCP Apps tab
│ ├── cli/ Scriptable CLI (tsup bundle, @inspector/core alias)
│ ├── tui/ Ink + React terminal UI (tsup bundle)
│ └── launcher/ The `mcp-inspector` bin; dispatches to web/cli/tui in-process
├── core/ Shared code, consumed via the `@inspector/core` alias (no package.json)
│ ├── auth/ OAuth end to end + the per-server SecretStore backends
│ ├── client/ Install-level client config (`client.json`)
│ ├── json/ JSON/schema utilities shared by all three form builders
│ ├── logging/ Silent pino logger singleton
│ ├── mcp/ InspectorClient, transports, state stores, config import
│ ├── node/ Node-only helpers (version reader, host normalization)
│ ├── react/ React hooks over the state stores (read during render — see React instructions)
│ └── storage/ File I/O helpers for the OAuth persist backends
├── test-servers/ Composable MCP test servers + JSON configs
├── scripts/ Root build/verify tooling: install cascade, smokes, verify:* guards
├── docs/ Task-oriented guides
├── specification/ Design/build specifications
└── .claude/skills/ The procedures (see the index above)
Every file here carries a header comment explaining its own purpose and the
reasoning behind it. Read the source rather than looking for a second copy of
that reasoning in this file — a duplicated rationale is one that goes stale
silently. For the fuller map (what each core/ area owns, what each
clients/web/server/ file does, where a new file belongs), the
project-structure skill.
v2 is not an npm workspace — each client under clients/* keeps its own
package.json and node_modules. A single npm install at the repo root
is still all you need: the root postinstall cascades into every client. Node
>=22.19.0.
npm install # repo root
npm run build # web → cli → tui → launcher
cd clients/web && npm run dev # day-to-day web iteration (Vite, HMR)Fuller detail — the launcher-driven scripts, the @inspector/core alias, and the
worktree trap — is the local-dev skill.
The reasoning behind each of these, and what breaks when it is ignored, is the
local-dev skill. The rules themselves:
- Every runtime dependency
core/imports is declared in the repo-rootpackage.jsonand nowhere else. That is the MCP SDK packages (@modelcontextprotocol/client,core,server,server-legacy,ext-apps) and, since #2195, the rest of whatcore/reaches:ajv,atomically,chokidar,hono,@napi-rs/keyring,pino,proper-lockfile,react,undici,zod. So is anything reached only through root-owned code with no manifest of its own (test-servers/src,core/). The v1 SDK (@modelcontextprotocol/sdk) is not a dependency of this repo and must not become one. - A root declaration is not by itself a claim that
core/imports it.commander,open,@hono/node-server,viteand@vitejs/plugin-reactare rootdependenciesreached only from client code, for the runtime-consumption reason below: a published install resolves every externalized import from the root manifest, so a client's runtime import has to be declared there whether or notcore/also reaches it. Those need naming only in theexternallist of the client that actually imports them, not in all three. - A client declares only what that client alone consumes — its own UI stack, its bundler-inlined packages, its dev tooling.
clients/cliandclients/launchertherefore declare no runtime dependencies at all, and that is the expected steady state, not an omission: everything they run on is root-declared and resolves by walk-up from the client directory. Re-adding a root-declared package to a client manifest re-creates the second copy this rule exists to make impossible (#1896), so a missing module at runtime is a signal to check the root manifest and the client'sexternallist, never to add it back. - A package that moves to the root moves its
vitest.shared.mtspin with it. Left pointing at<client>/node_modulesa pin resolves to a directory that no longer exists — or, where a transitive copy happens to sit there (chokidarundervite,reactas a peer ofreact-domandink), to the very duplicate the pin list exists to prevent.reactandreact-domare the deliberate exception and stay pinned per client, so a client's renderer and the React it calls into come from one install; every other root-owned pin resolves from the repo root. dependenciesvsdevDependenciesfollows from who consumes it at runtime, not from where it is declared. Anythingcore/imports at runtime must be a rootdependency— the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent.- The shared toolchain is declared once, at the repo root, and in no client manifest.
eslint,@eslint/js,typescript-eslint,globals,prettier,typescript,vitest,@vitest/coverage-v8and@types/nodeare used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up —npm runputs each ancestornode_modules/.binonPATH, and Node and TypeScript walk parentnode_modules/node_modules/@typesthe same way.clients/launcherdeclares nodevDependenciesat all and itsvalidateis unchanged. A client-side declaration buys nothing and installs a second copy free to drift, asglobals(^17.7.0root /^17.4.0clients) andtypescript-eslint(^8.65.0/^8.56.1) had before #2196. These staydevDependencies— none is consumed at runtime and the tarball ships only each client'sbuild/. The boundary is used by every client, not "used by one": anything narrower stays where it is, whether one client declares it (tsx,playwright,storybook,happy-dom,ink-testing-library,vite-node, each client's own@types/*) or several do —tsupis declared in web, cli and tui, andvitein web and tui on top of the root runtimedependencythat--web --devneeds. Those are out of scope here; consolidating them is a different call with a different rationale.⚠️ Deleting the declaration does not always delete the copy, and the local copy still wins. npm auto-installs an unmet peer into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-localeslintin (eslint-plugin-react-refresh/-storybookin web,eslint-plugin-react-hooksin tui), and web's Storybook/Vitest stack drags in a localtypescriptandvitest. A hoisted transitive does the same:@types/expressputs an@types/nodein web and cli. Those copies sit nearer than the root's and take precedence. The consolidation is therefore about one declaration and one place to bump, not about a single copy on disk.⚠️ Nothing keeps the surviving copies aligned, and nothing gates them. A peer copy is at least constrained by its holder's peer range — tightly forvitest(an exact peer, hence the pin below), loosely foreslint(^9 || ^10), where the copies agree only because npm resolves the same latest in both installs. A transitive copy is constrained by nothing of ours at all, and cli's@types/node(24.13.1against the root's24.13.3) has already diverged on exactly that.verify:dep-lockstepdoes not catch either: it compares only packages that onetscprogram loads from two installs, so a strayeslint,prettierorvitestbinary is outside its candidate set entirely, and the cli@types/nodedifference goes unreported because no one program sees both copies. Check a tool copy by hand —npm exec -- which eslintfrom the client — when you change what a client declares.⚠️ vitest,@vitest/coverage-v8and web's@vitest/browser-playwrightare pinned exactly, and move together.@vitest/browser-playwrightdeclares an exact peer onvitest, so it — not the root range — decides whichvitestweb installs. Left to float, the root resolves a newer patch and web's tests then run on onevitestwhile loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exactprettierpin (#1790) exists for.
- A root-declared package that
core/imports at runtime must also be named in all three bundlerexternallists (clients/{cli,tui}/tsup.config.ts,clients/web/tsup.runner.config.ts), since which client reaches it is a function of whatcore/imports rather than of what the client's own code names.npm run verify:bundle-externalsenforces this against the built output. - A dependency that renders React components must be bundled into the client that uses it (
noExternal) and declared only there — an externalized one resolves its ownreactand splits the tree.inkis the single exemption, on cost, and it is only safe while the rootreactrange stays open to the whole major (^19.0.0). - One version per install-crossing dependency. When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's
devDependencies, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one.npm run verify:dep-lockstepenforces this. - Pin a transitive dependency with an
overridesentry, not withnpm audit fix— which "resolves" an advisory with no upward escape by silently downgrading.
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.
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/main |
Develop. All active v2 work lands here. | Yes — every v2 PR | nothing directly; reaches npm via main |
main |
Release. The repo's default branch; holds the latest released v2. Not a development branch. | No — it only receives milestone merges from v2/main |
latest |
v1/main |
Maintenance. The deprecated v1 line, security fixes only, no active development. | Yes — every v1 PR, directly | v1-latest, published straight from this branch |
So v2 flows feature branch → v2/main → (milestone) main → npm latest, while v1
is flat: feature branch → v1/main → npm v1-latest, with no merge into main at
any point. The two lines publish independently under separate dist-tags, so a v1
fix does not need forward-porting.
- Repo: https://github.com/modelcontextprotocol/inspector.git
- Project boards: v2 → #28 (active), v1 → #11 (security fixes only)
A corollary for branching: cut feature branches from v2/main, never
from a milestone-merge branch — the latter carries release-only commits that will
show up in your PR's diff. The version bump rides the same flow and is made on
v2/main; see the release skill.
- 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 the appropriate README.md files with the details.
- When adding new commands, dependencies, or architectural patterns, update the relevant sections of the appropriate README.md files as well.
- When rules for implementation and testing change, update this file, AGENTS.md.
- When a procedure changes, update the skill that owns it — not this file. Rules live here; recipes live in
.claude/skills/. Two copies of a board ID or a command sequence is strictly worse than one, because the stale copy is indistinguishable from the live one.
Skills are conditional — a skill's body loads only when it is invoked — so a skill that stops being reachable loses behavior silently. Four rules keep that from happening:
npm run verify:skillsmust pass. It runs insidevalidate(and so inlocal:gateand in CI). It parses eachSKILL.md's frontmatter the way Claude Code does and fails on anything that would strip the metadata — most importantly malformed YAML, which loads the body with an empty description, so/skill-namestill works and a manual spot check passes while the skill can never auto-fire again. An unquoted colon in a description is enough — and so is an unquoted#, which YAML reads as a comment and which truncates the description silently from that point on rather than emptying it.board-opsshipped that way:Covers board #28 (v2) and board #11 (v1), their node/field/option IDs, and the option-deletion hazardwas cut at#28, so 90 characters — including both board numbers — were absent from the listing while every check stayed green, because a truncated description is still a non-empty one. Quote any description containing#or:. It also runsclaude plugin validate— the authoritative schema — when the installed CLI is exactly the pinned version, and otherwise says so and moves on. That best-effort hand-off keepsvalidatefast and offline, but it also means it usually does not run — so the authoritative check gets a guaranteed step of its own,npm run verify:skills:cli, inlocal:gateand in CI. It resolves the CLI rather than hoping for one: an installed CLI only when it matches the pin exactly, otherwise the pinned package vianpx -y. Exact rather than a floor, because a newer local CLI is a different schema from CI's — accepting it would let the samelocal:gatedisagree across machines, which is what a pin exists to prevent. Both tiers run the same script, so they cannot drift either.⚠️ Frontmatter is only read when the opening---is the file's first line — no BOM, no leading blank line.- Every skill declares its invocation mode explicitly.
disable-model-invocationis required on every skill (true= reachable only by name,false= the model may fire it). A skill that is background knowledge rather than an action also setsuser-invocable: false. Default tofalse.truewas the original default here, on the argument that a procedure with side effects would be typed as/nameanyway — but invoking a skill has no side effects, it loads instructions, and the premise is false for anything a user asks for in prose. "Create a PR for #2163" is how that work actually starts, and undertruethe model cannot reach the skill at all: it is absent from the listing and the Skill tool refuses it. The costs are asymmetric — a spurious load costs ~250 characters, a missed one costs a wrong base branch or an unsigned commit — and the budget is not tight (nine of the ten are model-invoked today and total ~3.2k of 4k). Reservetruefor a procedure that is genuinely only ever started deliberately —releaseis the only one left, because nobody cuts a release by implication.⚠️ Atrueskill cannot be reached by another skill either. If a model-invocable skill says "see/board-ops", that pointer is a dead end for the model unlessboard-opsis model-invocable too.⚠️ Flipping is not free, and the listing budget is not what costs. Going from three model-invoked skills to nine measurably lowered the trigger rate of the ones already there:project-structurefell from 100% to 0% on two cases (n=4) andtestingfrom 3/5 to 2/5, while the six new skills all measured 100% and every negative case stayed clean. So the ceiling is attention, not characters — we were at 2.8k of a 4k budget throughout that experiment (it is ~3.2k now; the point is that nothing was near the cap). Adding a skill therefore has a cost paid by the existing ones, which onlyskills:evalcan see. Re-run the full eval after any flip or description edit, not just the changed skill's own cases. How to write a description that fires, and cases that measure it, isdocs/skill-authoring.md— the case shapes that work, the ones that can never pass, and the probe-then-measure loop. The lever that works is the description's shape. Leading with the actions and then enumerating concrete situations ("Use when … ; when … ; when …") beats a noun-phrase list of contents: it tookpre-push-gatefrom 3/5 to 5/5 andtesting's three cases from 25/50/25% to 100% each (n=4), displacing nothing else.⚠️ pathsis not a free win. It looks like the deterministic option, and it does gate loading to matching files — but measured against thetestingskill's own eval cases, adding it roughly halved the rate at which the same skill fired from a conversational prompt (0–50% withpaths, 33–100% without). It also cannot be measured: a prompt-only eval can never exercise a path trigger, so shippingpathsmeans shipping an untestable claim. Reach for it only when the skill is useless outside those files, and say in the PR that you accepted that trade. - A model-invoked skill carries committed eval cases at
evals/evals.json— positives and negatives. Seeing a skill fire once tells you Claude found it, not that it finds it reliably, and a skill that fires on everything is a context regression that nobody notices by hand.verify:skillsrequires both kinds — and at least five positives, for breadth rather than variance: each prompt is scored on its ownpasses / RUNS, so more prompts steady nothing, they cover more of the ways someone might reach the skill and expose a description that only fires on one narrow phrasing;npm run skills:evalactually runs them (it needs theclaudeCLI and real model calls, so it is deliberately not in the gate — run it when adding a skill or editing a model-invoked description). Two things learned writing the first set, both of which make a case measure the wrong thing: a prompt whose answer is already in this file is not a trigger case — the model answers correctly without the skill, and the case reads as a miss; and a prompt naming a concrete file or mechanism ("how does the@inspector/corealias resolve?") invites aRead, which is a better answer than a skill. Good cases are "how do I / where does this go" questions whose answer is a procedure.⚠️ The gate cannot catch a description that never matches.verify:skillschecks that a skill is well-formed and that its cases exist; onlyskills:evalobserves whether it actually fires, and that cannot be gated — it spends metered model calls, its result is a hit rate rather than a verdict, and it goes red on a rate limit. So the eval is a tuning tool you run deliberately, and a case below threshold is a signal, not a build break. - Re-check the listing budget when adding a skill. Claude Code loads a
listing of every skill's name and description into context, truncates it when it
overflows, and drops the least-invoked entries first — which are exactly the
model-invoked skills that must fire on their own.
verify:skillsprints the current cost against the budget recorded inscripts/lib/skill-manifest.mjs(3,234/4,000 characters as of this writing) and fails when it is exceeded. Raise the budget deliberately, or tighten a description; each entry is capped at 1,536 characters regardless, so put the key use case first.
One asymmetry that reinforces the rules-vs-recipes split: once invoked, a skill's
content stays in the conversation — but auto-compaction re-attaches only the most
recent invocation of each, under a combined budget, so in a long session the older
ones can be dropped entirely. AGENTS.md does not degrade that way. If a
convention lived solely in a skill body it could vanish mid-session, in exactly
the long tasks where it matters most — which is why rules stay here.
.claude/skills/ directories below the working directory do
not load at startup. Keep everything in the repo-root .claude/skills/ and
let paths do the scoping.
All work is driven by items on the project board. The recipes for the flows
below are in the issue-create, issue-triage, board-ops and pr-flow
skills; the rules are here.
- Before starting work, check the board for the relevant item.
- Every board item is a real GitHub issue. No draft cards. Before creating a new issue, check the board for a matching item — never create a duplicate.
- Only issues go on a board — never PRs. A PR gets the
v2label but is tracked through its linked issue's card (viaCloses #N), not its own board item. - Label by version — every issue and every PR, no exceptions. Exactly one of
v1(work targetingv1/main, the deprecated security-fix-only line) orv2(active development; the default for anything new). There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line and is invisible to every version-filtered query. Set it at create time (gh issue create --label v2 …), never by backfilling. If the target version isn't obvious, it'sv2. - Label by type — exactly one of
bug/enhancement/documentation/chore/questionon every issue you create or triage. The version label says which line the work belongs to; the type label says what kind of work it is, and the two are independent. Don't force the binary: pressing a docs task or a dependency pin intoenhancementdegrades it to "not a bug", at which point filtering by it stops telling you anything. A PR needs no type label — it is classified through the issue it closes. - Every v2 issue you create gets a milestone. Milestones are release buckets, so pick by when the work ships. Never leave a v2 issue you filed unmilestoned pending a decision. Two exceptions, both deliberate: an issue that arrives unboarded stays unmilestoned in
Incominguntil a maintainer approves it — there, the absence of a milestone is the signal; and every milestone is a v2 release bucket, so av1issue has none to take. Say so when filing one rather than dropping it in a v2.x bucket. - Every v2 board item has a Priority. Priority is a board field, not a label, so an unboarded issue has nowhere to store it. Derive it with the rubric in the
issue-triageskill rather than asserting it. Board #11 has no Priority field; a v1 issue gets a Status and nothing else. Incoming⇔ no milestone; everything past it ⇔ milestoned — on board #28. Board #11 is exempt for the reason above: a v1 issue has no bucket to take, so its Status is set on its own and the audit's milestone checks do not apply to it. The rest of the invariant is unchanged: assigning the milestone is the approval act, so the two always go together.Todoasserts a maintainer signed off, so never park an unreviewed issue there — that erases the distinction and quietly promotes unreviewed work into the queue. An issue created through the documented flow skipsIncomingentirely, because filing it was the approval.Donemeans the work shipped. Exactly two things earn a card a place in Done: its PR merged, or it is a parent whose last sub-issue closed. Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so the card is deleted. Done is read as the record of what a milestone actually delivered; a duplicate sitting there makes that record wrong in a way nobody can detect later. Deleting a card touches the board only — the issue keeps its labels and comments and stays searchable forever.- When work begins, create a feature branch and set Status to In Progress. Branch names start with the target version segment —
v2/fix/2071-oauth-resource-metadata,v1/fix/proxy-ssrf-pin— matching the base branches themselves. - When work is complete, run
npm run formatthennpm run local:gate, sign off every commit (git commit -s— the DCO check is a hard merge gate with no partial credit), open a PR against the matching base branch withCloses #<ISSUE_NUMBER>as the body's first line, and set Status to In Review. - Attach screenshots as proof of functionality for any web-UI or TUI change. Put them in a
pr-screenshots/folder off the repo root — it is gitignored, so the images are staged for upload and never committed — and name them for what they show. ⚠️ Closing keywords only auto-link and auto-close for PRs targeting the default branch (main). A v2 PR targetsv2/main, soCloses #Nthere is only a cross-reference. On merge, manually close the issue and move the card to Done. Keep the line anyway, so the issues close if/whenv2/mainreachesmain.- If new tasks are discovered during development, create issues and add them to the board.
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 one if there is a good reason
- after making the changes, respond to each review comment with what was done (or why it was ignored)
The procedure — where a given test file goes, which command runs it, how to
diagnose a failing gate — is the testing skill. These are the rules.
- Ensure all code has corresponding tests. New code must clear ≥ 90 on all four dimensions — lines, statements, functions, and branches — per file. This gate is enforced by each client's
test:coverageacrossclients/web,clients/cli,clients/tuiandclients/launcher, and CI enforces it: a PR that drops any file below 90 on any dimension fails. - A genuinely-unreachable branch is annotated at the source, never waved through by lowering the gate. Use a justified
/* v8 ignore … -- <reason> */. Acceptable reasons: happy-dom-inherent paths (Mantine portal mount points,useMediaQueryfallbacks,typeof windowSSR guards); React StrictMode effect-replay blocks; and provably-dead defensive guards (a?? fallbackfor a value the types guarantee non-null, aSelect.onChangereceiving a value outside the allowed list). Reach for it only when the branch is genuinely impossible to exercise. - In unit tests that expect error output, suppress it from the console.
- Test placement — side-by-side by default,
src/test/only for what can't be co-located, and the Node clients are different.clients/web:<Name>.test.tsxnext to the source — components, hooks,lib/,utils/. A web-owned test living undersrc/test/instead is a bug.src/test/is for the three things that cannot be co-located: tests of the repo-rootcore/package (src/test/core/…, mirroring thecore/layout — it lives outsideclients/web/and has no harness of its own); theintegrationproject (src/test/integration/…— placement is the manifest, picked up by a folder glob, with no enumeration to keep in sync); and shared test infrastructure (renderWithMantine.tsx,setup.ts,fixtures/).clients/cli,clients/tui,clients/launcher: all tests in a top-level__tests__/, not beside their source. Theirtsconfig.jsonexcludes**/*.test.*, so a co-located test lands in no tsconfig project and failsnpm run verify:typecheck-coverage.- Root tooling: a
scripts/*.mjshelper with pure logic gets a sibling*.test.mjs. Keep that exact filename —node --testsilently skips a file its glob misses and still exits 0.
- Render React components through
renderWithMantine(src/test/renderWithMantine.tsx); do not hand-roll a bareMantineProvider, which skips the project theme and the helper's options and drifts from every other test. Pass thecolorSchemeoption to exercise a forced scheme rather than hand-rollingdefaultColorScheme. UserenderWithMantineTransitionsonly when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. - The web coverage
includeis a whitelist. It namescomponents/hooks/theme/lib/utils/serverplus the browser-consumedcore/*runtime, so a module placed outside those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions —src/App.tsxand thesrc/main.tsx/src/index.tsbootstraps — are called out in a comment on theincludearray itself.
- ALWAYS run
npm run formatbefore committing. The rootformatauto-fixescore/, the rootscripts/tooling, the root shared surface, and every client's scope in one shot.validateruns the non-fixingformat:checkand will fail in CI on any unformatted file, so run the auto-fixer first rather than lettingformat:checkcatch it. npm run local:gateis the mandatory pre-push command. It is a strict superset of.github/workflows/main.yml, so passing it locally means CI's gates will pass. Expect several minutes.npm run validateis the fast inner-loop check and is NOT an acceptable substitute. It runstest, nottest:coverage, so it does zero coverage gating, no smokes, and no Storybook tests. Skipping the gate is how a push passes every fast local check and still fails CI.- There is deliberately no
npm run ci— that name collided with thenpm cibuilt-in, which clean-installs from the lockfile and does not run this script. - What each stage covers, and why two of them are local-only, is
docs/quality-gate.md; how to diagnose a failing stage is thepre-push-gateskill.
No gate — lint, format, or typecheck — may read generated output. The gated surface is first-party source only: clients/*/src, clients/*/__tests__, clients/web/{server,.storybook}, each client's top-level configs, core/, test-servers/src, scripts/, and the root shared files. Everything a build writes is out of scope: clients/*/build (the tsup/tsc bundles), clients/web/dist (the Vite SPA), clients/web/storybook-static, clients/*/coverage, test-servers/build, core/**/{build,dist}, and any *.tsbuildinfo. Each scope states this itself — globalIgnores([...]) in every eslint.config.js, the format/format:check globs in each package.json, and a tsconfig include that names source directories rather than the package root.
Why it matters, given that these paths are all gitignored and the findings are usually warnings:
- It reports defects nobody can fix. A bundle vendors third-party code, so a rule that fires inside it names a problem in someone else's source.
clients/webshipped this for a while:buildwas missing from itsglobalIgnoreswhile its three sibling clients had it, so the client's only lint output was an unused-eslint-disablewarning from inside the vendoredundici(#2043). - A warning becomes a gate failure without warning.
reportUnusedDisableDirectivesis a warning by default and a rule promotion — or any new rule a bundled dependency happens to trip — turns it into avalidatefailure on a file nobody wrote. #1959 (enablingno-floating-promisesacross all five scopes) is exactly that kind of change. - It trains people to skim the channel. A scope whose lint is never clean has no signal left in it, and the real warning added later lands where everyone has learned to look past.
- It is wasted work on every run.
lintruns insidevalidate, the fast inner-loop check, and the web runner bundle alone is ~1.2MB of generated JS.
The two coverage guards do not catch this, and adding a third is not the fix. verify:format-coverage and verify:typecheck-coverage assert that first-party source is covered; neither asserts that generated output is excluded — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: when a build starts writing to a new location, add it to that scope's ignore list in the same change. The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig include to make a generated .d.ts resolve (import the source, or fix the build's types).
Every lint script runs with --max-warnings 0, so a warning fails validate exactly as an error does (#2085). All six scopes carry the flag — each of clients/{web,cli,tui,launcher}'s eslint ., plus the root's lint:core and lint:shared.
This exists because the gate's promise — that passing npm run local:gate locally means CI's gates pass — was kept while a real bug walked through it. react-hooks/exhaustive-deps ships at warn in the recommended set, and two useCallbacks in App.tsx omitted a non-stable refresh from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument Build output is never a gate target makes from the other direction: a channel nobody fails on is one people learn to skim.
Two consequences worth stating:
- Do not silence a finding to satisfy the gate. A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment with a one-line justification — the same standard this document sets for
v8 ignoreand forvoidon a floating promise. Widening aglobalIgnoresor dropping a rule to makelintpass is not an acceptable fix. - A rule left at
warnstill reads wrong in an editor. The flag makes severity irrelevant to the gate, not to the developer looking at a squiggle.react-hooks/exhaustive-depsis therefore set toerrorin every React scope (clients/web,clients/tui, and the root'score/react/**block) rather than relying on the CLI flag alone. Prefererrorfor any rule you actually intend to enforce.
- 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
- NEVER leave a promise floating.
@typescript-eslint/no-floating-promisesis enabled aterrorin all five ESLint scopes —clients/{web,cli,tui,launcher}and the rootcore/+ shared gate (#1959). Every promise must be awaited, returned, terminated with.catch(…), or explicitly discarded with thevoidoperator.- The class it catches is invisible at review time. A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held
client.callTool(...)promises madenpm run local:gateunpassable in #1947:disconnect()rejected them withConnection closed, the unhandled rejection failed the whole vitest run, and the chain aborted atcoverage, silently skippingverify:build-gate,smoke, and Storybook. Attributing it took a full investigation; the fix was two lines. - Prefer holding and settling the promise.
voidis an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in acatchthat surfaces the message) or the caller genuinely cannot await — a synchronoususeEffectbody, an InkuseInputkey handler, a Honostream.onAbortlistener. Say which of those it is in a one-line comment; an unexplainedvoidis a review finding. Where the callee does not own its failures, give it acatchrather than voiding the call (seehandleDisconnectinclients/tui/src/App.tsx), or terminate with.catch(…)at the call site (seeopen(url)inclients/web/server/{server,vite-hono-plugin}.ts). - In Storybook play functions,
expect(...)fromstorybook/testreturns a promise. Storybook instruments it so the interactions panel can trace each assertion, so everyexpectin a play function is awaited — as is any shared helper that wraps one (src/test/scrollAreaStoryAssertions.tsisasyncfor this reason). - The rule is type-aware, so each scope's ESLint config carries a parser project. Each client's config must name every leaf project covering its lint surface, since the parser needs a program that literally contains the file — for cli, tui, and launcher that is the two they already typecheck (
tsconfig.json+tsconfig.test.json,srcin the first and__tests__only in the second), and for web it is four (tsconfig.app.json,tsconfig.node.json,tsconfig.storybook.json,tsconfig.test.json) — web'stsconfig.jsonis a solution file withfiles: [], so naming it alone would contain nothing. Adding a leaf project to a client means adding it here too. The root config instead points attsconfig.lint.json, which exists solely to give the parser a program coveringcore/**,test-servers/src/**, andvitest.shared.mts— none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type checking for those sources stays where it was (core/throughclients/web'stsc -b,test-servers/srcthroughclients/cli's test project). It setsmoduleResolution: bundlerdeliberately —core/uses extensionless relative imports, which NodeNext fails to resolve, degrading every import toanyso the rule silently stops seeing promises at all. Itsincludemust stay a superset of the root config's type-awarefilesglobs — a file the lint block matches but the project omits fails outright with "was not found in any of the provided project(s)" rather than being checked, so widening one means widening the other (that is why theincludecarriestest-servers/src/**/*.tsx, which nothing has produced yet). Note also that a tsconfigincludedoes not expand braces —core/**/*.{ts,tsx}matches nothing; list the extensions separately. - Type-aware linting costs real time:
clients/web'seslint .went from ~8s to ~19s, andlintruns insidevalidate, the fast inner-loop check. That is the price of the guarantee; if it needs reducing later, narrowing the projects each scope loads is the lever, not dropping the rule.
- The class it catches is invisible at review time. A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held
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,errorFormat,stepUp, and the toast-id/formatter modules underutils/toasts/.) 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;authTokenreadswindow.locationandsessionStorage;protocolReplayre-issues a request through the liveInspectorClient.)
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 ~3.2k-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.
- 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 theeslint-plugin-react-hooksrecommended set enforces for the web client and — since #2192 — forcore/react/too. (clients/tuiregisters the plugin but deliberately enables onlyrules-of-hooksandexhaustive-deps, for the reason its own config states.) - 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. - Subscribing to an
@inspector/corestate store isuseSyncExternalStore, neveruseState+ a subscribinguseEffect. That second shape looks like the legitimate "synchronize with an external system" case above and is not, because it also seeds and re-seeds local state from the store prop — so it carries the same stale frame (switching servers paints the previous server's tools for one frame) plus a window where an event dispatched between the render and the effect is lost outright. Every hook incore/react/was converted away from it in #1955.- Reach for
useStoreSnapshot(store, event, read, whenAbsent)(core/react/useStoreSnapshot.ts) when the getter returns a fresh value per read — a defensive copy (getTools()is[...this.items]) or a freshly built object (getPagination()). That is nearly all of them, and the caching it adds is what keepsuseSyncExternalStorefrom looping. Call it once per value. - When a getter's value is already referentially stable, subscribe with
useSyncExternalStoredirectly and skip the helper —useListErrordoes, because its snapshot is the storedErrorinstance itself (ornull). The rule is read-during-render, not "always use the helper". - Either way
useValueChangeis not the tool here — it lives inclients/web/src, andcore/react/is consumed by the CLI and TUI too. readandwhenAbsentmust be referentially stable across renders — they are part of the snapshot's cache key.readis a function, so declare it at module scope.whenAbsentonly needs a module-scope constant when it is an object or array (NO_TOOLS,NO_PAGINATION); a primitive fallback (false,undefined,"disconnected") is already stable underObject.isand is passed inline throughout these hooks.- An unstable one fails quietly, so don't expect to be told: measured on React 19, an inline
readthrows nothing, logs nothing — not even React's "getSnapshot should be cached" dev warning, whose double-call happens within a single render where the closure is unchanged — and forces no extra render. It simply returns a fresh value every render, defeating every downstreamuseMemo/React.memo/ effect dep that keys on it. - The snapshot is cached against the store's per-event dispatch counter (
TypedEventTarget.getEventRevision), which every dispatch advances automatically — not against the snapshot's contents. That is deliberate and load-bearing: these getters return a defensive copy, so contents are the only alternative, and a contents comparison cannot see a dispatch that mutated an entry the list already holds (MessageLogStatefolding a response into its request entry does exactly that). Don't "optimize" it into a shallow compare. - Consequently the store is the source of truth and the event is only the signal — the hook re-reads the store rather than taking the event's
detail. A test that fires a store event must put the value on the store first; dispatching alone announces a change that isn't there. A hand-rolled fake must extend the realTypedEventTarget(a bareEventTargethas nogetEventRevisionand fails at runtime). - Genuinely local state accumulated from an event stream, with no getter to read it back from, stays
useState—useInspectorClient'slastErroris the one such case. Reset it on a store swap during render, not in an effect.
- Reach for
- 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 (
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.