* build(web): fail vite build on a browser-externalized Node built-in (#1769)
Vite 8 (rolldown) externalizes a node:* / Node built-in that reaches the
browser graph with a warning and ships a `module.exports = {}` stub — the
build still succeeds, shipping a broken bundle. The runtime browser smoke
(smoke:web:browser, #1615) only catches the subset where that stub is *called*
at module init; a stub imported but never called ships silently and nothing
gates it.
Add a Vite build plugin that turns that specific browser-externalization
warning into a hard `vite build` error, moving detection upstream into
`npm run build` / `validate` and additionally catching the never-called case
the runtime smoke can't see.
- clients/web/server/browser-externalized-builtin-gate.ts: Vite-agnostic
detection + error + per-build state machine (unit-tested to the ≥90 gate).
- vite.config.ts: thin Vite wiring. A throw inside rolldown's onLog is
swallowed (the one hook where it doesn't abort — verified against vite@8.0.0),
so the gate records the warning in onLog and re-throws in buildEnd. Scoped to
`vite build` (apply: 'build'); the Node runner build (tsup) is unaffected.
- scripts/verify-build-gate.mjs + verify:build-gate (in npm run ci and the
GitHub workflow): runs a real build with a node:fs probe and asserts it fails
via the gate — the only check that catches the warning phrasing drifting in a
future Vite bump and silently disabling the message-keyed gate.
There is no stable log `code` on the rolldown warning (only message + plugin),
so the gate keys off the documented message phrasing; the drift guard above is
what keeps that safe across Vite versions. smoke:web:browser stays as the
runtime backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): harden the browser-externalized-builtin gate per review (#1769)
Review follow-ups on PR #1777:
- Per-build state: add gate.reset() called from the plugin's buildStart, so a
`vite build --watch` rebuild doesn't inherit a prior build's recorded warning
(the plugin instance is reused across rebuilds). Makes the docblock true.
- buildEnd(error) now only asserts on the success path — rolldown also calls
buildEnd(error) when the build already failed for another reason, and throwing
then would mask that real error with the #1769 message.
- verify-build-gate.mjs: drop the shell `rm -rf` cache-clear entirely (verified
the gate fires reliably without it — the earlier "caching" symptom was the
onLog-swallow behavior, not a stale cache); add a result.error spawn-failure
check so a missing `npx` isn't misdiagnosed as "not via the gate"; add
SIGINT/SIGTERM handlers so an interrupt during the multi-minute build still
restores the mutated entry; add a progress log before the spawn.
- Match the double-quote style of the sibling server/ files.
- Docs: list browser-externalized-builtin-gate.ts in the AGENTS.md server/ block
and the clients/web/README server/ list (with why a build helper lives under
server/), and add verify-build-gate to the root README scripts/ line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-2 review hardening for the #1769 build gate
- Scope the gate to the browser (`client`) environment via applyToEnvironment,
so a future SSR/node environment built from this config isn't failed for a
legitimate node:* import — browser-only intent is now structural.
- Report ALL externalized built-ins in one build, deduped, instead of only the
first offender (N leaks no longer means N fix-and-rebuild cycles).
browserExternalizedBuiltinError now takes string[]; gate uses a Set.
- verify-build-gate.mjs: pin Vite with `npx --no-install` so the drift check
can never silently run a registry-fetched Vite instead of the repo-pinned one
(the whole point is version fidelity); append the probe instead of prepending
(ES imports hoist, and appending won't demote a future leading directive);
clarify the signal-handler comment (handlers are a backstop — a blocked
spawnSync tears down via the process group first).
- AGENTS.md: update both `ci` pipeline enumerations to include verify:build-gate
and drop the now-false "Storybook is the only CI step left out" claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-3 review polish for the #1769 build gate
- Error message: name the transitive-dependency remedy (a resolve.alias to a
browser shim) — "remove the import / gate behind the backend" doesn't apply
when the node:* import lives in node_modules.
- isBrowserExternalizedBuiltinLog is now a `message is string` type guard, so
recordLog drops its redundant `!== undefined` clause while still typing the
Set.add() — a real one-liner, not a coverage-only removal.
- verify-build-gate.mjs: docblock now states WHY it builds the real config +
entry (catches config-level regressions — plugin removed, onwarn suppression
added — that a temp config would miss), so the minute-long build isn't later
"optimized" into a temp entry and silently lose that coverage. Guard the
entry read so a renamed main.tsx fails through fail() with an actionable
message. On a botched restore, save the captured original to a sidecar .bak
and point there instead of recommending `git checkout --`, which would
discard uncommitted edits.
- AGENTS.md: fix the muddled CI-steps sentence (GitHub CI runs the whole chain
as separate steps, Storybook last) and note the applyToEnvironment client-only
scoping in the gate prose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-4 review polish — sharper verify-build-gate diagnostics (#1769)
All in scripts/verify-build-gate.mjs's failure paths:
- Split the "build SUCCEEDED" failure into three diagnoses keyed off the
captured output, so it names the right file: warning present but build passed
→ plugin not applying (removed from `plugins`, `applyToEnvironment` env-name
mismatch, or an `onwarn` suppression); node:fs present but phrase absent →
phrasing drift, update the constant; neither → probe never reached the graph.
The old message hard-coded "phrasing drifted", which misdirects for the
config-regression class this script exists to catch. Added applyToEnvironment
to the docblock's config-regression list too.
- Bound the build with a 10-min spawnSync timeout (+ SIGKILL); a timeout sets
result.error (ETIMEDOUT) and reports via the existing branch, instead of
burning to the GitHub job's 360-min default with no output.
- Guard the probe write so a read-only/EACCES checkout fails through fail()
with an actionable message instead of escaping as a raw stack.
- SIGTERM now exits 143 (was 130, which is SIGINT's code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-5 review polish — self-consistent verify-build-gate (#1769)
All in scripts/verify-build-gate.mjs:
- Drift guard: assert the gate source file still contains the mirrored
KNOWN_PHRASE literal, failing fast if the two drift. Without it, a stale
KNOWN_PHRASE after a phrasing update would make the three-way diagnosis
misreport a plugin-not-applying regression as a phrasing drift — the exact
misdirection the diagnosis exists to avoid.
- Make the .bak safety net reachable on the path that needs it most: restoreEntry
now catches its own write failure and routes to a shared saveBackupAndFail
helper, instead of letting the write escape the finally as a raw stack (which
skipped the .bak fallback and left the probe injected in the entry).
- Add the fourth "plugin isn't applying" cause to the diagnosis: a future Vite
emitting the warning before the client env's buildStart reset clears it (same
symptom as the other three).
- Flag once, on the failing-check path, that clients/web/dist holds a probe
build so a local debugger doesn't serve it unaware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-6 review polish — verify-build-gate literal contracts (#1769)
All in scripts/verify-build-gate.mjs; one theme — the literals the script
asserts on can each go stale independently of what they mirror:
- Success key: assert on the gate's distinctive thrown-error prefix
"Build failed (#1769)", not a bare "#1769". The issue number also appears in
vite.config.ts comments, so a code frame from an unrelated build error there
could otherwise make the check report OK with the gate dead — the one
direction it must never get wrong (a false pass hiding a dead gate).
- Drift guard now anchors on the BROWSER_EXTERNALIZED_BUILTIN_PHRASE assignment
(regex) instead of a whole-file substring match, so the old wording lingering
in a comment can't mask a changed constant; and its readFileSync of the gate
module is guarded so a moved file fails through fail() actionably.
- Extract PROBE_MODULE ("node:fs") used to build both the PROBE and the
diagnosis check + messages, so changing the probe module can't silently
misroute a phrasing-drift failure into "probe never reached the graph".
- Comment the load-bearing diagnosis order (phrase before module-name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-7 review polish — drift-guard both mirrored literals (#1769)
- Extract ERROR_PREFIX ("Build failed (#1769)") and drift-guard it against the
gate module too, and use it as the success key. Previously only KNOWN_PHRASE
was guarded; the prefix is mirrored across the same TS/.mjs boundary for the
same reason, so rewording the gate's thrown error would make a correctly-firing
gate report "the build broke for another reason" (benign direction, but the
same misdirection the other guards close).
- Write the restore-failure .bak under the OS temp dir, not next to the entry:
a sidecar inside src/ is untracked and a `git add -A` (likely while recovering)
would sweep it into a commit.
- Branch-(b) remediation now says update BOTH the gate constant AND KNOWN_PHRASE,
so a phrasing-drift fix lands in one pass instead of tripping the drift guard
on the next run.
- Note next to BROWSER_EXTERNALIZED_BUILTIN_PHRASE that it must stay a single
string literal (the script's drift guard anchors a regex on the assignment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-8 review polish — safe recovery path + structural onLog order (#1769)
- saveBackupAndFail no longer claims a backup it may not have written: it tracks
whether the write succeeded and only points the user at the .bak in that case
(otherwise it says the probe is still injected and to restore from VCS). Uses
mkdtempSync for a unique dir instead of a fixed tmp filename, so a
pre-existing/foreign-owned /tmp file can't make the recovery message point at
someone else's content.
- Guard the restore-verification readFileSync too, routing a failure through
saveBackupAndFail so the whole restore path is uniformly safe (a raw throw
there would skip the backup net).
- vite.config.ts: enforce: 'pre' on the gate plugin so it runs ahead of the
normal-plugin group in the onLog chain — a plugin whose onLog returns false
filters the log for later plugins, so trailing the array would let a future
log-filtering plugin silently blind the gate. Makes "the gate sees every log"
structural rather than array-position-dependent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-9 review polish — prove the gate fired on the probe (#1769)
The success key confirmed the #1769 gate fired, but not that OUR probe tripped
it. If the repo already leaked a Node built-in, the gate would fire on that and
the script would report OK even if the probe was tree-shaken or entryPath went
stale — branch (c)'s failure silently inverted into a pass. Add an assertion
that PROBE_MODULE appears among the gate's embedded offender warnings. Unreachable
inside `npm run ci` (validate's build:web fails first on a pre-existing leak),
but this script is a documented standalone command — run exactly when debugging
a leak, the case where this mattered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-10 review polish — roll back a partial probe write (#1769)
The probe writeFileSync uses the default 'w' flag (truncates at open), so a
mid-write ENOSPC/EIO could leave clients/web/src/main.tsx empty or half-written
— and its catch called fail() directly, the one path on this script that could
destroy the entry rather than merely dirty it (every other read/write is already
routed into the recovery net). Call restoreEntry() in that catch to roll the
partial write back (falling through to the .bak net if the restore itself can't
write), and correct the comment that claimed a write failure happens "before any
mutation". Also widen the enforce:'pre' comment to note it orders ahead of Vite's
core plugins too (harmless — the gate defines no resolve/load/transform).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-11 review polish — don't let the rollback swallow the write error (#1769)
Round 10's restoreEntry() in the probe-write catch swallowed the primary error:
restoreEntry() on its own write failure calls saveBackupAndFail → fail → exit,
so the "could not write the probe" diagnosis never ran when the rollback also
failed — which is exactly the read-only-checkout / ENOSPC case (both writes fail
at open). On a read-only checkout nothing was ever written, yet the developer
was told the restore failed and pointed at a /tmp backup of an untouched file.
Roll back inline instead: attempt the restore without exiting, track whether it
succeeded, then always report the PRIMARY write error, appending "restore from
version control" only when the rollback didn't take. Also pin the
"Build failed (#1769)" error prefix as contiguous in a single string fragment
(a comment symmetric with the phrase pin), since verify-build-gate.mjs
drift-guards on it as ERROR_PREFIX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-12 review polish — don't let the pin comment defeat its own guard (#1769)
The round-11 pin comment quoted "Build failed (#1769)" verbatim, so the prefix
appeared twice in the gate module and verify-build-gate.mjs's whole-file
includes(ERROR_PREFIX) drift guard passed on the comment alone — rewording the
thrown error would have kept the guard green while a correctly-firing gate
reported "the build broke for another reason." Reworded the comment to not repeat
the full lead (matching how the phrase pin avoids repeating its phrase); the
literal now occurs exactly once, restoring the guard's meaning.
Also make the probe-write recovery message factual instead of inferred: read the
entry back and only warn "restore from version control" when it actually differs
from the original. On a read-only checkout both writes fail at open, so the file
is untouched and the old hedge needlessly implied damage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: add headless-browser boot smoke for the web client (#1615)
smoke:web only asserts GET / serves the SPA HTML with the injected token;
it never runs the React app, so a Node built-in reaching the browser
bundle (which crashes the app to a blank page at runtime, e.g. #1612)
slips through. Add smoke:web:browser, which boots the same prod --web
server and runs the bundle in headless Chromium (Playwright, already a
clients/web devDependency), asserting the app renders its first frame
(the "Add Servers" control) with no uncaught page errors — in particular
no `Module "node:*" has been externalized`. This catches "Node code
reached the browser bundle" as a class, not one import at a time.
Wire it into the root smoke chain (and thus npm run ci / GitHub CI), and
move the Playwright install/cache steps ahead of the smoke step so the
new smoke reuses the cache. Update AGENTS.md and README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: address review on the headless-browser boot smoke
- Don't fail on benign console.error (CDN font loads, React warnings) — the
#1612 class arrives as an uncaught pageerror. console errors are now
diagnostics, hard-failing only when they carry the externalization
signature.
- Broaden the externalization regex to non-prefixed built-ins ("fs" as well
as "node:fs") and label the signature on both the pageerror and console
channels.
- Rename the port override to SMOKE_WEB_BROWSER_PORT so it doesn't collide
with smoke:web's SMOKE_WEB_PORT.
- Add a settle window (networkidle + 500ms) so a late-evaluating lazy chunk
that throws is caught, and race the render against launcher death so a
mid-load crash reports the real cause instead of a render timeout.
- Print an actionable hint when Playwright/chromium can't launch (missing
--with-deps system libs).
- Extract the shared spawn/readiness boilerplate into
scripts/lib/prod-web-server.mjs so smoke-web and smoke-web-browser can't
drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: fix playwright resolution in the browser smoke (CI failure)
The bare `import("playwright")` resolved relative to the script's own
directory (scripts/), not the cwd — so `cd clients/web` in the npm script
never made it resolvable. It only passed locally because an ancestor
node_modules happened to carry playwright; CI has none, so it failed with
"Cannot find package 'playwright'". Resolve it explicitly from
clients/web via createRequire, independent of cwd and ancestor dirs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: address second review on the prod-web-server helper
- waitForReady now records the last non-ok status and includes it in the
timeout message, so a server that boots but persistently answers 500
(broken dist / injection failure) reports "server did not start … (last
response: HTTP 500)" instead of a bare timeout that points at the wrong
thing. Keeps polling on non-ok (a warming server may answer 503).
- Add a child 'error' handler so a spawn-level failure surfaces as the
smoke's FAILED line instead of an uncaught raw stack; readiness and the
child-exit race both report it.
- Drop the unused isExited/exitCode/child exports from the helper API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: reframe browser smoke around uncaught pageerror; fix stop() edge
Third-review findings.
1. The `Module "…" has been externalized` string can't fire against a Vite 8
*prod* bundle — verified end-to-end against the installed vite@8.0.0: a
node: import reaching the browser graph is a BUILD-time warning, and the
shipped stub is a silent `module.exports = {}`. The real #1612 manifestation
is the first *call* into that empty stub throwing a TypeError at module init
(confirmed: injecting a used `node:fs` import fails the smoke with
"readFileSync is not a function"; an unused one ships {} and is harmless).
So the signature machinery was dead against the bundle it runs on — dropped
it. The smoke now asserts exactly what it verifies: the prod bundle boots and
paints with no uncaught page error. Reworded the script header, the
AGENTS.md bullet, and the README row to match. Console errors stay
diagnostic-only.
2. The helper's child 'error' handler set `exited = true`, but Node emits
'error' for a failed kill/send too (child still alive) — so `stop()`
(guarded on `exited`) would skip the SIGTERM and orphan the launcher on the
port. Track the error separately (`childError`); `bootFailed()` = exited ||
childError, while `stop()` still SIGTERMs a live child.
Docs: fixed the stale AGENTS.md claim that `cd clients/web` makes
`import("playwright")` resolve (createRequire does, cwd-independently; the cd is
only for `npx playwright install`), and noted the shared
scripts/lib/prod-web-server.mjs helper on both smoke bullets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: also hard-fail the async half of the crash class (fourth review)
page.on("pageerror") fires for uncaught SYNCHRONOUS exceptions only. The
async twin of the #1612 shape — the same empty-stub TypeError reached
through an await/.then(), or a failed dynamic import (this app lazy-loads
chunks) — lands on the console channel as `Uncaught (in promise) …` /
`Failed to fetch dynamically imported module`, which were diagnostic-only,
so the smoke would have passed. Hard-fail console errors matching
/^Uncaught\b|Failed to fetch dynamically imported module/; every other
console error stays a printed non-fatal note. The prefixes are unambiguous
(a font/CDN miss reads "Failed to load resource: net::ERR_…"; React
warnings never start with "Uncaught"), so this can't reintroduce the flake
the earlier round fixed. Verified: an unhandled rejection at module init
now fails the smoke where it previously wouldn't.
Also prod-scope the AGENTS.md note that the "externalized" string isn't a
runtime message (under `npm run dev` Vite's stub is a Proxy that
console.warns it), and document both the sync and async failure signals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: reword smoke success line + README to two-channel wording (fifth review)
After the async hard-failure signature, the assertion spans both the
pageerror (sync) and console (unhandled rejection / failed dynamic import)
channels. The OK console line and the README smoke row still said "page
error"; match the two-channel wording already in the AGENTS.md bullet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* ci: correct the playwright-package-resolution hint (sixth review)
The catch around requireFromWeb("playwright") advised `playwright install
--with-deps chromium`, but that installs browser binaries, not the npm
package — and the npm script already ran `playwright install` a step
earlier. A failure there means the package isn't resolvable from
clients/web, so the actionable fix is `npm install` at the repo root (the
postinstall cascade installs clients/web's devDependencies). The
chromium.launch() catch keeps its --with-deps advice (correct for the
missing-system-libraries case).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR #1648 review:
- Bump the GHCR action majors to match current v1 (docker/login-action v4,
docker/metadata-action v6, docker/setup-buildx-action v4,
actions/attest-build-provenance v4) to avoid Dependabot churn.
- Make the image tags explicit (`type=semver` + `flavor: latest=true`) instead
of relying on `flavor.latest=auto`, so a release lands both the version tags
and `:latest` — the README's bare `ghcr.io/…/inspector` URL won't 404.
- Run the container as the non-root `node` user with /home/node as workdir (its
writable home is where the inspector stores the catalog / OAuth state).
- Add a HEALTHCHECK that probes `/` via Node's global fetch (no curl in slim).
- Trim specification/ and docs/ from the build context. (test-servers/ stays —
`tsc -b` typechecks the integration tests that import its alias, so excluding
it breaks the build.)
Re-verified locally: `docker build` succeeds, the container runs as uid 1000
(node), serves `/` (200), and the HEALTHCHECK reports healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
v1 published a container image but v2 arrived with no Dockerfile (the
client/server/cli layout is gone). Add a v2 image for the single-package /
launcher architecture and wire the release-gated GHCR publish job.
- `Dockerfile`: two-stage build — stage 1 installs + `npm pack`s the publishable
tarball; stage 2 `npm install -g`s that tarball, so the image ships the exact
same artifact as npm with a clean `mcp-inspector` bin. Defaults to `--web` on
`0.0.0.0:6274`, browser auto-open off. `.dockerignore` keeps the context lean
and forces a clean in-container install/build.
- `publish-github-container-registry` job in main.yml (ported from v1):
release-gated, multi-arch (amd64/arm64) build+push to GHCR with a provenance
attestation. Independent of the npm `publish` job.
- README: document the image + how to run it; the release section now covers
both publish jobs.
Verified locally: `docker build` succeeds, the container serves `/` (HTTP 200
with the injected token) and dispatches `--cli` / `--tui`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Re-review nit: interpolating `${{ github.event.release.tag_name }}` directly
into the `run:` block is a script-injection surface (a crafted tag with shell
metacharacters would splice into the script). Pass it through `env: TAG` so it
arrives as a plain shell variable instead. Purely defensive; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Address PR #1647 review:
- Assert the release tag matches the root package.json version before publishing
(fail fast, before install/pack:verify) — publishing is irreversible, so a
release drafted without `npm version` or cut from the wrong commit shouldn't
silently ship a mismatched version. Tolerates a leading `v`.
- Add a `concurrency: { group: publish-npm, cancel-in-progress: false }` guard so
two releases in quick succession can't run overlapping publishes.
- Note in-comment that the triple build (build job → pack:verify → prepack) is
intentional, so a future reader doesn't "optimize" prepack away and break the
tarball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Wire the v2 publish pipeline into main.yml. On a published GitHub release, a new
`publish` job runs `npm run pack:verify` (the pre-publish gate — builds, packs
the real tarball, installs it into a clean consumer, and drives the bin) then
`npm publish --access public --provenance`. This is a single `npm publish` (v2
is not an npm workspace, so no v1-style publish-all/--workspaces), with a signed
provenance attestation via GitHub OIDC (id-token: write, environment: release,
NPM_TOKEN). Add the `release: types: [published]` trigger; the existing `build`
job is unchanged and the publish job is gated on the release event.
Document the single-version release/tag flow in the root README (one `npm
version` at the root; no per-client sync / check-version since only the root
package.json carries a version).
Docker/GHCR is deferred: v2 has no Dockerfile (the v1 client/server/cli layout
is gone), and a correct image needs a net-new, docker-build-tested Dockerfile.
Split into #1646.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Add a CI step that runs `npm run coverage` so a PR that drops any file
below 90% on lines/statements/functions/branches fails the job. Remove
the now-redundant standalone `Run web integration tests` step, since
web's `test:coverage` already runs `--project=unit --project=integration
--coverage`, and update AGENTS.md to reflect that CI now enforces the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
undici 8.5.0 requires Node >= 22.19.0 (its declared engines), so CI's
Node 20.x could not load undici's CacheStorage (missing
webidl.util.markAsUncloneable) and the proxy integration tests failed.
Bump the CI setup-node to 22.x to match the repo's Node floor.
Reconcile the declared floor with undici's real requirement: bump root
engines.node to >=22.19.0 and correct the CLI README note (undici 8.5.0
needs >=22.19.0, not >=22.7.5). Also document proxy support in the web
README and clarify that a caller-supplied eventSourceInit.fetch bypasses
the proxy wrapper by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
* refactor(launcher): self-contained validate; split smokes into top-level step (#1504)
Previously `validate:launcher` built cli/tui/web and ran the cross-client
smokes, so the launcher's "validation" depended on every other client
being built — and the launcher had no lint/format/coverage of its own.
- clients/launcher: add a web-style `validate` (format:check && lint &&
build && test:coverage) with a Node-only eslint flat config, prettier,
and a per-file coverage gate (parse-launcher-argv.ts covered;
src/index.ts excluded as binary bootstrap). 100% on the gated file.
- Root `validate:launcher` now just delegates: `cd clients/launcher &&
npm run validate` — no dependency on the other clients.
- New top-level `smoke` step (smoke:launcher + smoke:cli + smoke:tui +
smoke:web) with NO build commands; `validate` runs it last. Each
client's own validation builds its bundle first, so nothing builds
twice (cli/tui via test:coverage, launcher via its validate;
smoke:web builds dist on demand).
- clients/tui test:coverage now builds the TUI bundle (mirroring cli) so
the build-free smoke has it available.
- CI: replace the standalone cli/tui/launcher build step with a
"Validate launcher" step. Docs updated (AGENTS.md, launcher README).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cli,tui,root): every client self-validates; smoke separate from validate
Extends the launcher self-validate work to the CLI and TUI, and makes the
top-level `validate` nothing but the per-client validations — smokes are
now a separate top-level target.
- clients/cli, clients/tui: add a web-style `validate` with a Node flat
eslint config + prettier. CLI lint/format pass as-is. TUI registers
eslint-plugin-react-hooks for the classic rules only (rules-of-hooks +
exhaustive-deps, which the source already references); the stricter
react-hooks@7 rules are not enforced on the interim component surface
(#1501).
- Avoid double builds:
- CLI's e2e spawns the built binary, so its `test:coverage` builds first;
`validate` folds that in (`format:check && lint && test:coverage`, no
separate `build`).
- TUI tests don't spawn a bundle, so `validate` keeps an explicit
`build` (for the smoke) and `test:coverage` is pure.
- Root: `validate` = validate:web && validate:cli && validate:tui &&
validate:launcher (each delegates to the client's own validate). `smoke`
is no longer part of `validate` — it's a separate top-level target run
after a build/validate, with no build commands of its own.
- CI: per-client "Validate X" steps + a single "Run cross-client smokes"
(npm run smoke). Docs updated (AGENTS.md, cli/tui READMEs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(root): drop redundant test aggregator scripts
Now that every client self-validates and the top-level `validate` chains
the per-client validations, the root `test` / `test:web` / `test:cli` /
`test:tui` / `test:coverage` aggregators are redundant — run
`npm run validate` (all) or `cd clients/<name> && npm run validate` (one).
`test:storybook` stays (it's the one check not part of validate). CI was
already using each client's own scripts, so it's unaffected. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): silence happy-dom infra noise in unit runs
Passing unit runs printed dozens of alarming-but-expected lines that made
green runs look broken — all from happy-dom performing real I/O with no
server behind it:
- `<iframe src="/sandbox.html">` (MCP Apps sandbox) made happy-dom fetch
and later unload that URL, emitting ~40 `DOMException [NetworkError/
AbortError]` + `AsyncTaskManager destroyed` lines. Disable child-frame
navigation in the unit project (the tests only assert on the iframe
element/attributes, not its document), removing the noise with no
coverage loss.
- App-level renders read `GET /api/config` via useSandboxUrl /
useServerListWritable, 404-ing ~76x. Add a benign default `fetch` (empty
200) in the unit setup; tests that exercise fetch still override it.
Negative tests that assert on errors already suppress their own console
output (e.g. jsonUtils/schemaUtils); CLI, TUI, and the web integration
suite were already clean. Unit suite is now noise-free; 2424 web tests
pass and the coverage gate is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(scripts): make validate fast (test), split coverage into its own target
Each client's `validate` now runs `test` instead of `test:coverage`, so
the everyday pre-push check skips v8 instrumentation (and, for web, the
slower integration project). The per-file coverage gate moves to a
dedicated top-level `coverage` script:
coverage = coverage:web && coverage:cli && coverage:tui && coverage:launcher
coverage:<name> = cd clients/<name> && npm run test:coverage
`validate` still builds each client's bundle (cli via its test `pretest`,
the others via an explicit `build`), so `npm run validate && npm run
smoke` works unchanged. CI now runs `validate` (fast checks + builds),
then `coverage` (the gate), then `smoke` — collapsing the old per-client
steps. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: drop the coverage gate from CI; keep web integration as its own step
CI no longer runs `npm run coverage` — the per-file coverage gate is now a
local/manual check (`npm run coverage` before pushing). To avoid silently
dropping the web integration suite (it only ran via test:coverage, and
`validate` runs the unit project only), CI gains a standalone
`clients/web` `test:integration` step that exercises the real
stdio/HTTP/OAuth/storage paths without coverage instrumentation.
CI is now: validate (fast) → web integration → smoke → storybook. Docs
updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(smoke): give smoke:launcher its own script like the others
smoke:launcher was the odd one out — an inline chain of three
`node clients/launcher/build/index.js ... --help` commands while smoke:cli
/ smoke:tui / smoke:web each run a `scripts/smoke-*.mjs`. Extract it to
`scripts/smoke-launcher.mjs`, which runs the same `--help` / `--cli --help`
/ `--tui --help` dispatch checks but now also asserts each prints that
mode's usage banner (proving the launcher resolved the right client build)
and verifies the required builds exist up front with a clear error. Prints
a `smoke:launcher OK — ...` summary in the same style as the rest. Docs
updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: reorder package.json scripts for readability
Move frequently-run scripts toward the top and rarely-run-by-hand ones
(pretest hooks, lint/format internals, etc.) toward the end, consistently
across the root and client manifests. Pure reordering — no script names or
values changed, and no other manifest content is touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: drop references to removed root inspector / test:storybook scripts
The reordering dropped the root `inspector` and `test:storybook`
aggregators (intentional — `inspector` is redundant with `web`, and
storybook is run from clients/web in CI). Update AGENTS.md so it no longer
points at `npm run inspector`, and run `test:storybook` from `clients/web/`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: address PR #1505 review (format glob gap, fetch-baseline comment)
- TUI format/format:check now also cover the client-root source files
(index.ts, tui.tsx, dev.ts) that live outside src/ — `eslint .` already
lints them, so they were linted but not format-checked. (cli/launcher
have no source outside src/; their root .ts are single-quoted configs
intentionally left out of prettier, matching web.)
- Reword the setup.ts default-fetch comment: it overstated auto-restore
(the unit project sets no restoreMocks/unstubGlobals), and now also
flags that the permissive 200 default means a test asserting on a fetch
FAILURE must install its own rejecting stub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: remove redundant root install:clients script
`install:clients` ran the same `node scripts/install-clients.mjs` as
`postinstall`, which fires automatically on `npm install` — so the manual
alias was redundant. Drop it and the doc references (AGENTS.md, spec).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): skip smoke:tui in CI (Ink needs a real TTY)
This PR made CI run the full `npm run smoke`, which surfaced that
smoke:tui fails in headless CI: the Ink TUI throws "Raw mode is not
supported" on mount (no TTY) and exits before rendering its first frame.
The old CI never ran smoke:tui, so this was latent.
smoke-tui.mjs now self-skips when `process.env.CI` is set — the boot/
render check is inherently local-only (a TUI needs a terminal). The TUI is
still built and unit-tested in CI; `npm run smoke` stays the single
command everywhere (full locally, tui-skipped in CI). Verified
`CI=true npm run smoke` is green (launcher/cli OK, tui SKIPPED, web OK).
Docs + CI comment updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli,tui): add in-process source coverage gates (#1484)
CLI and TUI source had no coverage gate: CLI tests spawned the built
binary out-of-process (coverage never saw clients/cli/src) and the TUI
had only two smoke tests. Regressions there were invisible to CI.
CLI:
- Rewrite __tests__/helpers/cli-runner.ts to invoke runCli() in-process
(capture process.std*.write, map thrown errors to an exit code),
preserving the CliResult shape so all existing tests run unchanged and
clients/cli/src is now measured under v8 instrumentation.
- cli.ts: program.exitOverride() so a commander usage error throws
(catchable in-process) instead of tearing down the worker; --help /
--version still exit cleanly. Drop the dead, never-imported main().
- Add a thin out-of-process e2e.test.ts that spawns the built binary for
the shebang / process.exit paths the in-process runner can't reach.
- Add @vitest/coverage-v8, a per-file gate (90/85/80/50) excluding only
src/index.ts (binary bootstrap), and pool: 'forks' for capture
isolation. cli.ts lands at ~98% lines / 100% functions.
TUI:
- Add @vitest/coverage-v8 and an interim per-file gate over the
non-React logic (logger.ts, components/tabsConfig.ts, utils/*); the
Ink components, App.tsx, and hooks/ are excluded with a documented
follow-up (#1501). Add tests covering the included modules (100%).
Wiring + docs:
- CI runs each client's test:coverage; root test:cli / test:tui (used by
validate) and root test:coverage now enforce the gates.
- gitignore clients/**/coverage; update AGENTS.md and client READMEs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(cli): clarify in-process runner footguns from PR #1502 review
Review notes on the in-process CLI test runner (no behavior change):
- Warn in runCli's docblock that --help/--version must NOT go through the
in-process runner: exitOverride only re-throws non-zero exits, so exitCode
0 falls through to process.exit(0) and would kill the vitest worker. Those
belong in the out-of-process e2e.test.ts layer.
- Document that a timeout orphans the uncancellable invokeCli promise (its
stdio subprocess stays alive; later writes hit the restored stdout) — a
known, acceptable trade-off on the hang-only failure path.
- Fix a stale reference to the removed main() (errors now route through
index.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(cli): fix remaining stale main() reference in vitest exclusion comment
PR #1502 re-review caught a second stale `main()` mention (the first was
fixed in 52c23e41). src/index.ts inlines the bootstrap as
runCli(process.argv).then(...).catch(handleError) — there is no main().
Comment-only; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: compose root test:coverage from per-client scripts
Replace the brittle inline cd-chain with a composition of the existing
test:cli / test:tui scripts plus a new matching test:web. Behavior is
unchanged (web → cli → tui coverage gates) but there's a single source of
truth for each client's coverage invocation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): build dist on demand for prod --web + end-to-end CI smoke (#1486)
Prod `mcp-inspector --web` serves static assets from clients/web/dist,
which only exists after a build. In a fresh checkout it was absent and the
prod path served a broken page (ENOENT on index.html); the launcher smoke
test only covered --help, so this path was unverified in CI.
- ensure-web-build.ts: build dist on demand (vite build via build:client)
when missing, or exit with an actionable error if the build can't run.
- run-web.ts: invoke ensureWebBuild before startHonoServer in the prod path
(--dev runs Vite directly and skips it).
- scripts/smoke-web.mjs + smoke:web: start --web against built dist and
assert GET / returns 200 with the injected __INSPECTOR_API_TOKEN__.
Wired into validate:launcher and a new CI step.
- Docs: launcher README + AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): surface spawn error in build-on-demand; clarify smoke comment
Address PR #1497 review:
- runViteBuild now logs result.error.message (e.g. ENOENT when npm isn't on
PATH) before returning a null status, so the spawn-failure cause surfaces
instead of only the generic actionable error. The build seam gains a `log`
param; added a test covering the ENOENT path.
- Reword scripts/smoke-web.mjs header so it no longer implies the readiness
wait covers a cold build-on-demand vite build (it's sized for a pre-built
dist, which validate/CI guarantee).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).
Closes#1246.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(storybook): wire test:storybook into CI (#1251)
- Add test:storybook:watch to clients/web for interactive iteration; the
one-shot script already existed.
- Surface test:storybook at the repo root, delegating to the web client.
- Add a separate CI step that installs the Playwright Chromium binary
and runs the storybook play-function suite after validate. Kept out of
validate because the suite needs a real browser and is ~10s slower
than the unit tests.
- Document the new pre-push step in AGENTS.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(storybook): cache Playwright browsers + tighten AGENTS wording
Cache the Chromium binary keyed on clients/web/package-lock.json so the
~150MB download is a no-op on CI cache hits. Also clarify that CI runs
test:storybook after the unit/lint/build checks (not literal `validate`,
which is a local-only script).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(coverage): bootstrap vitest unit infra and add tests at 90%+ per file (#1245)
Establishes the unit-test runtime AGENTS.md mandates: a vitest "unit"
project alongside the existing storybook project, RTL + jest-dom +
happy-dom, a Mantine-aware render helper, and a 90%-line per-file
coverage gate enforced by CI. Adds 66 test files covering all 62
components under elements/groups/screens/views, plus core/mcp and
clients/web/src/utils. Total: 577 tests, 98.7% line coverage globally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(coverage): address PR #1269 review feedback
- AGENTS.md: correct coverage gate description (lines ≥ 90, statements ≥ 85,
functions ≥ 80, branches ≥ 50) with rationale for the relaxed branch
threshold
- InspectorView.test.tsx: document what the Math.random() mock values
(0.1 / 0.99) control vs. STUB_SUCCESS_RATE in InspectorView.tsx
- vite.config.ts / tsconfig.test.json: drop unused `globals: true` and the
`vitest/globals` type now that every test file imports describe/it/expect/vi
explicitly; manual `afterEach(cleanup)` in setup.ts remains required because
testing-library auto-cleanup relies on globalThis.afterEach
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clients/web/tsconfig.app.json's include pulls in ../../core/**/*.ts.
TypeScript module resolution from those files walks up from core/mcp/
looking for node_modules, which lands at the repo root. CI only ran
npm install inside clients/web/, so root node_modules was empty and
core/mcp/types.ts / elicitationCreateMessage.ts / taskNotificationSchemas.ts
failed to resolve @modelcontextprotocol/sdk/types.js and zod/v4
(locally the build worked because root had been manually installed).
Add an explicit root npm install step before the clients/web install.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Right now, the inspector has a CLI feature as well as a series of tests.
However, they're not currently run in CI. For the moment, they are only
triggered on file changes in the CLI folder, as AFAICT it is currently a
separate code base.
- Playwright installation can take more than 5m, increase to 15m
- Only attempt to upload the report if Playwright executed, otherwise
there are no report files, and the upload step will error