Every browser-driven check in this repo launched Chromium and nothing else.
That is fine for most of the web client, whose behavior is React and Mantine.
It is not fine for the MCP Apps sandbox, which is built out of the primitives
that genuinely diverge between engines: a CSP <meta> injected as the first
<head> child of a srcdoc document, a nested sandboxed iframe, a
Permissions-Policy allow attribute, and postMessage origin discipline across
those two frames. Nothing else covers that surface — sandbox-csp.test.ts
asserts which policy STRING is built (environment-independent by
construction), and no Storybook story reaches the sandbox at all, since all
three App stories use a data: placeholder iframe and a mock bridge.
The engine is now a parameter:
SMOKE_BROWSER=webkit npm run smoke:web:app
SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes
Unset means chromium, so `npm run ci` and `npm run smoke` are unchanged. An
unrecognized value is an error rather than a fallback — falling back would
report a green Chromium run under a job labelled "webkit", claiming coverage
that never ran — and a launch failure names the engine that failed and its own
`playwright install` invocation.
Selection, launching, and the fatal-vs-benign page-diagnostics split now live
in one shared scripts/lib/headless-browser.mjs. The three smokes had each
hand-rolled the diagnostics split and two had their own copy of loadChromium;
pack:verify goes through the same helper, pinned to Chromium explicitly since
it is a packaging check.
The npm scripts drop their `cd clients/web && npx playwright install chromium`
prefix for scripts/install-smoke-browser.mjs. The engine is a variable now, and
a ${VAR:-default} expansion does not expand under Windows' cmd.exe; npx is also
a .cmd shim there that a shell-free spawn cannot start, so this resolves the
Playwright CLI through resolveNodeBin — the #1939 problem solved the #1939 way.
CI gains a `Sandbox smokes (<engine>)` matrix job rather than matrixing `build`,
which also runs validate, the coverage gate, two verify gates and Storybook —
all engine-independent. Both publish jobs now need it.
WebKit is supported by the tooling but deliberately NOT in the CI matrix yet:
pointing the smokes at it immediately found #2132, a pre-existing Safari
incompatibility in the web client's SSE transport that strands the last message
on /api/mcp/events, so an MCP App never leaves "loading". That is a product bug
affecting real Safari users, with its own diagnosis and a verified fix, and it
is not a matrix change. Adding `webkit` here is a one-word diff once it lands.
An engine is either green in CI or absent from it; a continue-on-error job would
report coverage nobody is held to.
Signed-off-by: cliffhall <cliff@futurescale.com>
20 KiB
Copilot review instructions — MCP Inspector
AGENTS.mdis the source of truth. This file is a review-focused distillation of it, kept in sync by hand — see "Keep documentation files up to date" inAGENTS.md. Where the two disagree,AGENTS.mdwins, and the drift is a bug worth flagging in review.
The Inspector ships as one package with three clients (Web, CLI, TUI) over a shared core/, consumed via the @inspector/core build-time alias. v2 is not an npm workspace: the root and each clients/* keep their own package.json and node_modules.
TypeScript
- Never use
any. Not in types, not in casts, not in generics. - Never suppress errors to satisfy the linter or compiler — no disabling
no-unused-vars/no-explicit-anyin config, and no// @ts-nocheckor// @ts-ignore(@typescript-eslint/ban-ts-commentrejects these across every surface). - Avoid double casts (
as unknown as T). They erase all type safety and usually mean the real type is being worked around. Prefer a type guard, a narrower single cast, or fixing the underlying type. If genuinely unavoidable (a documented gap in a third-party type, or bridging structurally-identical shapes TS can't relate), it must carry an inline comment justifying why it's safe and why nothing better exists. An unjustifiedas unknown asis not acceptable in review. - Prefer inference, type guards, and precise annotations over assertions.
- An
_prefix is the intentionally-unused marker (argsIgnorePattern/varsIgnorePattern/caughtErrorsIgnorePattern). - Never float a promise.
@typescript-eslint/no-floating-promisesiserrorin all five ESLint scopes, so every promise must be awaited,.catch(…)-terminated, returned, or explicitly discarded withvoid. Prefer holding and settling it — a floated rejection surfaces in an unrelated file as an SDK-internal stack, which is how two un-heldcallToolcalls failed the wholenpm run cichain (#1947). Reach forvoidonly when the callee already owns its failures (it ends in acatch) or the caller genuinely cannot await (a sync effect body, an Ink key handler), and say which in a comment. In Storybook play functions,expect(...)fromstorybook/testis instrumented and returns a promise — it must be awaited.
React and UI (web client)
The web client is built from presentational ("dumb") components — they take data and callbacks as props and hold display logic only. No data fetching or client state inside them; state comes from the @inspector/core hooks wired near the top of the tree. A component that reaches for a store or fetches directly is a review finding.
Styling is Mantine-first, in this strict order of preference: component props → theme variants → CSS classes (last resort).
- Never use inline styles.
- Never use raw color literals — no hex (
#ddd), norgba(). Use the--inspector-*CSS custom properties fromApp.css :root(e.g.c: 'var(--inspector-text-primary)'). If no token fits, add one to:rootfirst. - Avoid
divand bare HTML for layout. Use MantineBox,Group,Stack,Flex,Paper. - Never add a CSS class when the styles can be component props or a theme variant. Flat CSS properties (margin, padding, background, border, color, font-size) belong in the theme (
src/theme/<Component>.ts, viaComponent.extend()).App.cssmay contain only what the theme cannot express:@keyframes, pseudo-selectors (:hover,:focus), cross-component hover relationships, nested child selectors for third-party HTML output, and styles for native elements (img,iframe). - When a theme variant needs a class for nested/pseudo selectors, assign it via
classNamesin the theme extension — never a manualclassNamein JSX for theme-styled components.
The .withProps() rule
Declare a named subcomponent constant via .withProps() whenever an inline Mantine element carries two or more static props. This applies to single-use elements too — "it's only used once" is not an exemption.
- Static = a literal configuring styling, layout, or behavior:
size="sm",c="dimmed",fw={500},gap="xs",justify="space-between",variant="light",withBorder,readOnly,striped. - Not counted: dynamic props (
value,on*,children,key,ref, anything whose value is a variable) — pass these at the call site; and per-instance content/accessibility literals (label,description,placeholder,title,aria-label,role) — these never by themselves trigger extraction.
const CardContent = Group.withProps({
flex: 1,
align: "flex-start",
justify: "space-between",
wrap: "nowrap",
});
Legitimate exceptions (each stays inline, with a one-line comment saying why):
Box— does not support.withProps(). UseGroup/Stack/Flex/Text/Paper/UnstyledButton/Imageinstead, chosen by purpose. ABoxthat genuinely needs a non-flex primitive (component="iframe",display="grid") stays inline.Accordion— a compound,multiple-discriminated generic;.withProps()loses its JSX call signature and fails to type.- Headless, non-
factory()components such asTransition— no Styles API, so no.withPropsstatic at all. data-*attributes — not part of a component's typed props, so excess-property-checked out of awithPropsliteral. Pass at the call site.- Anything that isn't a Mantine factory component — a
react-iconsglyph, another library's component, or a first-party plainexport function.
State and effects
- Never reset or re-sync local state from a prop inside a
useEffect.useEffect(() => setX(prop), [prop])paints the stale value first and renders twice; it is an error underreact-hooks/set-state-in-effect. - Use
useValueChange(value, onChange)(src/hooks/useValueChange.ts) — React's documented "adjusting state during render" pattern. It does not fire on the first render; seed the state withuseState. The comparison isObject.is, so pass a referentially stable value — a primitive key (id/name/URI) or a memoized one, never a fresh object literal. - The
onChangeruns during render, so it must be pure —setStateand nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned. - Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers).
Theme files vs. element components
Both exist and do different jobs. Theme files (src/theme/<Component>.ts) customize a Mantine primitive app-wide. Element components (src/components/elements/) add domain semantics on top of primitives.
- Element components import from
@mantine/core, not fromsrc/theme/— the theme layer is applied transparently by the provider. - Never push domain-specific variant logic into theme files (annotation types, transport types, …). Domain variants belong to the element component that owns those semantics.
Where code goes (web client)
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. Also: pure shared domain types and their constructors; diagnosticconsole.warn/errordoes not count as a side effect; type-only imports from@inspector/core, and re-exporting pure functions/constants from core, are both fine.src/lib/— infrastructure, integration, stateful adapters: composes subsystems, wraps the core runtime, touches DOM /window/sessionStorage, or produces side effects.- Cross-directory imports go one way:
lib → utils, never the reverse. src/types/is only for ambient.d.tsmodule stubs — not a home for new domain types.- ⚠️ The coverage
includeis a whitelist namingcomponents/hooks/theme/lib/utils/server(plus thecore/*runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories.
Dependency placement
- The MCP SDK packages (
@modelcontextprotocol/client,core,server,server-legacy,ext-apps) belong in the rootpackage.jsononly. Adding one to aclients/*/package.jsonis a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions ofext-apps, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failurevitest.shared.mtshas adedupeworkaround for. - The v1
@modelcontextprotocol/sdkis not a dependency of this repo and must not become one. It is a peer ofext-apps, present in lock files only. - Dependencies reached only through root-owned code with no manifest (
test-servers/src,core/) are declared at the root and aliased to the repo root invitest.shared.mts— asexpressandyamlare — not to<client>/node_moduleslike the other pins there. - 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
reactfrom wherever npm placed it in the consumer's tree, beside a React satisfying its peer range — looser than ours, which is all it takes to split React.ink-form/ink-scroll-viewdeclare">=18", so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined vianoExternalinclients/tui/tsup.config.ts.inkis the one exemption, justified by cost (~1.4MB) — never by a peer range: flag any claim that">=19"keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the rootreactrange staying open to the whole major (^19.0.0) so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug.clients/tui/__tests__/tsupConfig.test.tsenforces the split, the root-declaration of exempt packages, and that range. - Which section is a separate question from which manifest. A package
core/imports at runtime must be in rootdependencies: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (express) belong indevDependencies. Flag a runtimecore/import added todevDependencies— it passes every local check and breaks the published package. - A root-declared package that
core/imports at runtime must also be named in each client's bundlerexternallist —clients/{cli,tui}/tsup.config.tsandclients/web/tsup.runner.config.ts, all three. Bundlers externalize what the client's manifest declares, and these packages are root-only by rule, so omitting them means they get bundled. For a CJS package inlined into an ESM bundle that is fatal: esbuild'sDynamic require of "path" is not supportedshim throws at import time and the binary dies before parsing a flag (#2082,proper-lockfile). Flag a new root runtime dependency that is not added to all three. This has broken twice —undicislipped past #2082 because it was declared inclients/cli/package.json, so the CLI looked fine while the web and TUI bundles inlined 1.05MB of it, unloadably (#2067).npm run verify:bundle-externalsnow guards it from the built output; flag any change that would weaken or skip that guard. - Pin a transitive dependency past its parent's declared range with an
overridesentry — never withnpm audit fix. When the advisory range has no upward escape inside the parent's range,npm audit fixresolves it by silently downgrading (esbuild 0.27.7 → 0.27.2 across three installs, #2058/#2062). Flag that in a diff. An override puts a bundler on a major it does not declare, so the gate for one isnpm run build, notnpm audit; a lockfile diff accompanying it should touch that package's entries only. Flag an override added without a note saying when it can be dropped.
Tests and the coverage gate
- All new or modified code needs tests. The per-file gate is ≥ 90% on all four dimensions — lines, statements, functions, and branches — enforced in CI for
clients/{web,cli,tui,launcher}and the gatedcore/runtime. - Never lower the gate to accommodate an unreachable branch. Annotate at the source with a justified
/* v8 ignore … -- <reason> */. Acceptable reasons: happy-dom-inherent paths (Mantine portal mounts,useMediaQueryfallbacks,typeof windowSSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards. Anything else is a missing test. - Suppress expected error output from the console in tests that exercise error paths.
Test placement
- Web: side-by-side by default —
<Name>.test.tsxnext to the source. A web-owned test undersrc/test/instead of beside its source is a bug.src/test/is only for what can't be co-located: tests of the repo-rootcore/package (src/test/core/…, mirroring core's layout), theintegrationproject (src/test/integration/…— placement is the manifest), and shared test infrastructure. - CLI / TUI / launcher: all tests live in a top-level
__tests__/directory. A co-locatedsrc/**/*.test.*lands in no tsconfig project and failsverify:typecheck-coverage.
Rendering components in tests
- Always render through
renderWithMantinefromsrc/test/renderWithMantine.tsx. A hand-rolled bareMantineProviderskips the project theme and the helper's options, and drifts from every other test. (It does not reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net insetup.tsis global and covers every unit test regardless of how it renders.) - For a forced color scheme, pass the option —
renderWithMantine(ui, { colorScheme: "dark" })— rather than a hand-rolleddefaultColorSchemeprovider. - Only when asserting mid-flight transition state, use
renderWithMantineTransitions, passingsettleMsderived from the component's real animation duration. Do not combine it withvi.useFakeTimers(), and use theunmount()it returns if the test unmounts the tree itself.
Gates and PR hygiene
-
npm run formatbefore committing;npm run cibefore pushing (validate→coverage→verify:build-gate→verify:bundle-externals→smoke→ Storybook).npm run validateis the fast inner-loop check and is not a substitute — it runstest, nottest:coverage, so it does zero coverage gating. -
A dependency bump must land in every install that declares it. v2 is not a workspace — the root and each
clients/*have their ownnode_modules, and a client's test project compilescore/andtest-servers/src(which resolve from the root) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in onetscprogram; for a recursive-generic surface like zod that exhausts the tsc heap (#1896).verify:dep-lockstepfails the build on this — it derives its candidates from what eachtscprogram actually resolves (tsc --listFilesOnly, keeping packages that reach one program from two installs), so a package reached only through another package's.d.tscounts too (#1965) — so a PR bumping a package the shared sources pull in should update the root and every client that already lists it — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. -
A test or smoke must not touch real user state. The web smokes run against a throwaway catalog via the shared
scripts/lib/prod-web-server.mjshelper, never the developer's~/.mcp-inspector/mcp.json(#1977); the cli/tui smokes drive a temp--catalog;pack:verify's--webchild sets its ownMCP_CATALOG_PATHfor the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then navigates it needs that isolation, not just the scripts namedsmoke:*. A new smoke spawning its own server, or teardown that removes a work dir without first awaitingstopChild(the #1801 race —child-cleanup.mjsexports both halves and both are required), should be flagged. -
The headless web smokes are engine-parameterized, not Chromium-only.
smoke:web:browser/smoke:web:app/smoke:web:elicittake their engine fromSMOKE_BROWSER(chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix pending #2132, a real Safari SSE hang it found), because the MCP Apps sandbox is built out of the primitives that diverge between engines:srcdocCSP inheritance, nested sandboxed iframes,Permissions-Policy, cross-framepostMessage. Nothing else covers that —sandbox-csp.test.tsasserts a policy string, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going throughscripts/lib/headless-browser.mjs, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine.pack:verifyis pinned to Chromium on purpose — it is a packaging check. -
Build output is never a gate target. Lint, format, and typecheck read first-party source only; everything a build writes (
clients/*/build,clients/web/dist,storybook-static,coverage,test-servers/build,core/**/{build,dist},*.tsbuildinfo) stays out via each scope'sglobalIgnores,formatglobs, and tsconfiginclude. Gating generated code reports defects in vendored third-party source that nobody can fix, and a rule promotion turns that warning into avalidatefailure (#2043). Flag a PR that adds a build location without ignoring it in the same change, that widens an ignore to silence a finding in first-party code, or that adds a build directory to a tsconfigincludeto make a generated.d.tsresolve. Note the coverage guards don't catch this — they assert source is covered, not that output is excluded. -
Lint has no warning tier. Every
lintscript runs--max-warnings 0, so a warning failsvalidateexactly as an error does (#2085) — awarn-levelreact-hooks/exhaustive-depsfinding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening aglobalIgnores, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set toerrorrather than left atwarnand carried by the flag. -
Every PR references an issue, first body line
Closes #<ISSUE_NUMBER>. -
Every PR carries exactly one version label,
v1orv2, matching its base branch. -
Commits carry a
Signed-off-by:trailer. The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author or committer, and skips only merge and bot commits. Usegit commit -s— noteformat.signOffdoes not signgit commit(onlyformat-patch). Repairing pushed commits meansgit rebase HEAD~<n> --signoff+git push --force-with-lease; remediation commits are not enabled on this repo. -
Update the relevant
README.md/AGENTS.mdwhen a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern.
What to prioritize in review
- Correctness and security — this backend spawns local processes and proxies outbound requests, so anything touching auth, origin validation, host binding, or the proxy's SSRF controls deserves close reading.
- Type-safety violations (
any, suppressions, unjustified double casts). - Missing or thin tests against the ≥90% four-dimension gate, and modules placed outside the gated directories.
- Mantine convention violations — inline styles, raw colors, unnecessary CSS classes, missing
.withProps()extraction. - Docs that contradict the change.