`eslint-plugin-react-hooks` was a `clients/web` devDependency only, and each client's `eslint .` is scoped to its own directory — so the whole hook rule set had never looked at `core/react/`, a directory of nothing but React hooks. "Neither surface has JSX" was the stated reason and was never a good one: the rules judge hooks, not JSX. Add the plugin at the root and apply it in a `core/react/**` block (scoped there rather than to all of `core/**`, since the rules key off the `use` prefix), with `exhaustive-deps` promoted to `error` as `clients/web` does. Resolve the 15 findings it then reports: - `useSettingsDraft` / `useClientSettingsDraft` seed their draft during render instead of in a `targetId`/`opened`-keyed effect, so the previous target's values never reach the DOM — the same rule `clients/web` spells `useValueChange`. Their "latest value" refs move from render-time writes into an effect; every reader (the debounce timer, `flush`, `onChange`) runs after a commit, so a post-commit write is what they need. - The three async loaders keep their effects behind justified inline disables: `set-state-in-effect` follows a call into an async function without modelling the `await`, verified against a minimal hook whose only setState is in a continuation. - `useInitialConfig`'s cleanup bump keeps reading `generation.current` at teardown — the rule's "copy it into a variable" advice would restore a stale request token and let a superseded response commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
201 KiB
Inspector V2
This is an application for inspecting MCP servers. Has three incarnations, Web, TUI, and CLI.
Project Structure
v2/main/
├── clients/
│ ├── web/ # Web client (Vite + React + Mantine)
│ │ ├── src/ # Browser source (React app, hooks, components)
│ │ ├── server/ # Node-only dev/prod backend wiring:
│ │ │ # vite-hono-plugin.ts (Hono middleware on the Vite dev server),
│ │ │ # server.ts (standalone Hono prod server),
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ # app-origin-controller.ts (the DEDICATED APP ORIGIN for
│ │ # `_meta.ui.domain` — #2056. The default render hands an
│ │ # app's HTML to the proxy as `srcdoc` in a frame with no
│ │ # `allow-same-origin`, so the document has an OPAQUE origin
│ │ # and every request it makes sends `Origin: null`; no CORS /
│ │ # OAuth-callback / API-key allowlist can admit that, which is
│ │ # what the spec field exists to fix. `domain` is explicitly
│ │ # HOST-DEPENDENT and the Inspector owns no domain
│ │ # infrastructure, so it treats any non-empty value as a
│ │ # REQUEST, not an address, and answers with a real loopback
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6278).
│ │ # ONE SHARED origin, path-keyed per document — a real,
│ │ # allowlistable origin without a port or DNS name per app,
│ │ # and therefore NOT a per-app isolation boundary (two such
│ │ # apps share its localStorage/cookies). The inner frame is
│ │ # granted `allow-same-origin` on THIS path only, which is
│ │ # what makes the origin real; that is not a #1565 regression
│ │ # because the listener is on its own port, so the app stays
│ │ # cross-origin to both the proxy and the Inspector — the
│ │ # proxy refuses the grant if the URL's origin equals its own,
│ │ # and it is never reachable from the server-supplied
│ │ # `sandbox` string, which is still stripped. The browser
│ │ # hands the wrapped bytes back through the authenticated
│ │ # `POST /api/app-document` (core/mcp/remote/node/server.ts)
│ │ # because only it holds the MCP connection. EVERY failure —
│ │ # no listener, port never bound, older backend, network
│ │ # error — falls back to the srcdoc render with a warning
│ │ # rather than blanking the app),
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to
│ │ │ # 127.0.0.1 — an ADDRESS, never the name `localhost`, which
│ │ │ # listen() resolves to a SINGLE family (::1 on glibc Linux),
│ │ │ # refusing every IPv4 client — #1951; and refuses an
│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES;
│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost.
│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795),
│ │ │ # browser-externalized-builtin-gate.ts (build-gate logic that fails
│ │ │ # `vite build` on a browser-externalized Node built-in — #1769)
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends;
│ │ # mid-session recovery (challenge.ts WWW-Authenticate
│ │ # parsing — including the RFC 9728
│ │ # `resource_metadata` URL, carried on
│ │ # AuthChallenge as a STRING because the
│ │ # web client's challenge crosses the
│ │ # remote-backend boundary as JSON, and
│ │ # converted to a URL at the OAuth
│ │ # boundary by challengeResourceMetadataUrl
│ │ # (malformed values ignored, matching the
│ │ # SDK's own parser) so discovery targets
│ │ # the advertised document rather than a
│ │ # location derived from the MCP server
│ │ # URL — #2071;
│ │ # discovery.ts the authorization-server
│ │ # URL POLICY — when protected-resource
│ │ # metadata names no authorization
│ │ # server, the MCP server URL stands in,
│ │ # and its PATH is load-bearing: the
│ │ # SDK's buildDiscoveryUrls derives the
│ │ # path-scoped well-known locations from
│ │ # that pathname, so reducing it to the
│ │ # origin can only ever probe the domain
│ │ # root. getAuthorizationServerUrlCandidates
│ │ # therefore yields the path-scoped URL
│ │ # FIRST and the bare origin second, and
│ │ # discoverAuthorizationServerMetadataForServer
│ │ # walks them — the second candidate is
│ │ # what keeps a server that merely lives
│ │ # under a path while publishing metadata
│ │ # at the root working, so do not drop it
│ │ # in favour of a straight swap. EVERY
│ │ # consumer of the fallback must walk:
│ │ # discoverScopes, the CIMD probe, and
│ │ # the CLI's refreshStoredAuthToken,
│ │ # which is why the walk is also exposed
│ │ # as ...FromCandidates(candidates,
│ │ # discover) — the CLI injects its own
│ │ # discovery function as a test seam and
│ │ # needs to know WHICH candidate
│ │ # answered, since that is the base its
│ │ # token request is made against — #2110;
│ │ # revocation.ts RFC 7009 token revocation —
│ │ # the request the three clear paths send.
│ │ # TWO halves — clearAndPlanRevocation then
│ │ # executeOAuthRevocation — and the split is
│ │ # the contract. The first TAKES the stored
│ │ # state and CLEARS it in one atomic storage
│ │ # step (OAuthStorage.takeRevocationSnapshot);
│ │ # the second sends from what was taken.
│ │ # There is no separate `storage.clear()` in
│ │ # any caller, deliberately — the clear cannot
│ │ # be forgotten or reordered. Both halves of
│ │ # that matter: the requests need state the
│ │ # clear destroys, and the clear must not wait
│ │ # on the network, or a fresh authorization
│ │ # completing during a 5s request is deleted
│ │ # by a clear reasoning about the grant it
│ │ # replaced. NOT cross-process atomic — no
│ │ # OAuth-store mutation takes a lock, and
│ │ # `clear` alone always had that property.
│ │ # Names the
│ │ # REFRESH token when there is one (§2.1 asks
│ │ # the AS to invalidate the access tokens
│ │ # under the same grant, so one request
│ │ # covers both). BEST-EFFORT by construction:
│ │ # every path returns a
│ │ # TokenRevocationOutcome rather than
│ │ # throwing — no advertised endpoint means
│ │ # nothing is sent and the AS behaves exactly
│ │ # as before, and a network error / non-2xx /
│ │ # timeout is reported so the local clear
│ │ # always finishes. Opt out per server with
│ │ # `oauth.revokeOnClear`, read at CLEAR time
│ │ # rather than connect time. `lost_authorization_state`
│ │ # recovery passes `revoke: false` — it clears a
│ │ # half-finished flow to retry it, and an
│ │ # authorization that never completed has no
│ │ # grant to revoke — #2144;
│ │ # scopes.ts SEP-2350 scope union, oauthUx.ts
│ │ # shared copy, mcpAuth.ts force-reauthorization,
│ │ # issuerBinding.ts SEP-2352 callback-leg failure
│ │ # classification — separates a recoverable
│ │ # "lost authorization state" from a genuine
│ │ # cross-AS issuer mismatch — #1808;
│ │ # authorizationParams.ts per-server custom
│ │ # authorization-request parameters — the
│ │ # reserved-key list plus the merge applied in
│ │ # providers.redirectToAuthorization, the only
│ │ # seam that sees the SDK-built authorize URL;
│ │ # authorization request only, never the token
│ │ # request — #2018;
│ │ # endpointOverrides.ts per-server
│ │ # authorization/token URL overrides — a fetch
│ │ # wrapper that rewrites the discovered AS
│ │ # metadata document, the one seam SDK v2 routes
│ │ # BOTH endpoints through (neither reaches the
│ │ # OAuthClientProvider) — #1906;
│ │ # oidcDiscoveryCompat.ts workaround for
│ │ # typescript-sdk#2733: the SDK picks the
│ │ # metadata schema from the well-known
│ │ # FILENAME, so a plain OAuth 2.0 AS
│ │ # publishing RFC 8414 metadata at
│ │ # /.well-known/openid-configuration (which
│ │ # RFC 8414 §5 permits) is parsed as an
│ │ # OpenID provider document and THROWS,
│ │ # aborting discovery rather than trying the
│ │ # next candidate. The wrapper fabricates
│ │ # NOTHING — on a failed RFC 8414 candidate
│ │ # it probes the OIDC candidates and, when
│ │ # one is RFC 8414 metadata that is not a
│ │ # valid OIDC document, serves that body as
│ │ # the RFC 8414 response so the SDK picks the
│ │ # right schema. GET-only, and the well-known
│ │ # match requires a `/` boundary, so a token
│ │ # endpoint living under that prefix keeps its
│ │ # own error. Sits on the BASE fetch like
│ │ # endpointOverrides — the one seam that also
│ │ # covers the discovery the SDK runs inside the
│ │ # transport — so the substituted response
│ │ # carries COMPAT_SOURCE_HEADER naming the URL
│ │ # its body came from — #2172;
│ │ # secret-storage-info.ts browser-safe
│ │ # descriptor of WHERE a typed secret lands
│ │ # — kind/plaintext/durable plus the label,
│ │ # tone, caveat and summary helpers shared
│ │ # by the startup banner and the web UI, so
│ │ # terminal and browser cannot describe the
│ │ # same store differently — #1950)
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
│ │ │ # runner-interactive-oauth loopback callback flow);
│ │ │ # plus the SecretStore backends and their selection —
│ │ │ # secret-store.ts (KeyringSecretStore + InMemorySecretStore,
│ │ │ # the SecretStoreUnavailableError base every store's `set`
│ │ │ # throws and the routes turn into a 503, and the keychain
│ │ │ # probe), file-secret-store.ts (0600 JSON, AES-256-GCM when
│ │ │ # MCP_INSPECTOR_SECRET_KEY is set — refuses to overwrite a
│ │ │ # file it cannot decrypt rather than destroying it),
│ │ │ # file-lock.ts (withSecretFileLock: the cross-process
│ │ │ # mutual exclusion #2082 settled on — proper-lockfile,
│ │ │ # borrowed rather than hand-rolled. Read its header before
│ │ │ # citing it: it makes two LIVE Inspectors exclusive, and
│ │ │ # does NOT make stale takeover single-winner. proper-lockfile
│ │ │ # detects a takeover only on its 5s refresh tick, which an
│ │ │ # ordinary sub-second mutation never reaches, and its release
│ │ │ # is an unconditional rmdir (as is its signal-exit handler) —
│ │ │ # so withSecretFileLock passes a GUARDED options.fs, the one
│ │ │ # seam both removal paths share, refusing to delete a lock
│ │ │ # that is no longer ours (inode+birthtime). That NARROWS the
│ │ │ # window, it does not close it — still check-then-act across
│ │ │ # processes. BEST-EFFORT throughout: do not write that a
│ │ │ # compromised holder is always told, or that the winner's
│ │ │ # lock is always preserved.
│ │ │ # DEGRADES when no lock CAN be taken (read-only $HOME etc),
│ │ │ # since this store exists for boxes missing the usual
│ │ │ # mechanism; but THROWS on ELOCKED — a lock held by a live
│ │ │ # writer is evidence the lock works, not licence to bypass
│ │ │ # it — after waiting past the stale window),
│ │ │ # and
│ │ │ # secret-store-selection.ts (the POLICY: explicit
│ │ │ # MCP_INSPECTOR_SECRET_STORE wins, else probe the keychain,
│ │ │ # else fall back LOUDLY — to memory in a container with
│ │ │ # nothing mounted, to file otherwise; cached per process so
│ │ │ # the banner, /api/config and the store doing the writing
│ │ │ # cannot disagree — #1950)
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
│ ├── client/ # Install-level client config (`client.json`): browser-safe
│ │ # parse/validate (config-parse.ts) + Node load/save
│ │ # (config.ts, node-persistence.ts), the remote backend
│ │ # (remote.ts), secrets (secrets.ts), and runner.ts.
│ │ # Consumed by both App.tsx trees (web + tui); gated by
│ │ # the web coverage `include`, tests in
│ │ # clients/web/src/test/core/client/.
│ ├── json/ # JSON utilities and parameter/argument conversion
│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header`
│ │ # annotation scan/validation + mirrored-param
│ │ # derivation, used by the Tools tab — #1632;
│ │ # plus `Mcp-Param-*` header building for the
│ │ # wire, used by both `tools/call` paths — #1846;
│ │ # nullableUnion.ts: collapses the two JSON Schema
│ │ # encodings of "T or null" (`anyOf` with a null
│ │ # branch, `type: [T,"null"]`) into a plain type,
│ │ # hoisting the surviving branch's own keywords.
│ │ # Shared by BOTH form builders — web SchemaForm
│ │ # and TUI schemaToForm — since each dispatches on
│ │ # a single `type` string and would otherwise miss
│ │ # a nullable field entirely — #1928/#2015;
│ │ # rootUnion.ts: flattens the COMPOSITION keywords a
│ │ # tool's inputSchema may carry at its ROOT — `allOf`
│ │ # merged unconditionally (conjunctive, so there is
│ │ # no choice to present), a root `oneOf`/`anyOf`
│ │ # returned as the BRANCHES a picker chooses between.
│ │ # Legal since 2026-07-28 and rendered as an EMPTY
│ │ # FORM before — no picker, no fields, not even the
│ │ # raw-JSON fallback a union-typed *property* gets,
│ │ # so the tool could only be called with empty
│ │ # arguments. Read by all three clients: web
│ │ # SchemaForm (the Variant picker + the branch-change
│ │ # value pruning), TUI schemaToForm (a section per
│ │ # branch, its fields forced OPTIONAL since only one
│ │ # alternative applies — the chosen branch's
│ │ # `required` is then checked at SUBMIT
│ │ # (missingRequiredFields), since optional fields make
│ │ # the FORM satisfiable, not the call — and rendered under PREFIXED
│ │ # names behind a variant select, because ink-form
│ │ # keys values by field name across the WHOLE form,
│ │ # so two branches' `kind` would otherwise be one
│ │ # field; schemaToForm.decodeFormValues translates
│ │ # back on submit), and convertToolParameters (which
│ │ # branch's schema types a CLI --tool-arg).
│ │ # DECLINES rather than half-reads, since keywords at
│ │ # one level are CONJUNCTIVE and it computes no
│ │ # intersections: a union whose members are not ALL
│ │ # field-carrying objects (or whose member `type`
│ │ # rules objects out), a branch restating a
│ │ # constraint the root states differently, ANY member
│ │ # stating more than the merge applies (only
│ │ # type/properties/required — a `false`, a $ref, a
│ │ # nested applicator would have its constraint erased
│ │ # with the keyword, turning an UNSATISFIABLE schema
│ │ # into a fillable form; allOf members are checked
│ │ # against the ACCUMULATED merge, not the root), and a
│ │ # schema carrying BOTH oneOf and anyOf. Does not
│ │ # interpret `not` at all. Declining changes what
│ │ # RENDERS, never whether the tool takes arguments:
│ │ # declaresAnyFields counts the raw members, so an
│ │ # App tool is never auto-invoked with `{}` — #2123;
│ │ # schemaLint.ts: tool-schema PORTABILITY lint —
│ │ # constructs that are legal JSON Schema and are
│ │ # refused or mishandled by real MCP clients (a bare
│ │ # `true` in `properties`, an array-form `type`, a
│ │ # remote `$ref`, a schema carrying no constraining
│ │ # keyword). Deliberately NOT a validator: a census of
│ │ # 617 public servers found 0 that fail the SDK's own
│ │ # parse, so a conformance check reports nothing on
│ │ # real servers. Equally deliberately it has NO
│ │ # "inputSchema must be an object" rule, even though
│ │ # MCP requires that: the SDK types inputSchema with
│ │ # `type: literal("object")`, so such a tool fails
│ │ # ListToolsResultSchema and salvageListItems drops it
│ │ # before any client sees it — a rule that cannot fire
│ │ # is worse than none, because the docs then claim a
│ │ # check the tool does not perform. ONE verdict for all
│ │ # three clients — the CLI's `--strict` report (exit 6),
│ │ # the TUI's tool detail pane, the web Tools tab — so
│ │ # they cannot disagree about what is portable; the
│ │ # report TEXT is shared too — #1005)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand.
│ │ # The ONE expander for the web Resources form and
│ │ # readResourceFromTemplate (TUI); the CLI is NOT a
│ │ # consumer — it has no template form and passes an
│ │ # already-expanded --uri to readResource. Both
│ │ # consumers derive their FORM FIELDS from it too
│ │ # (clients/tui uriTemplateToForm),
│ │ # which is what makes the sharing real: a form
│ │ # submits under the names it rendered, so a mangled
│ │ # name silently drops the value at expansion. The
│ │ # SDK's UriTemplate is used only to VALIDATE; its
│ │ # expander is wrong for `{a,b}` (raw-joined),
│ │ # `{;id}` (operator missing), `{id:3}` (modifier in
│ │ # the name), `{+v}`/`{#v}` (encodeURI mangles `[`/`]`
│ │ # and double-encodes pct-triplets), and `{v}`
│ │ # (encodeURIComponent leaves `!'()*` bare).
│ │ # Requiredness is per EXPRESSION, not per variable —
│ │ # requiredGroups + hasRequiredValues. A template that
│ │ # cannot expand ({id:abc}, {}, {a,}) WITHHOLDS the read:
│ │ # tryExpandUriTemplate returns the reason as a value, and
│ │ # the panel disables Read Resource rather than sending the
│ │ # raw template — expandUriTemplate's raw-template fallback
│ │ # is for DISPLAY (the preview) only. An UNDEFINED
│ │ # variable omits its expression; one defined as ""
│ │ # expands (`x{?q}` → `x?q=`), so each FORM — not the
│ │ # expander — drops its untouched blanks via
│ │ # definedValues — #1919;
│ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks
│ │ # extension wire schemas + normalize/handle helpers,
│ │ # used by the raw-wire tasks/* channel — #1631;
│ │ # listSalvage.ts: per-item salvage for list results —
│ │ # keeps the valid entries when one is non-conforming
│ │ # instead of losing the whole list, and owns the
│ │ # shared isClientDecodeRejection predicate — #1909;
│ │ # appElicitation.ts: app-rendered form elicitation
│ │ # (#1854) — the four negotiation gates, the
│ │ # `_meta.ui.resourceUri` reader/validator, and the
│ │ # result validator. Mirrors ext-apps#733 / SEP-3118
│ │ # because the released ext-apps (1.7.5) predates it;
│ │ # the host-side renderer is supplied by the CLIENT
│ │ # (`InspectorClientOptions.appElicitation`), and
│ │ # supplying one is what advertises the nested
│ │ # `elicitation` setting — so web opts in and
│ │ # cli/tui, which cannot host an App, do not;
│ │ # subscriptionAck.ts: recognizing the modern
│ │ # `subscriptions/listen` a server answers with a
│ │ # bare graceful-closure result instead of
│ │ # acknowledging (#2097). The SDK gives that close
│ │ # NO code of its own — it is the same
│ │ # `SdkError(ConnectionClosed)` any pre-ack close
│ │ # carries — so the predicate reads the message,
│ │ # deliberately, since the alternative is refusing
│ │ # to retry a genuinely transient drop. The live
│ │ # check on that string is the integration test,
│ │ # which drives a real never-acknowledging server
│ │ # (`subscriptions-never-acknowledged-http.json`);
│ │ # an SDK that rephrases it fails there rather than
│ │ # silently reverting to the eight-retry loop. The
│ │ # status is `"never-acknowledged"`, NOT `"ended"`:
│ │ # `ended` means an EXPECTED close, and reading a
│ │ # deterministic conformance failure as one is the
│ │ # silence #2063 reported)
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known
│ │ │ # paths, strategy-agnostic merge. Pure/isomorphic;
│ │ │ # used by the web file-upload path + /api/import-source.
│ │ ├── node/ # Node stdio transport factory, plus proxyFetch.ts
│ │ │ # (the shared HTTPS_PROXY/HTTP_PROXY/NO_PROXY
│ │ │ # fetch every Node client installs as its
│ │ │ # `environment.fetch`, and the web backend as the
│ │ │ # createTransportNode default — #2067)
│ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch
│ │ │ └── node/ # Hono-based remote server backend (used by remote/ above)
│ │ └── state/ # InspectorClient state stores consumed by core/react/
│ ├── node/ # Node-only shared helpers: version.ts (readInspectorVersion,
│ │ # walks to the root package.json), hostUrl.ts (shared host
│ │ # normalization + detection — formatHostForUrl brackets IPv6,
│ │ # canonicalUrlHost canonicalizes a bind host the way a browser
│ │ # builds `Origin`, isAllInterfacesHost is the wildcard-bind
│ │ # predicate the guard is built on, isLoopbackHost gates the OAuth
│ │ # callback listener; also stripBrackets. Used across
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
│ ├── react/ # React hooks over the state stores. Every store
│ │ # subscription reads its snapshot DURING RENDER via
│ │ # useSyncExternalStore, so a store swap lands in the
│ │ # same frame and an event dispatched between render
│ │ # and subscribe is not lost (#1955). Getters that
│ │ # return a fresh value per read — a defensive copy,
│ │ # or a freshly built object — go through
│ │ # useStoreSnapshot.ts, which caches the snapshot
│ │ # against TypedEventTarget's per-event dispatch
│ │ # counter; useListError subscribes directly, its
│ │ # Error|null snapshot being stable already
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
│ │ # Tasks extension runtime + tasks/* Express interceptor
│ │ # + modern_task/modern_input_task tools — #1631)
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
│ │ # so integration tests can spawn the stdio server as a real
│ │ # subprocess via `node test-servers/build/test-server-stdio.js`.
│ └── tsconfig.json # tsc build config (NodeNext, outDir ./build).
│ # The Vite alias `@modelcontextprotocol/inspector-test-server`
│ # in clients/web/vite.config.ts points at build/index.js
│ # (not src/) so `getTestMcpServerPath()` returns a `.js` path.
│ # tsconfig.test.json keeps paths pointing at src for typecheck.
├── docs/ # Task-oriented guides (v1-to-v2-migration.md,
│ # mcp-server-configuration.md, mcp-app-review.md,
│ # launcher-config-consolidation-plan.md,
│ # images/). Linked from the root README.
├── scripts/ # Root build/verify tooling: install-clients.mjs (the
│ # postinstall cascade), the smoke-*.mjs runners,
│ # verify-build-gate / verify-format-coverage /
│ # verify-typecheck-coverage / verify-dep-lockstep,
│ # pack-and-verify.mjs, and lib/ shared helpers
│ # (tsc-program.mjs is the `tsc --listFilesOnly`
│ # measurement both coverage guards read a program
│ # through — #1965; headless-browser.mjs owns the
│ # browser the web smokes drive — SMOKE_BROWSER
│ # engine selection (deny-by-default), the launch,
│ # and the fatal-vs-benign page-diagnostics split,
│ # shared by all three smokes and pack:verify —
│ # #2086; announced-child.mjs owns the
│ # spawn-and-wait-for-a-readiness-line step, publishing
│ # the child via `onSpawn` BEFORE the wait so the
│ # caller's teardown reaches it on every throw path —
│ # the readiness timeout included, which is what used
│ # to orphan a live server holding its port — #2000;
│ # ensure-test-servers.mjs is the ONE place
│ # `test-servers/build` is produced for a script
│ # consumer, and it builds UNCONDITIONALLY (once per
│ # process per repo root): the five hand-rolled copies
│ # it replaced each returned early when the output was
│ # already on disk, so after the first run an edit to
│ # `test-servers/src` was never picked up and the smoke
│ # silently drove the previous fixture — reported not as
│ # staleness but as a product failure in whatever the
│ # smoke was checking. Unconditional emit is still not a
│ # clean, and that is the one hole it does NOT close: a
│ # DELETED src file leaves its stale `.js` behind, the
│ # existence checks pass against it, and anything still
│ # importing that module SILENTLY runs the old code — the
│ # same failure class. So `rm -rf test-servers/build`
│ # after deleting or renaming a source file; the
│ # `.tsbuildinfo` is pinned inside `build/` so that
│ # clean actually invalidates the cache — #2111;
│ # resolve-node-bin.mjs resolves a
│ # package's bin through its own package.json, so the
│ # verify/smoke scripts spawn it with `process.execPath`
│ # rather than the `npx` `.cmd` shim Node refuses to
│ # spawn shell-free on Windows. pack-and-verify.mjs is
│ # the exception — its children are `npm` itself and the
│ # installed `.bin` shim, neither resolvable that way, so
│ # it keeps a Windows shell and quotes its generated
│ # paths via win-shell-args.mjs — #1939, though its
│ # test-server build now goes through the shared helper
│ # like everyone else's). Prettier-gated via
│ # `format:check:scripts`; its own pure parsers are
│ # unit-tested by `npm run test:scripts` (node --test).
├── specification/ # Build specification
...
Development setup
v2 is not an npm workspace — each client under clients/* keeps its own package.json and node_modules (see the rationale in specification/v2_cli_tui_launcher.md). A single npm install at the repo root is still all you need: the root postinstall (scripts/install-clients.mjs) cascades npm install into clients/web, clients/cli, clients/tui, and clients/launcher.
- Fresh clone / first-time setup: run
npm installat the repo root. - After a pull that changes a client's dependencies: re-run
npm installat the root to re-sync every client (thepostinstallcascade handles it). - The cascade is dev-only: it exits early when the package is installed under
node_modules, and the published tarball ships only each client'sbuild/, so end users are unaffected. SetINSPECTOR_SKIP_CLIENT_INSTALL=1to skip it.
After installing, npm run build builds all clients. The launcher scripts (npm run web / web:dev) run the built launcher, so build first; for day-to-day web iteration use cd clients/web && npm run dev.
Dependency placement
The MCP SDK packages — @modelcontextprotocol/client, core, server, server-legacy, ext-apps — are declared in the repo-root package.json and nowhere else. Do not add them to a clients/*/package.json: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a second copy that drifts from the root's. That drift is not theoretical — it put two versions of ext-apps (1.7.4 / 1.7.5), and of the transitive v1 @modelcontextprotocol/sdk (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of client/core is exactly the failure the dedupe + server.deps.inline workaround in vitest.shared.mts exists for.
The same placement rule covers anything reached only through root-owned code that has no manifest of its own (test-servers/src, core/): declare it at the root, and alias it to the repo root in vitest.shared.mts rather than to <client>/node_modules like that file's other pins. express and yaml are the two today — both reached through test-servers/src (express by the http/oauth servers, yaml by load-config.ts).
Placement and classification are separate questions. Which section of the root manifest it goes in follows from who consumes it at runtime:
- A package
core/imports at runtime belongs in rootdependencies. The client builds bundlecore/but externalize npm packages, so a published install resolves them from the root manifest — and devDependencies are not installed for consumers, so a runtime import parked there breaks the published package while passing every local check. - A package only the tests, the test servers, or the build tooling need belongs in
devDependencies—express, added there by #1970. yamlis independenciestoday even though its only importer istest-servers/src/load-config.ts. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first.
A root-declared package that core/ imports at runtime must also be named in each client's bundler external list. tsup and Vite externalize what the client's package.json declares, and a root-only dependency is in none of them — so it gets bundled, silently, and the placement rule above is what guarantees every such package is root-only. For a CJS package inlined into an ESM bundle that is fatal rather than merely wasteful: esbuild leaves a Dynamic require of "path" is not supported shim that throws at import time, so the binary dies before it parses a flag. proper-lockfile hit exactly that in #2082; @napi-rs/keyring is listed in all three for the same reason. The three lists are clients/cli/tsup.config.ts, clients/tui/tsup.config.ts, and clients/web/tsup.runner.config.ts — add a new package to all of them, since which client reaches it is a function of what core/ imports, not of what the client's own code names.
That rule has now been broken twice, so it is enforced by a guard rather than by review. undici predates #2082 and was missed by it: it was declared in clients/cli/package.json, so tsup externalized it for the CLI and the CLI looked fine, while the web and TUI bundles silently inlined 1.05MB of it (#2067). The failure mode is slightly worse than the proper-lockfile one, because the package is import()ed lazily rather than at startup: the binary boots normally and only the proxied code path dies — with esbuild's rewritten specifier (import("./undici-HXPKCIY3.js")) meaning no user-side install can ever satisfy it, so the published clients told every proxy user to install a package that was already present and could not have helped.
npm run verify:bundle-externals (scripts/verify-bundle-externals.mjs, in npm run local:gate and the GitHub workflow) is that guard. It reads the built output, not the config — the two disagreed for four releases — and checks the union of two independently-derived candidate lists: each client's own external array, and the root manifest's dependencies. The second is what makes it cover #2067 rather than only its regression: undici was missing from the web and TUI external lists — that omission was the bug — so a guard driven by those lists alone would have inspected the inlined 1.05MB bundle and reported success. Removing a package from an external list must not remove it from scrutiny. Deriving both ways also means a newly added root dependency, or a newly externalized package, is covered without editing the guard. Detection keys off esbuild's // <path> module banners, which covers both shapes an inlining can take: a separate <pkg>-HASH.js chunk (what a dynamically import()ed CommonJS package produces, i.e. #2067) and a statically-imported package folded straight into index.js, which emits no chunk and a chunk-name check would miss. Reading banners means the guard needs unminified output, which it asserts rather than assumes — a bundle with no banners fails loudly instead of reporting clean, so turning on minify surfaces here. It fails closed on the other way it could lose sight of a bundle, too: parsing the external array keys off literal config text, so if a tsup refactor leaves it parsing nothing, that is an error rather than a vacuous pass. Two more things worth knowing:
- ⚠️ Nothing in the ordinary test tiers catches this class. Unit and integration tests run against source, where the real package is on the resolution path; the smokes run the built tree but never take the code path (for
undici, no proxy env var is set), so the lazyimport()never executes. The defect exists only in bundled output, only on a path the smokes don't walk. - ⚠️ Probing it by hand with
node -efalsely succeeds.node -e 'import("./undici-XXXX.js")'resolves fine, becausenode -eexposes a globalrequirethat satisfies esbuild'stypeof require !== "undefined"guard. Reproduce from a real.mjsfile, or you will conclude the chunk is loadable when it is not.
A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency. An externalized package resolves its own react from wherever npm placed it in the consumer's tree, and npm places a package beside a React satisfying that package's peer range — looser than ours in every case here, which is all it takes to split React. ink-form and ink-scroll-view declare ">=18", satisfied by a consumer's React 18 while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on another, and the TUI crashes on the first hook (#1952). Both are inlined by clients/tui/tsup.config.ts (noExternal) and declared only in clients/tui/package.json, where the build resolves them — declaring an inlined package at the root would just make consumers install a second, unused copy.
ink is the single exemption, and it is justified by cost, not by safety. Bundling it works but adds ~1.4MB (react-reconciler + yoga-layout, plus a createRequire banner, since inlined CJS calls require at runtime and esbuild's ESM interop rejects that without a real require in scope). Never justify an exemption by a peer range — ink briefly carried "its ">=19" peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies ">=19" while a narrower range of ours nests underneath. What actually makes the exemption safe is a different lever: the root react range stays open to the whole major (^19.0.0), so npm can dedupe our React with whatever React 19 a consumer pins and an external ink lands on the same copy the bundle uses. Narrowing it (e.g. back to ^19.2.4) silently reopens the crash for the renderer itself, which breaks TUI startup, not just its forms. clients/tui/__tests__/tsupConfig.test.ts enforces all of it: React-rendering deps inlined, each exempt package both external and root-declared, and the root range pinned to ink's peer floor.
An overrides entry is how a transitive dependency gets pinned past its parent's declared range — reach for it before npm audit fix. clients/{web,cli,tui}/package.json each override esbuild to ^0.28.2 (#2062). tsup@8.5.1 declares esbuild: ^0.27.0, and GHSA-g7r4-m6w7-qqqr covers 0.27.3 - 0.28.0 with 0.27.7 the last 0.27.x — so there is no upward escape inside tsup's range, and npm audit fix "resolves" it by silently downgrading to 0.27.2 across three installs (~700 lines of lockfile churn for a low-severity dev-only advisory; tried and reverted in #2058). The override forces the single deduped copy above the range instead, which also collapses the nested tsup/node_modules/esbuild and tsx/node_modules/esbuild copies into it. Two things this costs: it puts tsup on an esbuild major it does not declare, so npm run build for web/cli/tui — not npm audit — is the real gate on such a pin; and it is invisible to the audit once clean, so when tsup widens its range to ^0.28, drop the override rather than carrying it forever. The same file's ink-select-input entry is a different case entirely (it pins a bundled dep's transitive resolution, which npm ignores for a consumer install — see above), so don't read one as precedent for the other.
The v1 SDK (@modelcontextprotocol/sdk) is not a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a "peer": true entry pulled in by ext-apps.
Contributing
External contributions are accepted as issues, not pull requests — maintainers handle design and implementation through a prompt-driven workflow.
If you've already built a change locally, share the prompt you used and screenshots if applicable, not a diff. See CONTRIBUTING.md for the full policy.
This applies to org members with write access too, not just outside contributors. Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the repo maintainers only. Anyone else — including organization members whose write access makes it technically possible — opens a detailed issue instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff.
Issues are filed through the forms in .github/ISSUE_TEMPLATE/ — blank issues are disabled. GitHub serves the chooser from the default branch only, so a form edited here on v2/main has no effect on the live chooser until the next milestone merge into main — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, Bug report (1-bug_report.yml, auto-labels bug) and Feature request (2-feature_request.yml, auto-labels enhancement and v2); config.yml holds the chooser's contact links. A form's labels: is static — GitHub cannot map a reporter's answer to a label — which splits the two cases: the bug form could target either line, so it carries a required version-line dropdown and a maintainer applies the matching label at triage per Label by version; the feature form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares v2 statically. If v1 ever reopens to features, that static label is what has to change. There is deliberately no security template: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see SECURITY.md). When adding or changing a form, validate it against GitHub's issue-forms schema (markdown blocks take no id and no validations; checkboxes mark required per option, not under validations).
Every PR must reference an issue. No exceptions, regardless of who opens it. The PR body's first line is Closes #<ISSUE_NUMBER> (see the Issue-driven Work Style rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature.
Project Status and Direction
-
The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is published straight from the branch to the
v1-latestnpm dist-tag — v1 releases never pass throughmain, and v1 PRs therefore targetv1/maindirectly. -
The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the develop branch: work accumulates here continuously and is merged into
mainat milestone releases. -
The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the
latestnpm dist-tag.
Maintenance Rules
Keep documentation files up to date
- When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md
- When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details.
- When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well.
- When rules for implementation and testing change, update this file AGENTS.md
- Mirror review-relevant changes into
.github/copilot-instructions.md. That file is what GitHub Copilot reads when it reviews a PR, and it is a hand-maintained distillation of this one — there is no generation step and nothing detects drift, so it goes stale silently and Copilot then reviews against rules we no longer hold.- AGENTS.md remains the source of truth. Never edit
copilot-instructions.mdalone to change a rule; change it here first, then mirror. - Review-relevant means anything a reviewer would cite against a diff: the TypeScript rules, the Mantine/React conventions (including the
.withProps()rule and its exceptions), thelibvsutilssplit, test placement, the ≥90% coverage gate and thev8 ignorepolicy, therenderWithMantinerequirement, and the PR hygiene rules (Closes #N, version label). Changing any of these means updating both files in the same PR. - Not review-relevant, and deliberately absent from the mirror: the board recipes and their IDs, milestone and branch-naming mechanics, release and publishing procedure, and the project-structure tree. Copying those in would double the maintenance surface for content no reviewer cites.
- Keep it a distillation, not a copy — it is read on every review, so length has a cost. Prefer tightening the summary over pasting a section wholesale.
- AGENTS.md remains the source of truth. Never edit
Issue-driven Work Style
All work should be driven by items on the project board.
A v2 issue you create is not "created" until it is labeled
v2and typed (bug/enhancement/documentation/chore/question), given a milestone, AND on board #28 with a Status and a Priority set. Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying--label v2does not add the item to the board, and adding it to the board does not set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). Only issues go on the board — never PRs. A PR still gets thev2label, but it is tracked through its linked issue's card (viaCloses #N), not its own board item.This describes an issue created through the flow below — not every issue that appears in the repo. An issue opened by hand, in the GitHub UI, arrives with no label, no milestone, and no card. That is true of an outside reporter's (they have no board access) and of a maintainer's (write access makes the board reachable, not automatic). Either way it is normal on arrival, not a defect to fix the moment it lands; it comes into the system through triage instead. The two paths differ in exactly one thing: doing the create flow is the approval, so the issue starts in Todo with a milestone; an issue that arrives unboarded and unmilestoned has been approved by nobody, so it starts in Incoming with no milestone.
-
Before starting work, check the board for the relevant item.
-
Every board item is a real GitHub issue. Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — never create a duplicate.
-
Label by version — every issue and every PR, no exceptions. Each one carries exactly one of
v1orv2at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible.v1— work targetingv1/main(the deprecated line: security fixes only)v2— work targetingv2/main(active development; the default for anything new) Set the label at create time —gh issue create --label v2 ...,gh pr create --label v2 ...— never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. If the target version isn't obvious, it'sv2: v2 is where all new work goes, andv1is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix for released v1 behavior and it's unclear whether v2 still has the bug. Note the label is a repo tag and is not the board — see the callout above; av2issue also needs a board card with a Status and a Priority (av1one needs a Status; board #11 has no Priority field).
-
Label by type — every issue you create or triage carries exactly one of
bug/enhancement/documentation/chore/question. 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 — every issue needs both. Set it at create time (gh issue create --label v2 --label bug ...) or, for one arriving through triage, in the same pass that applies the version label.Type Use for Not for bugSomething is broken, wrong, or regressed against its intended behavior A missing capability that was never built enhancementA new capability, or extending an existing one — features, spec support, tracking issues for either A cleanup with no behavior change documentationProse deliverables — READMEs, guides, specification/docs,AGENTS.mdrulesCode that happens to need a doc update choreMaintenance with no user-facing behavior change — dependency work, build/CI tooling, refactors, cleanup Anything a user would notice questionAn open question or discussion with no agreed deliverable yet Work someone has already decided to do Don't force the binary.
bugandenhancementare the two most reached for, and pressing a docs task or a dependency pin intoenhancementdegrades it to "not a bug" — at which point filtering by it stops telling you anything. If an issue is really a migration guide, saydocumentation; if it is atsup→tsdownmigration, saychore.A PR does not need a type label — it is classified through the issue it closes, the same way it is tracked through that issue's board card.
-
Prioritize every board item. Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an unboarded one). Priority is a board field, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in Setting issue priority rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace.
-
Add the issue to the board and set Status and Priority. After creating an issue, add it to the board for its version —
v2→ board #28,v1→ board #11 — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable.- An issue you create →
Todo. You only file an issue for work you intend to happen, so filing it is approving it. It gets a milestone and lands in Todo, ready to be picked up. Work you are starting immediately goes straight to In Progress instead. - An issue that arrives unboarded and unmilestoned →
Incoming. Nobody has evaluated it yet, so it is not approved and gets no release bucket — whoever filed it. See Triaging unboarded issues. Never park an unreviewed issue in Todo — Todo asserts a maintainer signed off, and using it as the inbox erases that distinction and quietly promotes unreviewed work into the queue. - Priority is v2-only. Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else.
- An issue you create →
-
Every issue you create gets a milestone — no exceptions. Set it at create time with
gh issue create --milestone <title> ..., and place it in Todo. If the user didn't specify one, default to the current milestone: the open milestone with the nearest due date. Never leave an issue you filed unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. (An issue that arrives unboarded is the deliberate exception: it stays unmilestoned in Incoming until a maintainer approves it — there, the absence of a milestone is the signal that nobody has scheduled it yet.) Get the current milestone with:# Open milestones, soonest due date first — the first row is the current one. gh api repos/modelcontextprotocol/inspector/milestones --jq \ 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"'Milestones are release buckets (
v2.1.0,v2.2.0, …), so pick by when the work ships, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. -
When work begins, create a feature branch and set the item's Status to In Progress.
-
Branch names start with the target version segment. The first path segment must be the version whose base branch the PR targets —
v2/for work onv2/main,v1/for work onv1/main— followed by the usual type and slug:v2/ci/restore-claude-workflow,v2/fix/oauth-scope-union,v1/fix/proxy-ssrf-pin. Notci/restore-claude-workflow. This keeps the two lines legible ingit branch -aand in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (v2/main,v1/main). -
When work is complete:
- Run
npm run local:gatefrom the root — the mandatory pre-push gate (see Mandatory pre-push gate).npm run validateis the fast inner-loop check and is not a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - Sign off your commits — the DCO check is a hard merge gate. The repo runs the probot DCO app, which fails the PR unless each commit carries a
Signed-off-by: Name <email>trailer whose name and email match either the commit's author or its committer (the validator checks both, so a cherry-picker's own signoff is accepted). Its only exemptions are merge commits and bot-authored commits, which it skips; everything else counts, and there is no partial credit — one unsigned commit out of six fails the whole check. The app does expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why.-
Prevent it with
git commit -s— the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not:- ⚠️
git config format.signOff truedoes nothing here. Despite the name it only defaults the-sflag forgit format-patch;git commitnever reads it, and there is nocommit.signoffequivalent. Setting it looks like a fix and silently changes nothing. - ⚠️ A
prepare-commit-msghook works, but think before installing one. It would satisfy the check — a hook signs with your configured identity, and you are the committer, which the validator accepts. The objection is not mechanical but what it means: the trailer is a certification, and a hook makes it on your behalf for every commit, including work you merely cherry-picked or applied on someone else's behalf. Verified that the hook cannot distinguish those cases: insideprepare-commit-msg,git var GIT_AUTHOR_IDENTreturns your config identity rather than the preserved author, so it cannot even tell that it is signing for someone else's authorship. Prefer-s, where each certification is a deliberate act.
- ⚠️
-
Repairing already-pushed commits means rewriting them:
git rebase HEAD~<n> --signoff, thengit push --force-with-lease. Use--force-with-leaserather than--forceso a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual perils of rebasing. Rewriting is the only legitimate repair, and the two apparent alternatives are not. The app's empty "remediation commit" flow requiresallowRemediationCommits.individual; this repo ships no.github/dco.yml, so it runs with that disabled and the original unsigned commits keep failing. The app does also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. -
The signoff is a Developer Certificate of Origin assertion, made in your own name. It does not claim you wrote the code — the DCO expressly covers submitting work created by others that you have the right to submit — so signing off a commit you cherry-picked is legitimate. What is never acceptable is fabricating someone else's certification: don't add a trailer bearing another person's name or email. If a commit needs their signoff, they add it.
-
- Open a PR against the matching base branch (
v1/mainfor v1,v2/mainfor v2) and set the item's Status to In Review - Attach screenshots as proof of functionality. Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a
pr-screenshots/folder off the repo root, creating it if it doesn't exist. That folder is gitignored — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (tools-tab-before.png,tools-tab-after.png), notScreenshot 2026-07-31 at 14.02.11.png. - Link the PR to its issue — mandatory for every PR, from anyone. No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the repo maintainers open PRs at all (see Contributing) — everyone else files a detailed issue. The PR body's first line must be
Closes #<ISSUE_NUMBER>. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's default branch (main). Because v2 PRs targetv2/main(a non-default branch),Closes #Nthere is only a cross-reference — it will not create a hard link or close the issue on merge. (There is noghflag for manual linking —gh pr edithas no--add-issue; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - On merge of a v2 PR, manually close its issue and move the board item to Done (option id
259d6aab), since auto-close won't fire onv2/main. Keep theCloses #Nline anyway so the issues close automatically if/whenv2/mainis eventually merged tomain.
- Run
-
Donemeans the work shipped. An issue closed for any other reason is REMOVED from the board, not moved to Done. Exactly two things earn a card a place in Done:- Its PR merged, or
- it is a parent whose last sub-issue closed — the work shipped across its children rather than through one PR of its own.
Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so delete the card:
ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ --jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id') gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID"Deleting the card removes it from the board only — the issue itself is untouched, keeps its labels and comments, and stays searchable and linkable forever. Nothing is lost; the board simply stops claiming the work was delivered.
Done is read as the record of what a milestone actually delivered, so a card parked there asserts something shipped. A duplicate sitting in Done makes that record wrong in a way nobody can detect later — counting Done cards can no longer distinguish a shipped fix from a report closed as a duplicate of one. Board columns are a workflow signal; GitHub's issue list is the archive. The close reason is the machine-readable form of the same distinction:
gh issue close --reasonaccepts onlycompletedandnot planned, soduplicatemust be set through the API —gh api repos/OWNER/REPO/issues/N -X PATCH -f state=closed -f state_reason=duplicate— or via "Mark as duplicate" in the web UI, which additionally records a duplicate-of link. -
If new tasks are discovered or requested during development, create issues and add them to the board.
Triaging unboarded issues
An issue needs triage when it arrives with no board card and no milestone — regardless of who filed it. That is the whole test, and it is deliberately about state, not authorship.
It's tempting to frame this as "issues from outside reporters," but that's the wrong line. An outside reporter has no board access, so their issue necessarily lands this way — but so does a maintainer's, whenever they open one by hand instead of through the documented create flow. Write access makes the board reachable, not automatic. What puts an issue in Todo is somebody performing the approval — labeling it, milestoning it, and carding it — and an issue nobody did that for has not been approved, whoever's name is on it.
So don't check the author's permissions; check whether the work was done. Arriving unboarded and unmilestoned is a normal state, not a defect to fix at the moment it lands — the issue enters the system through triage, in two distinct passes.
"Triage new issues" means running pass 1 over every unboarded open issue, then the board audit below. Pass 2 is a human judgment call and is never done unprompted.
Pass 1 — sweep them onto the board (no approval implied)
For each open issue with no card:
- Apply the version label (
v2unless it's a fix for released v1 behavior — see Label by version) and the type label (bug/enhancement/documentation/chore/question— see Label by type). An outside reporter cannot set either, so both are applied here. - Add it to the board for that version —
v2→ #28,v1→ #11. - Set Status to
Incoming. - Set Priority with the rubric (v2 only), and record the score in a comment (see Recording the score). This is an assessment, not an approval — it's how the queue gets ordered for the maintainer who reviews it next.
- Leave the milestone unset. Nobody has committed to shipping it yet, and an empty milestone is precisely what marks it as awaiting review.
The one exception is an unboarded issue that already carries a milestone. Someone recorded the approval and only the card is missing, so don't demote it to Incoming — board it straight into Todo, keeping the milestone. Otherwise triage would silently un-approve work a maintainer had already scheduled.
Find the unboarded ones by diffing the open issues against both boards. Diffing against #28 alone is wrong: a v1 issue correctly carded on #11 is reported as unboarded and gets double-boarded (a real defect a past sweep introduced — #1929 reproduced it).
D=$(mktemp -d)
gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \
--json number,milestone > "$D/open.json"
# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues.
for P in 28 11; do
gh project item-list $P --owner modelcontextprotocol --format json --limit 700 \
| jq '[.items[] | select(.content.type=="Issue"
and .content.repository=="modelcontextprotocol/inspector")
| .content.number]'
done | jq -s 'add' > "$D/boarded.json"
# Prints the destination too: milestoned already → Todo, otherwise → Incoming.
jq -r --slurpfile b "$D/boarded.json" \
'.[] | select(.number as $n | ($b[0]|index($n))|not)
| "#\(.number)\t→ \(if .milestone then "Todo (has milestone \(.milestone.title))" else "Incoming" end)"' \
"$D/open.json"
Pass 2 — approve what should ship
A maintainer reads the Incoming column and, for each issue worth doing, assigns a milestone and moves the card to Todo (or In Progress if picking it up now). That is the whole approval gesture; see the Status recipe.
This pass is a judgment call reserved for a human — deciding what ships in which release is not something to infer from a rubric. Never promote a card out of Incoming as part of a routine sweep; "triage new issues" stops at the end of pass 1.
Issues that shouldn't ship stay in Incoming (or get closed). Incoming is the review queue, and "milestoned" is the line between reviewed and not — which is why the milestone stays off in pass 1 and why the rubric can treat a milestone as a real signal rather than a formality every issue carries.
The board audit
Sweeping in the unboarded issues is only the most visible defect class. A board drifts in several other ways that no single-issue rule catches, so finish a triage run with the audit below — every check should print 0. Each maps to a rule stated elsewhere in this document; a non-zero count means the board contradicts the rule, not that the rule needs revisiting.
| Check | Invariant | Fix |
|---|---|---|
| Double-boarded | An issue has a card on one board, the one matching its version label | Delete the wrong-board card (gh project item-delete) |
| Non-Issue items | Only issues go on a board — never PRs, never drafts | Delete the item; the PR is tracked via its issue's Closes #N |
| No Status | Every card carries a Status | Set one — Incoming if unmilestoned, else by where it actually is |
Incoming with a milestone |
Incoming ⇔ no milestone | Approval was never recorded: move to Todo, or clear the milestone |
| Past Incoming without a milestone | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming |
| Wrong board for label | v1 → #11, v2 → #28 |
Move the card to the right board |
| No version label | Every issue carries exactly one of v1/v2 |
Apply it (v2 unless it's a fix for released v1 behavior) |
| No type label | Every issue carries one of bug/enhancement/documentation/chore/question |
Classify it per Label by type |
| No Priority (#28) | Every board item is prioritized | Score it with the rubric |
| Closed, not shipped, still carded | Done means the work shipped — a card closed as duplicate/not-planned is deleted, not parked | Delete the card (gh project item-delete) |
D=$(mktemp -d); R=modelcontextprotocol/inspector
# --limit must exceed the repo's TOTAL issue count (884 as of 2026-08-05), not just the open ones —
# the last check below reads closed issues' state reasons.
gh issue list --repo $R --state all --limit 2000 \
--json number,state,stateReason,labels,milestone > "$D/i.json"
for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \
--format json --limit 700 > "$D/b$P.json"; done
jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" '
($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""),
lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M
| def own($s): [$s[].items[] | select(.content.repository==$R)];
def I($n): ($M[($n|tostring)] // null);
def ms($n): (I($n).ms // null);
def lab($n): (I($n).lab // []);
def isopen($n): (I($n).st == "OPEN");
def shipped($n): (I($n).sr == "COMPLETED");
[own($a)[] | select(.content.type=="Issue") | {n:.content.number, s:.status, p:.priority}] as $B28
| [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11
| {
"double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))],
"non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | .content.number],
"no Status": [($B28[], $B11[]) | select(.s==null) | .n],
"Incoming w/ milestone": [($B28[], $B11[]) | select(.s=="Incoming" and ms(.n)!=null) | .n],
"past Incoming, no ms": [($B28[], $B11[]) | select(.s!=null and .s!="Incoming" and .s!="Done"
and isopen(.n) and ms(.n)==null) | .n],
"v1 label on #28": [$B28[] | select(isopen(.n) and (lab(.n)|index("v1"))) | .n],
"v2 label on #11": [$B11[] | select(isopen(.n) and (lab(.n)|index("v2"))) | .n],
"open, no version label":[$o[0][] | select(.state=="OPEN")
| select(([.labels[].name]|index("v1") or index("v2"))|not) | .number],
"open, no type label": [$o[0][] | select(.state=="OPEN")
| select(([.labels[].name] | index("bug") or index("enhancement")
or index("documentation") or index("chore")
or index("question"))|not) | .number],
"#28 open, no Priority": [$B28[] | select(.p==null and isopen(.n)) | .n],
"closed unshipped, still carded":
[($B28[], $B11[]) | select(I(.n)!=null and (isopen(.n)|not)
and (shipped(.n)|not)) | .n]
} | to_entries[] | "\(.value|length)\t\(.key)\t\(.value[0:10])"'
Two things the queries must account for, both learned the hard way:
- Filter by repository. These are org projects and can hold cards from any repo in the org — board #11 currently carries one
modelcontextprotocol/serversissue. Without the.content.repositoryfilter it reads as a statusless-card defect and "fixing" it would mean editing another repo's tracking. - Only open issues have a
$Ientry. A closed issue still has a card (correctly, inDone), so a check that treats "no milestone found" as a violation must gate onopen(.n)or it flags every closed card.
Do not "fix" a Done card that is missing a milestone. Cards predating a rule are not defects to backfill in bulk — the audit exists to stop new drift, and rewriting settled history destroys the record of when a rule started being enforced.
Setting issue priority
Every issue gets a Priority on its board card, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later.
⚠️ There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under Projects → Inspector V2.
Where it appears What it is Ours? Projects → Inspector V2 → Priority The project board field on board #28 ( PVTSSF_lADOCt2Azc4BJVxtzg5iJE4). Urgent/High/Medium/Low, each option carrying its rubric band in the description.✅ Yes — this is the one this rubric sets. Fields → Priority (above Projects) A GitHub issue field, IFSS_kgDOAdAWeg. Defined at themodelcontextprotocolorg and shared by every repo in it (typescript-sdk, servers, registry, …), alongsideEffort,Start date, andTarget date. Created 2026-05-06,ORG_ONLY.❌ No. Not ours, not repo-scoped. They look identical — same name, same four option names — but nothing syncs them. Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing
Urgentin Fields andHighon the board). There is no pass-through, in either direction.Never delete the org-level field. It belongs to the whole org, so removing it would strip Priority from every other
modelcontextprotocolrepo.Don't set it either — but do read it. A value there is a reporter's opinion, not a maintainer's assessment, so it is untrusted input. It feeds the rubric as a capped +1 signal bonus and nothing more; see Trust boundary below.
Axis 1 — Severity / impact (1–5). How bad is it when it happens?
| Score | Means |
|---|---|
| 1 | Cosmetic — a typo, a misaligned control, a wording nit. |
| 2 | Minor friction with an easy workaround. |
| 3 | A real feature is broken or missing; the workaround is annoying or partial. |
| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. |
| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. |
Axis 2 — Urgency / staleness (1–5). How time-sensitive or neglected is it?
| Score | Means |
|---|---|
| 1 | No time pressure; nothing waits on it. |
| 2 | Wanted eventually. |
| 3 | Wanted this milestone, or has sat >90 days with no activity. |
| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). |
| 5 | Blocking a release, or actively hurting users on a published version right now. |
Signal indicators (bonuses, +1 each — not an axis of their own). These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone:
- Carries a
bugor security-related label - Linked to a milestone — i.e. already approved by a maintainer. Note this is the re-scoring case: an issue being scored in a pass-1 triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing.
- High engagement (many comments or reactions)
- Assigned to someone
- A sub-issue of a larger epic
- The reporter set
Fields → Priorityto Urgent or High — +1, flat, whichever of the two they picked. It does not map to a band, andUrgentearns exactly whatHighearns. See below.
Bands. Axes give 2–10 and there are six bonuses, so the total runs 2–16.
| Total | Priority | Meaning |
|---|---|---|
| 12+ | Urgent | Drop what you're doing. |
| 9–11 | High | Next up after current work. |
| 6–8 | Medium | Scheduled normally. |
| ≤5 | Low | Nice to have; may sit. |
Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands High. That's deliberate — Urgent is reserved for a severe problem that something else also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around.
Set the resulting level on the board card with the Priority recipe in the V2 board (#28) gh recipes below.
Recording the score
The board stores only the result — a card reading High with no trace of how it got there. Since the boards are private, that result is also invisible to the reporter. So when you score an issue during triage, post the arithmetic as a comment on the issue. Without it the rubric's promise — that the reasoning "survives in a form someone can argue with later" — is not kept by anything, and a later re-scoring has no way to tell a considered judgment from a guess.
Keep it to the two axes, the bonuses claimed, and the total:
gh issue comment <N> --repo modelcontextprotocol/inspector --body \
'**Triage:** Priority **Medium** (total 7)
- Severity 3 — a real feature is broken; workaround is partial
- Urgency 2 — wanted eventually, nothing blocked on it
- Bonuses: +1 `bug` label, +1 reporter set Fields → Priority to High
Board: #28, Status `Incoming` (awaiting maintainer review — no milestone yet).'
Name the bonuses you claimed rather than just summing them — the milestone bonus in particular should be conspicuously absent on a pass-1 triage, and a comment that lists it is a visible sign the approval semantics were misapplied.
Trust boundary: who can set what
The boards are private (public: false, both #28 and #11 — verified 2026-08-01). The Status and Priority a maintainer assigns are visible only to people with project access: a reporter cannot see them, cannot set them, and will never learn how their issue was scored. Board priority is a maintainers' working queue, not a published commitment.
The org-level Fields → Priority is the opposite. It renders on the public issue page and is not part of maintainer triage, so any value there is untrusted — we didn't put it there, and it carries a preference rather than an assessment.
That asymmetry is the whole reason the reporter's value earns a flat +1 and nothing more:
- It counts for something. Someone flagging their own issue is real information about how much it hurts them. Discarding it throws away a signal we'd otherwise have to infer.
- It cannot decide an outcome. The bonus is capped, identical for
UrgentandHigh, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. - Never map the value across. A reporter selecting
Urgentdoes not make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact.
Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on who assessed the issue rather than on who was technically able to click.
Assess board Priority at boarding time, from the issue as it stands. The reporter's value is one input among several, weighted as above.
Repository & Project Boards
-
Base Branches — three branches, three distinct roles. Target the one matching the work; never open a PR against
main.Branch Role PRs target it? Publishes to v2/mainDevelop. All active v2 work lands here. Yes — every v2 PR nothing directly; reaches npm via mainmainRelease. The repo's default branch; holds the latest released v2. Not a development branch. No — it only receives milestone merges from v2/mainlatestv1/mainMaintenance. The deprecated v1 line, security fixes only, no active development. Yes — every v1 PR, directly v1-latest, published straight from this branchSo v2 flows
feature branch → v2/main → (milestone) main → npm latest, while v1 is flat:feature branch → v1/main → npm v1-latest, with no merge intomainat any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does not need to be forward-ported to reach users on v1 (npx @modelcontextprotocol/inspector@v1-latest).⚠️ The version bump rides that same flow — it is made on
v2/main, never on the milestone-merge branch (#2010).npm version <x> --no-git-tag-versionlands onv2/mainbefore the milestone merge, so the bump reachesmainas part of the milestone's work; the release tag (barex.y.z, novprefix) is then applied to the resultingmaincommit. This deliberately leaves a normal in-flight interval wherev2/mainreads the version being built andmainthe one currently released — that difference is expected. The invariant it buys is that they converge when the release merge lands, sov2/mainis never left behindmainafterwards. Bumping on the milestone-merge branch instead (which is cut frommain) puts the bump only downstream ofv2/main, where nothing carries it back — that is howv2/mainsat at2.0.0through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated2.0.0 → 2.2.0diff simply because its branch was cut from a milestone-merge branch. Never close a drift by mergingmainintov2/main:mainholds the whole pre-v2 v1 history (~230 commitsv2/mainlacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's Cutting a release.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. -
Project Boards:
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here)
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes)
On both boards,
Incomingis the review queue for issues that arrived unboarded — swept in at triage with a Priority but deliberately no milestone (see Triaging unboarded issues). A card only leaves Incoming when a maintainer has looked at it and approved the work by assigning a milestone, at which time it should move to Todo — unless the maintainer has chosen to work on it, in which case it should move to In Progress. Assigning the milestone is the approval act, so the two always go together: a milestoned card sitting in Incoming is a card whose approval was never recorded, and a Todo card with no milestone claims an approval nobody made. An issue created through the documented flow skips Incoming entirely — doing that flow is approving it, so it starts milestoned in Todo. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud).
V2 board (#28) gh recipes
The board is an org project, so all commands use --owner modelcontextprotocol and the numeric project 28. The project node id and the field ids are stable. The option ids are NOT stable — they are regenerated whenever a single-select field's option list is edited (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with:
# Swap "Status" for "Priority" to fetch the other field's options.
gh project field-list 28 --owner modelcontextprotocol --format json \
| jq '.fields[] | select(.name=="Status") | .options'
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BJVxt |
| Status field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iI8c |
| Priority field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 |
Status option IDs (--single-select-option-id) — last verified 2026-08-01.
| Status | Option ID |
|---|---|
| Incoming | 721a3d4c |
| Todo | fbdaf21e |
| In Progress | 195df262 |
| In Review | 159c8a02 |
| Done | 259d6aab |
Use Incoming for an issue that arrived unboarded and is awaiting review (no milestone yet), Todo once a maintainer has approved it by assigning a milestone — including an issue you created through the documented flow, which starts here — In Progress for general active work (regardless of surface), In Review once a PR is open, and Done on merge — and only on merge (or a parent's last sub-issue closing). An issue closed for any other reason has its card deleted, because Done asserts the work shipped. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned.
Priority option IDs (--single-select-option-id) — last verified 2026-08-01. Derive the level with the rubric in Setting issue priority; don't eyeball it.
| Priority | Option ID | Rubric total |
|---|---|---|
| Urgent | 79628723 |
12+ |
| High | 0a877460 |
9–11 |
| Medium | da944a9c |
6–8 |
| Low | d67ac7ce |
≤5 |
⚠️ Never add, rename, or remove an option on a single-select board field (Status or Priority) with the
updateProjectV2FieldGraphQL mutation unless you pass every existing option'sid. That mutation does a full replace of the option list: if you resend options by name/color/description but omit theirids, GitHub deletes all existing options and mints new ones, which orphans that field's value on every card on the board (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) and invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference:
- Add or rename an option in the GitHub web UI (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on other options. ⚠️ Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so. Before deleting any option, snapshot the board (see recovery below).
- If you must script it, first
gh api graphqlthe current options with theirids, then callupdateProjectV2Fieldechoing back every existing option including itsid, appending only the new one.ProjectV2SingleSelectFieldOptionInput.idis an optionalString, so a mixed list works: echo theidfor every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshotgh project item-list … --format jsonbefore and after and diff, don't just spot-check.Both the
IncomingStatus option and the Urgent/High/Medium/LowPriorityoptions were added this way (#1891), with the before/after diff confirming all 264 cards kept their Status.
gh project item-addandgh project item-editare always safe — they set a card's value and never touch the field schema. When option ids change for any reason, re-verify and update the table above (and the references in the recipes below and the merge step above).Always snapshot before touching a field's options
One command, and it is the difference between a five-minute restore and reconstructing statuses by inference:
gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-snapshot.jsonRecovering from a deleted option
This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the
Donecolumn, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical.The recipe below is written for a deleted Status option — it reads
.statusand writes the Status field id. For a deleted Priority option it is the same three steps with two substitutions: read.priorityinstead of.status(gh project item-list --format jsonexposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field idPVTSSF_lADOCt2Azc4BJVxtzg5iJE4instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged.# 1. Which cards lost their value, and what did they hold? gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-broken.json jq -r '[.items[]|select(.status==null)|.id]' board-broken.json > lost-ids.json jq -r --slurpfile L lost-ids.json '($L[0]) as $lost | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' board-snapshot.json # 2. Recreate the option, echoing every surviving option's id (see above). # NOTE: the recreated option gets a NEW id — the deleted one never comes back. # 3. Re-apply it to the orphaned cards. for id in $(jq -r '.[]' lost-ids.json); do gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \ --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id <NEW_OPTION_ID> sleep 0.4 doneStep 1's grouping is the safety check: confirm the orphaned set is exactly the cards that held the deleted option, so you don't overwrite a card someone legitimately moved in the meantime. And because the recreated option carries a new id, the table above and every reference to it must be updated in the same change —
grepthe old id across the repo. TheDoneid has been248a3910and is now259d6aabfor exactly this reason.
# 1. Add an issue to the board — prints the item id (PVTI_…); capture it.
gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json
# 2. Set its Status (here: In Progress). Use the option id from the table above.
gh project item-edit \
--project-id PVT_kwDOCt2Azc4BJVxt \
--id <item-id-from-step-1> \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \
--single-select-option-id 195df262
The full one-liner for an issue you just created — add it, then set Status and Priority (both required). It goes to Todo, because filing it was the approval, and it already carries the milestone you passed to gh issue create:
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
# Status → Todo (an issue you filed is approved by definition)
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id fbdaf21e
# Priority → Medium
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c
For an issue swept in at triage (arrived unboarded, no milestone), the only difference is the Status option — Incoming (721a3d4c) instead of Todo — and that you do not set a milestone:
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
# Status → Incoming (awaiting maintainer review; no milestone yet)
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c
# Priority → Medium (an assessment for queue ordering, not an approval)
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c
Each item-edit sets one field, so setting both takes two calls — there is no combined form.
For an issue already on the board (moving an existing card, e.g. to In Review when its PR opens, or re-scoring its Priority), look its item id up by issue number instead of re-adding it. Keep --limit above the board's item count (~265 as of 2026-08-01) — past it item-list truncates silently, select matches nothing, and item-edit --id "" fails with an opaque node-resolution error rather than saying the limit was too low:
# --limit must stay above the board's item count (~265 today) — past it the
# list truncates silently and item-edit fails with an opaque node-resolution error.
ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \
--jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02
V1 board (#11) gh recipes
The v1 line takes security fixes only, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies: one created through the documented flow starts in Todo, one that arrived unboarded starts in Incoming awaiting review. Board #11 is a separate org project with its own ids; none of the #28 ids above work here.
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BA5sz |
| Status field ID | PVTSSF_lADOCt2Azc4BA5szzgzkS-g |
Status option IDs — last verified 2026-08-01.
| Status | Option ID |
|---|---|
| Incoming | 831820cf |
| Todo | f75ad846 |
| In Progress | 47fc9ee4 |
| In Review | 0439b2bf |
| Done | 98236657 |
There is no Priority field on this board — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist.
# Add a v1 issue to board #11. Swap the option id for the case you're in:
# 831820cf = Incoming — arrived unboarded, awaiting review (no milestone)
# f75ad846 = Todo — created through the documented flow (milestoned at create time)
ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id f75ad846
The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to this board too — same mutation, same failure mode, different ids. Note that board #11 also carries one card from another repo (modelcontextprotocol/servers), which has no Status. That is why every audit query filters on .content.repository — an org project can hold items from any repo in the org, and an unfiltered check reports that card as a statusless-card defect. It is not ours to fix. (The three inspector cards that carried no Status on 2026-08-01 predated the Incoming addition and have since been set; neither case was evidence of an orphaning event.)
Always test new or modified code
- Ensure all code has corresponding tests
- Ensure test coverage for each file is at least 90%
- In unit tests that expect error output, suppress it from the console
- Run unit tests with
npm run test(ornpm run test:watchduring development) fromclients/web/ - Run CLI tests with
npm run testfromclients/cli/(builds test-servers + CLI bin first viapretest) - Run TUI tests with
npm run testfromclients/tui/ - Run launcher tests with
npm run testfromclients/launcher/ - Run the root tooling's own tests with
npm run test:scriptsfrom the root (this is also what runs the #2146 workflow guard against.github/workflows/**) —node --test "scripts/**/*.test.mjs", node's built-in runner (the root has no vitest harness by design). A newscripts/*.mjshelper with pure logic gets a sibling*.test.mjs; keep the filename*.test.mjs, sincenode --testsilently skips a file its glob misses and still exits 0 (verify:typecheck-coverageguards against exactly that). - The test tiers, shallowest first: unit (
test, per client) → web integration (test:integration, real transports/servers) → out-of-process (clients/cli/__tests__/e2e.test.ts, spawns the built binary) → smokes through the built launcher (npm run smoke) → Storybook play functions (test:storybook) → the published-tarball check (npm run pack:verify, local/release only — needs network).validateruns the per-clienttestscripts — so web unit plus cli's out-of-processe2e.test.ts(it's part of cli'stest), but not web's integration project, which runs inside thecoveragegate. Everything fromsmokerightward is outsidevalidateand is described under Mandatory pre-push gate — note that being outsidevalidateis not the same as being local-only:smokeand the Storybook tests run in GitHub CI too,pack:verifyis in neither tier (local/release only), and the genuinely gate-only steps are the two named in the README's two-tier table. - The repo root has no aggregate
testscript — each client self-validates, so runnpm run validatefrom the root (all clients, fast) orcd clients/<name> && npm run validate(one client). Each client still exposes its owntest/test:coveragefor quick iteration. validateis fast: it runstest, nottest:coverage. The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a separate top-levelnpm run coverage(and per-clientcoverage:web/coverage:cli/coverage:tui/coverage:launcher, each delegating to that client'stest:coverage). Runnpm run coveragewhen you want to reproduce the gate locally before pushing. CI runscoverageon every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. It runs in its own job, in parallel withbuild(#2159) —coverageconsumes nothingvalidateproduces, since each client'stest:coveragebuilds whatever it needs itself, and running the two serially was 84% of a ~17m wall clock. Both publish jobs therefore gate onneeds: [build, coverage]; leaving either off would let a release publish with the coverage gate red. Because web'stest:coveragealready runs the integration project, CI has no separatetest:integrationstep — the integration paths are exercised inside the coverage gate.- Each client's
test:coverageenforces a uniform per-file gate of ≥ 90 on all four dimensions — lines, statements, functions, and branches — acrossclients/web,clients/cli,clients/tui, andclients/launcher(CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are not waved through by lowering the gate — they are annotated at the source with a justified/* v8 ignore … -- <reason> */comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points,useMediaQueryfallbacks,typeof windowSSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a?? fallbackfor a value the types guarantee non-null, or aSelect.onChangereceiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justifiedv8 ignoreonly when a branch is genuinely impossible to exercise. The web coverageinclude(inclients/web/vite.config.ts) covers the sharedcore/runtime consumed by the browser —core/mcp,core/react,core/auth,core/storage,core/logging,core/node,core/json, andcore/client(the last two folded in by #1689). When adding acore/json/*orcore/client/*module, its tests live underclients/web/src/test/core/…and are gated the same ≥90 way. - The same per-file gate is enforced for the CLI and TUI (#1484), not just web:
- CLI (
clients/cli): tests run in-process by importingrunCli()(see__tests__/helpers/cli-runner.ts) soclients/cli/srcis measured under v8 instrumentation. A thin out-of-process layer (__tests__/e2e.test.ts+scripts/smoke-cli.mjs) still spawns the built binary for the shebang/process.exitpaths;src/index.ts(binary bootstrap) is the only coverage exclusion.commanderuses.exitOverride()so a parse error throws instead of tearing down the test worker. - TUI (
clients/tui): the gate now covers all ofsrc/**, React surface included — the former interim exclusion of the Ink components,App.tsx, andhooks/was lifted in #1501. Components mount throughink-testing-librarywith theink-scroll-view/ink-formpassthrough doubles in__tests__/helpers/,App.tsxmounts against a controllable mock of the@inspector/coresurface, and keypresses are driven through stdin. The only coverage exclusion left inclients/tui/vitest.config.tsissrc/tui-servers.ts— a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured incore/via the web suite;tui-servers.test.tsstill exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic underclients/tui/src, React or not, is held to the gate automatically.
- CLI (
- Run
npm run test:integration(also fromclients/web/) for the InspectorClient + transport + auth integration suite. It runs under a separateintegrationvitest project in node env (no happy-dom) with 30s timeouts. The script buildstest-servers/first viatsc -p ../../test-servers --noCheckso the stdio MCP test server can be spawned as a real subprocess. CI does not runtest:integrationas its own step — the integration project is covered by the CIcoveragegate, whose webtest:coverageruns--project=unit --project=integration --coverage. - Test files live alongside the source as
<Name>.test.tsx(or.test.tsfor non-React modules). Integration tests live underclients/web/src/test/integration/, mirroring thecore/source layout (mcp/,mcp/node/,mcp/remote/,auth/,auth/node/,storage/). Any test file under that folder is automatically picked up by theintegrationvitest project (node env, 30s timeouts) via the folder glob invite.config.ts— placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in theunitproject (happy-dom). When adding a new test for, e.g.,core/mcp/remote/foo.ts, put it atsrc/test/integration/mcp/remote/foo.test.ts. - Test placement: side-by-side by default,
src/test/only for what can't be co-located. These look like competing conventions but aren't — the split is: tests live beside their source, except tests for the repo-rootcore/package (which lives outsideclients/web/) and shared test scaffolding — both of which live undersrc/test/, withcore/tests mirroring thecore/layout and integration tests undersrc/test/integration/.- Side-by-side (
<Name>.test.tsxnext to the source) — the default for web's ownsrc/code. Components, hooks,lib/,utils/. This is the overwhelming majority; a web-owned test living undersrc/test/instead of beside its source is a bug (fixed one such straggler,downloadFile.test.ts, in #1776). src/test/— the three things that cannot be co-located: (1) tests of the repo-rootcore/package (src/test/core/…, mirroring thecore/folder layout —core/physically lives at/coreoutsideclients/web/, is consumed via the@inspector/corealias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) theintegrationvitest project (src/test/integration/…, node env, 30s — placement is the manifest, see above); (3) shared test infrastructure (renderWithMantine.tsx,setup.ts,fixtures/,scrollAreaStoryAssertions.ts) — not tests of a source file, so nothing to sit beside.- The above is web only. The Node clients (cli, tui, launcher) keep all their tests in a top-level
__tests__/dir, not beside their source — theirtsconfig.jsonexcludes**/*.test.*and theirtsconfig.test.jsonincludes__tests__/**/*(plus, for launcher, its rootvitest.config.ts), so a co-locatedsrc/**/*.test.*lands in no tsconfig project and failsnpm run verify:typecheck-coverage(#1791). Put a new cli/tui/launcher test under__tests__/.
- Side-by-side (
- Use
renderWithMantinefromsrc/test/renderWithMantine.tsxto render components — it wraps inMantineProviderwith the project theme. It setsenv="test", which makes Mantine skip the transition's animated render — but be clear on what that does not buy you: it does not stop the timers.envis read only byTransition.mjs, at its render branch (transitionDuration === 0 || env === "test"), whileuseTransition()runs before that check (hooks cannot be conditional) and still schedules realwindow.setTimeouts on everymountedchange. Measured: opening a<Modal>throughrenderWithMantineschedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file'swindow, and React'sdispatchSetStatethen throws an uncaughtReferenceError: window is not definedthat fails the whole run — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the leaked-timer safety net insrc/test/setup.ts, which tracks every timer and clears whatever is still pending aftercleanup(); see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. Always render throughrenderWithMantine; do not hand-roll a bareMantineProviderin a test — a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Note this rule used to be justified by the leak class, which is now wrong: thesetup.tsnet is global and covers every unit test however it renders. The rule stands on consistency, not on timer safety. To exercise a forced color scheme (e.g. theuseComputedColorSchemedark branch) pass thecolorSchemeoption —renderWithMantine(ui, { colorScheme: "dark" })— instead of hand-rolling adefaultColorScheme="dark"provider (#1786). Only when a test must assert mid-flight transition state (e.g. adata-anim="out"cell during an exit crossfade) userenderWithMantineTransitions(real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does not settle a concurrent enter (a completed enter leaves no DOM signal towaitFor), so the helper automatically drains the in-flight animation after the test. The rule for using it: passsettleMsderived from the component's real animation duration — itsTransitionduration/exitDurationplus anyenterDelay/exitDelayplus rAF slack — e.g.renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })(so the window can't silently become insufficient when that duration changes); do not also usevi.useFakeTimers()in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the real clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use theunmount()the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-bodycleanup()on a still-armed tree would trip the check). The mechanism behind all three — why the drain isact-wrapped, the fake-timer hazard, theafterEach-before-cleanup()ordering and itscontainer.isConnectedself-checks, and the exportedsettleTransitions(ms)for manual mid-body settling — is documented at length on the helper inrenderWithMantine.tsx; read there before changing it.
Responding to Code Reviews
- When asked to respond to a code review of a PR,
- it is not necessary to implement all suggestions
- you are free to implement suggestions in a different way or to ignore if there is a good reason
- after making the changes, respond to each review comment with what was done (or why it was ignored)
Mandatory pre-push gate
-
ALWAYS do
npm run formatbefore committing — the rootformatauto-fixescore/(format:core), the rootscripts/tooling (format:scripts), the root "shared" surface (format:shared—test-servers/src/**,vitest.shared.mts, the rooteslint.config.js), and every client's scope in one shot. Every client format glob uses the uniform extension set*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(#1792) so a new-extension file can't slip the gate;core/stays{ts,tsx}and the shared surface{ts,tsx,mts,cts}(their surfaces can't hold the other extensions), andnpm run verify:format-coverage(the first step ofvalidate, #1792) is the backstop — it fails if any tracked source file is left uncovered by aformat:checkglob regardless of which glob was expected to catch it.validaterunsformat:check(the non-fixing variant, includingformat:check:core,format:check:scripts, andformat:check:shared) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than lettingformat:checkcatch it. -
npm run local:gateis the mandatory pre-push command — it is a strict superset of.github/workflows/main.yml(which additionally runsnpm install, done separately here):validate→coverage→verify:build-gate(the #1769 browser-externalized-builtin build gate) →verify:bundle-externals(the #2067 must-not-bundle gate) →smoke→smoke:web:firefox(the three browser-driven smokes again under Firefox — #2086; see below) →local:storybook(the Storybook play-function tests, installing Playwright chromium if needed).smoke:web:firefoxis the step with no GitHub CI counterpart, deliberately (smoke:tuiis the other, by self-skipping there). It is namedlocal:gaterather thancion purpose, and there is no alias (#2146):npm ciis a built-in that clean-installs from the lockfile and does not run this script, and a root script calledciinvited the cross-engine passes into GitHub CI.npm run cinow fails with npm's missing-script error. The canonical CI-vs-local table lives in the root README; point at it rather than restating the split in a new place, since that restating is how the two tiers kept getting blurred. It also runsnpm run coverage, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so the direction that matters holds: passing it locally means CI's gates will pass. Expect several minutes.npm run validateremains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is NOT an acceptable substitute fornpm run local:gatebefore pushing:validaterunstest, nottest:coverage, so it does zero coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -
ALWAYS do
npm run formatbefore committing, thennpm run local:gatebefore pushing. From the repo root,validaterunsverify:format-coveragefirst (the #1792 guard — asserts every tracked source file is covered by aformat:checkglob), thenverify:typecheck-coverage(the #1791 guard — asserts every tracked.ts/.tsx/.mts/.ctsin each gated Node client, plus the non-client first-party TS likecore/andtest-servers/src, lands in a tsconfig project), thenverify:dep-lockstep(the #1896 guard — asserts no dependency that reaches a singletscprogram from two installs resolves to two different versions across them), thentest:scripts(the guards' own parser unit tests,node --test), then thecore/gate (validate:core), then chains the four per-client validations (validate:web→validate:cli→validate:tui→validate:launcher); each client delegates to its ownnpm run validatein its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).validate:coreis the root-owned format + lint gate (#1689, widened in #1778 and #1767). Each client'sprettier/eslintis scoped to its own dir, so nothing reachedcore/, the rootscripts/, or the root "shared" surface before —validate:corecloses that: it runsformat:check:core(prettier --check "core/**/*.{ts,tsx}") +format:check:scripts(prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", the root build/verify tooling — #1778) +format:check:shared+lint:core(eslint "core/**/*.{ts,tsx}"via the rooteslint.config.js) +lint:shared. Usenpm run format:core/npm run format:scripts/npm run format:sharedto auto-fix (all folded into the rootformat). The shared surface (#1767) istest-servers/src/**/*.{ts,tsx,mts,cts}, the rootvitest.shared.mts, and the rooteslint.config.js— first-party code no client'seslint ./prettierreaches; it is both prettier-gated (format:check:shared) and eslint-gated (lint:shared, via a secondfilesblock in the rooteslint.config.jsscoped to Node globals). Thescripts/gate is prettier-only — the root has no eslint config for.mjs. The root carries prettier/eslint as devDependencies for this;core/is isomorphic (browser + Node globals, no JSX today — the{ts,tsx}glob future-proofs against acore/**/*.tsx).core/react/**additionally getseslint-plugin-react-hooks(#2192) — a third root devDependency, and a thirdfilesblock extendingreactHooks.configs.flat.recommendedwithexhaustive-depspromoted toerrorthe same wayclients/webpromotes it. "Neither surface has JSX" was the old reason for leaving it out and it was wrong: the hook rules judge hooks, not JSX, andcore/react/is a directory of nothing but hooks — so the identical prop-into-effect re-sync that is an error inclients/web/srcsat there unreported. Keep the block scoped tocore/react/**rather than all ofcore/**: the rules key off theuse-prefix naming convention, so a non-ReactuseFoohelper elsewhere incore/would be judged as a hook it is not. The rooteslint.config.jshonors an_-prefix as the intentionally-unused marker (argsIgnorePattern/varsIgnorePattern/caughtErrorsIgnorePattern: '^_'). prettier is pinned to an exact version (not a caret) in all fivepackage.jsons (#1790) so the gate's verdict can't shift with an in-range patch bump.- cli and tui now typecheck their
src(#1689). Theirbuild/testrun through esbuild (no type check), so each has atypecheckscript folded intovalidate. Theirtsconfig.jsonmatchesclients/web/tsconfig.app.json's module/lib resolution options — DOM lib,moduleResolution: bundler, and nonoUncheckedIndexedAccess(web's app config does not extendtsconfig.base, so re-enabling it would surfacecore/issues web never gates) — so the importedcore/sources are validated the same way web validates them. It does not mirror web's extra strictness flags (noUnusedLocals,verbatimModuleSyntax, ES2023 target, …), so cli/tui's ownsrcis checked slightly more loosely than web's.core/itself still typechecks through web'stsc -b. - The
__tests__dirs are typechecked too (#1791). The src-onlytsconfig.jsonexcludes**/*.test.*, so each of cli, tui, and launcher carries atsconfig.test.json— extending the build config,noEmit, including__tests__/**/*(only the tests root the project; tsc pulls in thesrcthey import, and the src-only config already validates all ofsrcwithout the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves viavitest.shared.mts. The alias set differs per client: cli's is the widest (@modelcontextprotocol/inspector-test-server→test-servers/src, the@inspector/core/*deep paths, express/vitest — cli is the only one importing the test-server package); tui's carries only the@inspector/core/*+ react/vitest redirects; launcher's has nopathsat all — it's a plainrootDir: "."sibling of the build config (whoserootDir: ./srcis what rejects the tests). Each client'stypecheckscript runs both projects (tsc -p tsconfig.json && tsc -p tsconfig.test.json) so running it standalone means the same thing everywhere (launcher'sbuildalsotscssrc, buttypecheckdoesn't rely on that). cli additionally carries@types/express(devDep) so the transitively-aliased test-server source typechecks, mirroringclients/web(cli'stsconfig.test.jsonalso namestest-servers/src/server-composable.tsexplicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client config files are typechecked too: cli's/tui's (vitest.config.ts,tsup.config.ts, tuidev.ts) are folded into each srctsconfig.json'sinclude; launcher'svitest.config.tsgoes in itstsconfig.test.jsoninstead (again therootDir: ./srcreason). Note the gate checks mock implementations and return types (typing avi.fn<T>()against a real signature keeps itsmockResolvedValue/impl in sync) but nottoHaveBeenCalledWith(...)arguments — vitest types those to accept anything regardless of the mock's type parameter.npm run verify:typecheck-coverage(scripts/verify-typecheck-coverage.mjs, run as the second step ofvalidateright afterverify:format-coverage) is the durable guard for this invariant: it runs each client'stypecheckprojects withtsc --listFilesOnly, unions them, and fails on any tracked.ts/.tsx/.mts/.ctsthat lands in no project — for every gated Node client, which it discovers from disk (eachclients/*is enrolled through itstypecheckscript's projects, or — for atsc -bclient likeclients/webwith notypecheckscript — through itstsconfig.jsonreferences), so a new client is covered without editing the guard — the typecheck analog ofverify:format-coverage, since a project only reaches the files itsincludenames plus their transitive imports, so a new top-level file (launcher especially, whose buildrootDir: ./srcrejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is wired (each client's typecheck pass is reachable from itsvalidate— itstypecheckscript for cli/tui/launcher, or a realtsc -bfor web — and the root chain runs each client'svalidate), so it can't stay green while measuring a pass nothing invokes. It asserts the same oftest:scripts— its own parser tests — on three axes: reachable from the rootvalidate, a non-empty trackedscripts/**/*.{test,spec}.*set, and every one of those files matched by a glob harvested across the scripts reachable fromtest:scripts(so a delegatingtest:scriptsstill measures correctly). The third axis exists becausenode --testsilently skips a file its glob misses and still exits 0 — a rename to*.spec.mjswould shrink the suite with a green run. Beyond the clients it also covers, deny-by-default, the first-party TS no client owns — everything tracked outsideclients/*(test-servers/src/**, the rootvitest.shared.mts, all ofcore/, and any new top-level TS location) must land in the global union of client projects (cli aliases the test-server source; web's enrolled projects includecore/). So acore*.tsxweb'sincludedoesn't reach, or an unimportedtest-servers/srcbin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file// @ts-nocheck— is owned by a different gate:@typescript-eslint/ban-ts-commentrejects it across every surface (lint:core,lint:shared, and each client'seslint .). The guard's own pure parsers (scripts/lib/npm-scripts.mjs+ the exported helpers ofverify-typecheck-coverage.mjs, whose execution is behind amain()so importing it for tests doesn't run it) are unit-tested —npm run test:scripts(node's built-innode --test, invalidate; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance:
clients/cli's out-of-processe2e.test.tsspawns the built binary, so itstestbuilds first viapretest(test-servers:build && build). To avoid building it twice,clients/cli'svalidatefolds that in — it isformat:check && lint && typecheck && testwith no separatebuildstep (the other clients, whose tests don't spawn their bundle, keep an explicitbuild).validate:web/validate:tui/validate:launcherare the uniformformat:check && lint && (typecheck &&) build && test. (#1778, #1789, #1792)clients/web'sformat/format:checkcoverssrc,server,.storybook, and its top-level configs (the uniform*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}glob —vite.config.ts,tsup.runner.config.ts,eslint.config.js, …), not justsrc, so the Node backend, Storybook config, and Vite/build config are prettier-gated too;clients/launcher's coverssrc,__tests__,scripts, and its top-level configs (the*.top-level glob is non-recursive, so each nested dir —.storybook,scripts— is named explicitly). Theverify:format-coverageguard (#1792) enforces that this coverage stays complete. - One version per install-crossing dependency (#1896). Because v2 is not a workspace, the root and each
clients/*carry their ownnode_modules— and a client'stsconfig.test.jsoncompiles first-party sources that live outside the client (test-servers/src,core/), which resolve their dependencies from the root install while the client's own sources resolve from the client install. So the same package can appear twice in onetscprogram. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod4.3.6(root) against zod4.4.3(clients/web) madeclients/web'stsc -bexhaust the 4GB default heap outright viaTS2589 Type instantiation is excessively deep, because every@modelcontextprotocol/*schema is built out of zod generics. Raising the heap with--max-old-space-sizehides this class rather than fixing it — align the versions instead.npm run verify:dep-lockstep(scripts/verify-dep-lockstep.mjs, invalidate) is the durable guard: it derives the candidate set from what actually enters eachtscprogram (#1965) — every client tsconfig project is listed withtsc --listFilesOnlythrough the sharedscripts/lib/tsc-program.mjshelper (the same machineryverify:typecheck-coveragereads a program through, so the two guards can't disagree about what one contains), each resolvednode_modulesfile is mapped to its owning install and package, and a package reaching one program from two installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named directly, which could not see one whose declarations arrive only through another package's.d.ts:@modelcontextprotocol/sdkis never written in first-party code (the shared sources import the split@modelcontextprotocol/client|core|…) yet 16 of its.d.tsfiles land inclients/web's test program, so a second copy underclients/web/node_modulesskewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly not a candidate, and TypeScript's package-identity redirect collapses two copies at the same name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions still come from the committed lockfiles, but from the entry for the exact install path the program resolved (node_modules/zod,node_modules/a/node_modules/zod) rather than from the install's top-level entry: a nested duplicate inside one install is still not a candidate on its own — folding it onto its outermost install is what keeps the set small — but once a program has loaded one, pricing it from a top-level entry that may be absent or differently versioned would let a real pair pass (Copilot). Only the installs that actually met in one program are compared, so a third install's copy that no program loads beside another is not evidence of anything. Any co-occurrence whose copies disagree fails, deny-by-default; a resolved copy with no lockfile entry fails too, since the tree and the lockfile then disagree about what was loaded. The escape hatch isTOLERATED_SKEWin that file, an allowlist of names (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is empty today — the four names it used to carry (react,hono,jose,@modelcontextprotocol/ext-apps) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. Being listed is not a blanket exemption — it tolerates skew only within a major version, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. When bumping a dependency that the shared sources pull in, bump it in every install that declares it — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested viatest:scripts(its own, plusscripts/lib/tsc-program.test.mjsfor the shared derivation), and it vouches — withverify:format-coverageandverify:typecheck-coverage— that its siblings are still wired intovalidate. It runs the sametsc --listFilesOnlypass its sibling does, in its own process, costing ~14s: the listing is deliberately not cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. npm run verify:bundle-externalsruns afterverify:build-gateinnpm run local:gate. It asserts that no package a client declaresexternalwas inlined into that client's bundle anyway — the must-not-bundle invariant above (#2067). It readsclients/*/build, so it needs a build to have run (validateprovides one).
-
npm run coverageis the per-file ≥90 gate and is now part ofnpm run local:gate— never treat it as optional before a push. It supersedes the old standalonetest:integrationstep: web'stest:coverageruns theunitandintegrationprojects under v8 instrumentation, socoverageboth enforces the ≥90 gate and exercises the same web integration paths CI covers. -
smokeis NOT part ofvalidate— it is included innpm run local:gate, as issmoke:web:firefox(the same browser-driven smokes again under Firefox, #2086 — CI does not run that step). It runssmoke:launcher(--helpdispatch) plus the prodsmoke:cli/smoke:tui/smoke:web/smoke:web:browser/smoke:web:app/smoke:web:elicit, and contains no build commands — it assumes the cli/tui/launcher bundles already exist (a fullvalidatebuilds them;smoke:webbuildsclients/web/diston demand). CI'sbuildjob runsvalidate, thenverify:build-gate(the #1769 build gate — see below), thenverify:bundle-externals(the #2067 must-not-bundle gate), thensmoke(with Playwright chromium installed just before it, sincesmoke:web:browserneeds it), then the Storybook play-function tests. Thecoveragegate (which also covers the web integration project) runs in a parallel job rather than in that chain (#2159), so GitHub CI is the same set of gates asnpm run local:gateminus its two local-only steps, and no longer in the same order. -
smoke:launcher(scripts/smoke-launcher.mjs) runs the built launcher with--help,--cli --help, and--tui --help, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. -
smoke:web(scripts/smoke-web.mjs) startsmcp-inspector --web(prod, no--dev) against the builtclients/web/distand assertsGET /serves the SPA (HTTP 200) with the injected__INSPECTOR_API_TOKEN__. Prod--webserves fromclients/web/dist, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (build:client=vite build) on first launch, or exits with an actionable error if that build can't run (seeclients/web/server/ensure-web-build.tsand the launcher README).--devruns Vite directly and never needsdist. It shares the spawn/readiness/teardown helper (scripts/lib/prod-web-server.mjs) withsmoke:web:browser,smoke:web:appandsmoke:web:elicit, so the four can't drift.Every web smoke runs against a throwaway catalog (#1977). The helper mints a temp dir per run and passes it as
MCP_CATALOG_PATH; without it the web backend falls back to the developer's real~/.mcp-inspector/mcp.json, which made these smokes both destructive and non-deterministic —smoke:web:app's deep link persists adeep-linkserver row, so a second run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a freshHOMEper run made every CI run look like a first run. This matchessmoke:cli/smoke:tui, which have always driven a temp--catalog. Only the catalog is redirected — other per-user state under~/.mcp-inspector(OAuth tokens,storage/) stays shared, because isolating it means redirectingHOMEwholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses both halves ofscripts/lib/child-cleanup.mjs(stopChildto await the child's exit, thenremoveSafeto delete the dir) — a barekill()only delivers the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makesstop()async, so every caller mustawaitit (and a caller's ownfail()/shutdown()becomes async in turn, or execution runs past the intended exit). The isolation contract is unit-tested inscripts/lib/prod-web-server.test.mjsviatest:scripts, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog or stopped cleaning up:createTempCatalogandbuildWebServerEnvcover which catalog the server gets, andteardownWebServer— extracted fromstop()for exactly this reason — is driven against a stand-in child process so the teardown asserts on the real directory rather than a spy. -
smoke:web:browser(scripts/smoke-web-browser.mjs, #1615) goes a step further thansmoke:web: it boots the same prod--webserver and then actually runs the bundle in a headless browser (Playwright — already aclients/webdevDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with no uncaught error.smoke:webonly checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a class (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first call into it (e.g.fs.readFileSync(...)during a transitive module's init) throws aTypeErrorthat aborts app mount. A synchronous such throw firespageerror; its async twin (the sameTypeErrorviaawait/.then(), or a failed dynamic import) is logged on the console channel asUncaught (in promise) …/Failed to fetch dynamically imported module— the smoke hard-fails on both. The literalModule "…" has been externalizedtext is, in a prod build, a build-time warning (vite build/npm run build), not a runtime message, so the browser never sees it (undernpm run devVite's stub is instead aProxythatconsole.warns that string at runtime); and an externalized import that is never called ships a harmless{}and is invisible here by design. Every other console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warningconsole.errordoesn't flake CI). Playwright is resolved viacreateRequirebased atclients/web/package.json— a bareimport("playwright")would resolve relative toscripts/, not the cwd, so it can't be reached that way (it only appears to work when an ancestornode_modulescarries playwright, and fails in CI, which has none). That resolution and the launch itself now live inscripts/lib/headless-browser.mjs, shared by all three web smokes andpack:verify, which is also whereSMOKE_BROWSERpicks the engine (#2086, below). The npm script no longer prefixescd clients/web && npx playwright install chromium:scripts/install-smoke-browser.mjsfetches the engine's binary instead, because the engine is now a variable (a${VAR:-default}expansion does not expand under Windows'cmd.exe, so it would try to install a browser literally named that) and becausenpxis a.cmdshim there that a shell-free spawn cannot start — the #1939 problem, solved the #1939 way, viaresolveNodeBin. -
smoke:web:app(scripts/smoke-web-app.mjs, #1859) goes one step further again:smoke:web:browserstops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod--webserver, spawns themcp-app-http.jsoncomposable test server (themcp_app_demotool + itsmcp_app_demo_widgetUI resource), and drives the whole connect → open app → widget ready chain through a single deep-link navigate (?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>). The assertion is thedata-app-status="ready"contract from clients/web/README.md — the renderer reportsreadyonly once the widget has loaded inside the sandbox iframe and firednotifications/initializedback through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on stderr (console.errorinserver-composable.ts), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is not the config's, becausecreateTestServerHttpresolves throughfindAvailablePort(), which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming3130. Both mechanics now live inscripts/lib/announced-child.mjsrather than in the smoke, so the failure path is testable: this smoke's happy path always receives the announcement, so nothing it could assert would prove that a child alive through the 30s timeout is still reachable byshutdown()— the case that orphaned a live server (#2000). The helper publishes the child viaonSpawnbefore waiting, andscripts/lib/announced-child.test.mjsdrives realnode -echildren (not spies) to assert it is published, still alive when the throw lands, and actually killable. Same reasonteardownWebServerwas extracted fromprod-web-server.mjs'sstop(). Scope note: this runs against the repo build tree like every other smoke, so it would not have caught #1859 itself (a packaging failure — the file is always present in-repo);pack:verifyowns that dimension, and since #2003 it owns it properly — it drives this smoke's first phase against the installed tarball rather than only asserting the proxy page exists. The flow therefore lives inscripts/lib/mcp-app-flow.mjs(deep-link construction and the staged assertions; the browser launch and the page-diagnostics split live one level down, inscripts/lib/headless-browser.mjs) and is shared, not copied: two copies of the deep-link shape would drift, and a drifted link fails as a silent timeout rather than a mismatch. Keep both consumers —pack:verifyis network-bound and local/release-only, so it does not run innpm run local:gate, where this smoke is the only thing exercising the App path at all, and the_meta.ui.domainphase (#2056) is this smoke's alone: the dedicated app origin is served by the same runner the packaging checks already cover, so driving it a second time from the tarball would cost a browser launch for no new packaging signal. It does carry a cheap structural pre-check that the proxy page exists at the pathsandbox-controller.tsresolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. -
smoke:web:elicit(scripts/smoke-web-elicitation.mjs, #1854) is the app-rendered elicitation counterpart ofsmoke:web:app: same prod--webserver and the same deep-link connect, but it then callsapp_choose_optionfrom the Tools tab, waits for[data-testid="app-elicitation"][data-app-elicitation-status="ready"], clicks a choice inside the sandboxed app (twoframeLocatorhops — the trusted sandbox proxy, then the untrusted app), and asserts the app's standardElicitResultcomes back in the tool result, i.e. that it reached the server rather than merely the host. It then repeats againstapp-elicitation-native-http.json— the same tool and app on a server that never advertised the nested MCP Appselicitationcapability — and asserts the native elicitation dialog takes it and no app modal is rendered. That second half is the more valuable one: the failure this feature can produce is not "the app doesn't render" but "an app renders when it should not have been offered one", which strands every user of a server that never opted in. SetSMOKE_SCREENSHOT_DIRto capture PNGs of the three states (used for PR proof); unset, it asserts only. Two mechanics worth knowing: the main-view tabs are a MantineSegmentedControl, so there is norole="tab"— the clickable element is the siblinglabel[for$="-Tools"]; and the prompt string also appears in the (hidden) Protocol-tab payload, so the fallback assertion is scoped to the dialog rather than a bare text lookup. -
smoke:web:tabs(scripts/smoke-web-tabs.mjs, #2148) is the connected-flow smoke for the core tabs — the surfaces the App smokes never touch. Before it, the web client's only connected smoke was the Apps tab, so Tools, Resources, and Prompts — what nearly every session actually uses — were unexercised through the prod bundle: unit tests render a screen with fixture props, Storybook does the same in a browser, and the integration project drivesInspectorClientwith no browser at all, so a regression living only in the built bundle or in the prod Hono backend's wiring of a tab passed all of them. It boots the same prod--webserver, connects once through the shared deep link againstweb-tabs-http.json, and drives three tabs on one browser launch (launch and boot dominate the cost;npm run local:gateis already several minutes): Tools runslist_itemsand asserts thestructuredContentsection rendered (#1908 — a panel showing only thecontent[]summary looks right while dropping the payload), Resources readseventswith both list counts asserted separately (resources/listandresources/templates/listare different calls and either can fail alone), and Prompts selects the argument-lesssimple_prompt, which auto-fetches. Three things worth knowing: every assertion is on adata-*attribute, never on copy — the contract is tabulated in clients/web/README.md and each attribute name is pinned by a screen unit test, so a rename fails loudly there rather than as an opaque 45s timeout here; it is Chromium-only and deliberately not inENGINE_SMOKES, because the engine tiers exist for the MCP Apps sandbox's genuinely divergent primitives and these tabs are ordinary React and Mantine, so a second Firefox pass would double the cost for coverage that is not engine-sensitive; and Network / Protocol / Logs / Tasks are not covered — each needs its own config and more waiting, and that remains a stated gap rather than an oversight. -
The deep-link connect step is shared, not copied (
scripts/lib/deep-link-connect.mjs, #2148). Every browser smoke that touches a server begins with the same?serverUrl=…&autoConnect=<token>link, thedata-deeplinktoken gate, and thedata-status="connected"wait;driveAppFlowhad it inline andsmoke:web:elicitcarried a second hand-rolled copy. Sharing matters more than the line count here:parseDeepLinkignores a link it cannot validate rather than reporting one, so a drifted copy fails as an opaque connect timeout in whichever smoke was not updated, not as a mismatch. Its failure branches are unit-tested against a stand-inpage(deep-link-connect.test.mjs), since every consumer only ever drives the happy path. -
The build gate for the browser-externalized-builtin class (#1769) is the earlier, more complete companion to
smoke:web:browser. A Vite plugin inclients/web/vite.config.ts(logic inclients/web/server/browser-externalized-builtin-gate.ts, unit-tested) turns Vite 8's browser-externalization warning (Module "node:*" has been externalized for browser compatibility) into a hardvite builderror, so a Node built-in in the browser graph now failsnpm run build/validateinstead of shipping a{}stub. This catches both the called-at-init case (whichsmoke:web:browseralso catches, but later/at runtime) and the imported-but-never-called case (the{}stub that is invisible to the runtime smoke "by design" — see above). Because rolldown swallows a throw insideonLog(the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin records the warning inonLogand re-throws inbuildEnd. There is no stable logcode, so the gate keys off the documented message phrasing;npm run verify:build-gate(scripts/verify-build-gate.mjs, innpm run local:gateand the GitHub workflow) runs a real build with anode:fsprobe forced intosrc/main.tsxand asserts the build fails via the gate — the only check that catches the message phrasing drifting in a future Vite bump and silently disabling the gate. The gate is scoped tovite build(apply: 'build') — nevervite devor the vitest projects — and to the browser (client) environment (applyToEnvironment), so a future SSR/node environment built from this config isn't failed for a legitimatenode:*import; the Node runner build (tsup,build:runner) is a separate config where built-ins are legitimate.smoke:web:browserstays as the runtime backstop for crashes the build can't reason about. -
smoke:cli(scripts/smoke-cli.mjs) drivesmcp-inspector --clithrough the built launcher against the bundled stdio test server via a temp--catalog: it assertstools/listreturns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only--configerrors without seeding, and--catalog+--configis rejected.smoke:tui(scripts/smoke-tui.mjs) launchesmcp-inspector --tui --catalog <temp>under a pseudoterminal, asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then waitsSMOKE_TUI_SURVIVE_MS(default 2s) and asserts it is still running before SIGTERMing it — a shallow boot/render/survival check, not full interaction. Both rebuildtest-servers/buildon every run — once per process, whether or not it already exists (#2111): presence is not freshness, and a smoke driving a stale fixture reports a product failure rather than a staleness one.The survival half is the assertion, and it is the whole point (#2147). This smoke used to settle OK the instant the marker appeared and never look at the child again. It was spawning the TUI with
stdio: ["ignore", …], i.e. stdin on/dev/null; Ink mountsuseInput,useInputneeds raw mode, and raw mode is a property of the file descriptor — so the TUI painted one frame and died ~40ms later withRaw mode is not supported on the current process.stdin. The smoke won that ~40ms race and printed OK. What it asserted was first paint, while its own header claimed "boots and renders without crashing", and it had never once verified a running TUI on any machine —stdio: ["ignore", …]makes the child's stdin/dev/nullwhether or not the developer's own terminal is a TTY. The race also cut both ways: anexitprocessed first failed the run, which reads as flake rather than as the standing defect it was.Two pieces, and the order matters.
scripts/lib/pty.mjsgives the child a real terminal viascript(1)— no dependency, and the flavors are not interchangeable (BSD takes argv, util-linux takes one shell string plus-e, busybox that string without-e), so the invocation is built and unit-tested rather than written inline; a platform with noscript(1)(Windows) skips rather than running without one, since that is a guaranteed failure and not a weaker check.scripts/lib/render-smoke.mjsis the fix: it requires the child to outlive its first frame. Without that, a future harness change reintroduces the same false green and nothing notices — which is why it lives in a moduletest:scriptsdrives against a stub that paints the marker and immediately exits (the old harness passes that stub; this one must not), rather than in a smoke that only ever walks its own happy path. Do not collapse the survival wait back into "resolve on the marker" to save two seconds.smoke:tuiis still local-only: it self-skips whenprocess.env.CIis set — now by decision rather than by capability. The PTY removes the technical blocker the skip used to cite; whether it joins GitHub CI is a separate call, and #2146 deliberately keeps the other local-only smokes out. So run it (vianpm run smoke) on your own machine before pushing: a local-only gate is exactly where a false green is least likely to be caught by anything else. -
Storybook play-function tests (
clients/webtest:storybook) run in headless Chromium via@vitest/browser-playwright(~10s). They are part ofnpm run local:gate(vialocal:storybook, which installs Playwright chromium first); kept out ofvalidatebecause they need the browser binary and are slower than the unit suite.
The web smokes are engine-parameterized; Firefox is in the pre-push gate, not CI (#2086)
smoke:web:browser, smoke:web:app and smoke:web:elicit support Chromium, Firefox and WebKit; SMOKE_BROWSER picks one, unset means chromium.
npm run smoke:web:firefox # part of `npm run local:gate`
SMOKE_BROWSER=webkit npm run smoke:web:engine # any engine, on demand
SMOKE_BROWSER=firefox npm run smoke:web:app # one smoke, one engine
Firefox runs in the local pre-push gate (npm run local:gate) and NOT in GitHub CI. That split is the whole design — see the gate bullet below. ENGINE_SMOKES in scripts/run-engine-smokes.mjs is the single list of which smokes are engine-sensitive, and every tier reads it — the default Chromium run inside npm run smoke, the Firefox pass in the gate, and the on-demand command. Add a fourth there and all three pick it up. A tier that enumerated the smokes itself would silently run fewer and still pass, so run-engine-smokes.test.mjs asserts that none of them does. smoke:web:firefox passes the engine as an argument rather than relying on the ambient SMOKE_BROWSER, so the gate cannot be silently redirected to another engine by a stray variable — verified, an explicit argument beats it.
Everything about the selection lives in scripts/lib/headless-browser.mjs — resolveBrowserName, loadBrowser, and the attachPageDiagnostics / FATAL_CONSOLE split the smokes had each hand-rolled. Reach for it rather than launching Playwright in a new script.
- The engine matters for one surface, and it is the MCP Apps sandbox. Most of the web client is React and Mantine, where a second engine buys little. The sandbox is built out of the primitives that genuinely diverge: a CSP
<meta>injected as the first<head>child of asrcdocdocument, a nested sandboxed iframe, aPermissions-Policyallowattribute, andpostMessageorigin discipline across those two frames. - No other tier can substitute, so don't propose one.
sandbox-csp.test.tsasserts which policy string is built — environment-independent by construction, and it would pass identically on an engine that ignores<meta>CSP entirely. And no Storybook story reaches the sandbox at all: all three App stories (AppRenderer,AppsScreen,AppElicitationHost) point the iframe at adata:placeholder and hand the renderer a mock bridge, sosandbox-csp.tsis imported by exactly two things in the tree — its own test andcreateAppBridgeFactory.ts. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. - An unrecognized
SMOKE_BROWSERis an error, never a fallback. Falling back to Chromium would report a green Chromium run under a job labelledwebkit— coverage claimed but not run, which is worse than none. - A launch failure names the engine that failed and a remedy that works from where the caller actually is:
npm run smoke:web:<engine>, ornpx playwright install --with-deps <engine>run fromclients/web. Namingchromiumwhile WebKit was missing would send the reader to install a browser they already have — and naming a bare root-levelnpx playwrightwould send them to a version that is not the pinned one, which can install a browser revision the pinned Playwright still cannot launch. - Firefox is gated locally, not in CI, and that asymmetry is deliberate. A GitHub Actions job was trialled and removed. It was cheap — ~2 minutes, parallel with the 15-minute
buildjob, so zero added wall-clock — but across a dozen runs Firefox never once disagreed with Chromium, so it spent runner minutes on every push, from every branch, carrying a real flake surface (playwright install --with-depsrunsapt-get update, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result already in hand. Moving it intonpm run local:gatekeeps the check where a human is about to push a change they can still reason about, and pays for it once rather than on every push. Don't re-add the CI job on the argument that cross-engine coverage is good in principle — that argument was accepted, and the pre-push gate is what serves it. Re-add it on evidence: a cross-engine regression that reachedv2/mainbecause someone skipped the gate. - A guard enforces the split, so it cannot be undone by accident (#2146).
scripts/lib/workflow-gate.mjsis run bynpm run test:scriptsover every file under.github/workflows/, and fails the suite on: an invocation of alocal:*script (the prefix means local-only, so a future sibling is covered without editing the guard); an invocation of a non-Chromium engine pass, derived fromSUPPORTED_BROWSERSso a fourth engine extends the guard rather than opening a hole in it; an invocation ofsmoke:web:engine, whose engine comes from the environment and so cannot be read off the workflow at all; orSMOKE_BROWSERset to anything but a literalchromium— the back door that would redirect an otherwise innocentnpm run smoke, and one that includes setting it to nothing, since present-and-empty is rejected rather than defaulted, andcontainer.envas much as a job's ownenv. That rule is kind-aware — anenv:key (matched case-insensitively, since Windows environment variables are), or an assignment in arun:— so a script printing the variable's name is not read as setting it; and therun:half covers POSIX, cmd, the>> $GITHUB_ENVappend and PowerShell (the default shell on a Windows runner), not justNAME=value. A name the guard cannot read counts as forbidden too — a workflow expression (smoke:web:${{ matrix.browser }}) or a shell variable the script expands itself (smoke:web:$ENGINE,%ENGINE%,local:$TASK): a matrix the guard cannot read may well containfirefox, so an unreadable name is not waved through. Its limit is worth knowing — a fully opaquenpm run ${{ matrix.script }}names no family at all and is not detectable. It deliberately does not forbidnpm run smoke,smoke:web:chromium, orsmoke:tui: those belong in CI and are there today, andsmoke:tuiself-skips underprocess.env.CIon its own. It parses the workflow (via the root-declaredyamlpackage) and descends the schema's executable paths — workflowenv/defaults.run.shell, jobenv/container.env/with/defaults.run.shell, steprun/shell/env/with(a customshell:is a command template Actions runs around the script), with YAML aliases resolved — rather than matching pairs by key name anywhere in the tree, which would scan a workflow input legitimately namedenvand fail the gate on documentation of the gate. So it reads only what executes — so ordinary metadata such as a stepname:explaining the rule is not reported as an invocation of it (Copilot); comments are skipped for the same reason, and because a guard that made the workflow undocumentable would be traded for the wrong thing. That scoping is a stated limit rather than a hidden one: an execution vector outside those three positions is not seen. - This makes
npm run local:gatea strict superset of GitHub CI rather than a mirror of it. That was already the direction (smoke:tuiself-skips on CI and runs only locally); Firefox is the second such step. The invariant that still holds, and the one that matters, is the useful direction: passingnpm run local:gatelocally means CI's gates will pass. pack:verifystays pinned to Chromium, passing it explicitly rather than readingSMOKE_BROWSER. It is a packaging check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed.- WebKit fails the two App smokes, for reasons nobody has identified — the failure stage even varies between runs. The investigation was dropped as not worth the effort once it became clear it does not reproduce in real Safari, and that an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either. Treat a WebKit failure as unexplained rather than as a defect until someone has actually looked.
- ⚠️ A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee. That failure was first written up as a Safari bug on the strength of a Playwright-WebKit run alone; a manual check in Safari did not reproduce it, and neither did an isolated repro of the mechanism it was blamed on. The caveat below cuts both ways, and only one direction of it was applied. Reproduce in the real browser before claiming user impact in one — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives.
- ⚠️ Playwright's WebKit is a WebKit build, not Safari. Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works.
Build output is never a gate target
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).
Lint has no warning tier
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.
Typescript instructions
- Use TypeScript for all new code
- Follow TypeScript best practices and coding standards
- NEVER use 'any' as a type
- NEVER suppress error types (e.g., no-unused-vars, no-explicit-any) in the typescript or eslint configuration as a way of satisfying the linter or compiler.
- AVOID double casts (
as unknown as T). They erase all type safety and usually signal that the real type is being worked around. Prefer a type guard, a narrower singleascast, or fixing the underlying type. When a double cast is genuinely unavoidable (e.g. a documented gap in a third-party type, or bridging a structurally-identical shape TS can't relate), it MUST carry an inline comment justifying why it is safe and why no better option exists — an unjustifiedas unknown asis not acceptable in review. - Utilize type annotations and interfaces to improve code clarity and maintainability
- Leverage TypeScript's type inference and static analysis features for better code quality and refactoring
- Use type guards and type assertions to handle potential type mismatches and ensure type safety
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
- 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
Web source layout: src/lib vs src/utils
The web client keeps two grab-bag directories under clients/web/src, split by a real (now codified) rule — utils = functions that compute; lib = things that instantiate, adapt, or touch the environment. If it does I/O or wraps a subsystem, it's lib; if it's a pure transform, it's utils.
src/utils/— pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors:jsonUtils,schemaUtils,toolUtils,maskSecrets,inspectorTabs,deepLink,mcpNetworkHeaders,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.
React instructions
- UI Components
- We are using the Mantine component library for UI.
- Instructions are at https://mantine.dev/llms.txt
- Avoid using div and other basic HTML elements for layout purposes.
- Prefer Mantine's Box, Group, and Stack components for layout.
- Use Mantine's theme and styling utilities to ensure a consistent and responsive design.
- NEVER use inline styles on a component.
- NEVER use raw hex values (
#ddd,#94a3b8, etc.) orrgba()literals for colors in component props or theme files. Use--inspector-*CSS custom properties defined inApp.css :root(e.g.,c: 'var(--inspector-text-primary)'). If no existing token fits, add one to:rootfirst. - NEVER add a CSS class to a Mantine component when the styles can instead be expressed as component props or a theme variant. CSS classes are a last resort.
- PREFER component props (via
.withProps()) to CSS for behavioral and visual styles. - PREFER defining styles as theme variants (via
Component.extend()insrc/theme/<Component>.ts) over CSS classes. Each Mantine component with custom variants has its own file insrc/theme/, exporting aTheme<Name>constant. The barrelsrc/theme/index.tsre-exports them all andtheme.tsimports from the barrel. Flat CSS properties (margin, padding, background, border, color, font-size, etc.) belong in the theme. Only pseudo-selectors, nested child selectors, keyframes, and native HTML element styles belong in App.css. - App.css must contain ONLY styles that cannot be expressed in the Mantine theme:
@keyframes, pseudo-selectors (:hover,:focus), cross-component hover relationships, nested child-element selectors for third-party HTML output (e.g. ReactMarkdown), and styles for native HTML elements (img,iframe). When refactoring a component, actively move any flat CSS properties out of App.css and into theme variants or.withProps()constants. - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable.
- In a component's file, for sub-components:
- ALWAYS use Mantine components for layout and content, configured with props for styling and behavior.
- ALWAYS declare a meaningfully named subcomponent as a constant using
.withProps()if an inline Mantine element carries two or more static props. A static prop is one whose value is a literal that configures the element's styling, layout, or behavior (size="sm",c="dimmed",fw={500},gap="xs",justify="space-between",variant="light",withBorder,readOnly,striped, …); dynamic props (value,onChange/on*,children,key,ref, and anything whose value is a variable/expression) do not count toward the two and are passed at the call site, not baked into the constant. Purely per-instance content/accessibility literals —label,description,placeholder,title,aria-label,role— likewise do not count toward the two (a<Checkbox label="…" description="…">with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in all cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the.withProps()constant and pass the dynamic ones where it's rendered. - The following cannot be expressed via
.withProps()and so stay inline (likeBoxbelow), each with a one-line comment saying why:Accordion(a compound,multiple-discriminated generic —.withProps({ multiple: true, … })loses its JSX call signature and fails to type); headless, non-factory()Mantine components such asTransition(plain function components with no Styles API — they have no.withPropsstatic at all, e.g.Transition.withPropsis a TS2339); anddata-*attributes (not part of a component's typed props object, so excess-property-checked out of awithPropsliteral — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (areact-iconsglyph, another library's component) and a first-party component that isn't a Mantine factory (a dumbexport functionlikeContentViewer, which has no.withPropsstatic of its own). - NEVER use
Boxfor subcomponent constants —Boxdoes not support.withProps(). UseGroup,Stack,Flex,Text,Paper,UnstyledButton, orImageinstead. Pick the component that best matches the purpose:Paperfor bordered/surfaced containers,Textfor any text or content wrapper,Stack/Group/Flexfor layout. ABoxthat genuinely needs a non-flex primitive it can't provide —component="iframe", ordisplay="grid"(no Mantine flex primitive is a CSS grid) — stays aBoxinline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in
src/theme/<Component>.tsusingComponent.extend({ styles: (_theme, props) => { ... } })and reference them withvariant="variantName"on the component or in.withProps(). - CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (
:hover,:focus), cross-component hover relationships (.parent:hover .child), nested child-element selectors (.wrapper p,.wrapper code),@keyframesdefinitions, and native HTML elements (img,iframe) that are not Mantine components. - When a theme variant needs a CSS class for nested/pseudo selectors, use
classNamesin the theme extension to auto-assign it — never addclassNamemanually in JSX for theme-styled components. - Example — subcomponent constant with
withProps:
const CardContent = Group.withProps({ flex: 1, align: "flex-start", justify: "space-between", wrap: "nowrap", }); return <CardContent> ... </CardContent>;- Example — theme variant with auto-assigned className for nested selectors:
// src/theme/Paper.ts export const ThemePaper = Paper.extend({ classNames: (_theme, props) => { if (props.variant === "message") return { root: "message" }; return {}; }, styles: (_theme, props) => { if (props.variant === "message") { return { root: { padding: "1.5rem", borderRadius: 12 } }; } return { root: {} }; }, }); // Component.tsx const MessageContainer = Paper.withProps({ variant: "message" });
- State and effects
- NEVER reset or re-sync local state from a prop inside a
useEffect.useEffect(() => setX(prop), [prop])renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error underreact-hooks/set-state-in-effect, which theeslint-plugin-react-hooksrecommended set enforces for the web client, the TUI, and — since #2192 —core/react/too. - 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 (
Web backend auth token
The dev/prod web backend protects every /api/* route with x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>. The browser recovers that token from three sources, in priority order (see App.tsx getAuthToken()):
window.__INSPECTOR_API_TOKEN__— injected intoindex.htmlon every page load by the backend (the dev Vite plugin viatransformIndexHtml, the prod Hono server on the/route), both routed throughclients/web/server/inject-auth-token.ts. This is what makes a bare-URL reload, a bookmark, or a clearedsessionStoragekeep working.?MCP_INSPECTOR_API_TOKEN=…query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs.sessionStorage— backstop for navigations that land without either of the above.
Injection is a no-op when auth is disabled (DANGEROUSLY_OMIT_AUTH), and the global name is the shared INSPECTOR_API_TOKEN_GLOBAL constant in core/mcp/remote/constants.ts.