refactor: self-validating clients, fast validate + separate coverage/smoke (#1504) (#1505)

* 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>
This commit is contained in:
Cliff Hall 2026-06-21 11:41:33 -04:00 • committed by GitHub
commit 5efe9ddc7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 5290 additions and 250 deletions

View file

@ -22,50 +22,30 @@ jobs:
# clients/launcher, so this single step sets up every client. # clients/launcher, so this single step sets up every client.
run: npm install run: npm install
- name: Check formatting - name: Validate (format, lint, build, fast tests)
# Each client self-validates: format:check + lint + build + test (no
# coverage instrumentation — fast). This also builds every client bundle
# (web dist, cli/tui/launcher) that the smokes below need. The per-file
# coverage gate (`npm run coverage`) is intentionally NOT run in CI — run
# it locally before pushing when you want the gate (#1484).
run: npm run validate
- name: Run web integration tests
# `validate` runs the web UNIT project only (fast); the integration
# project (real stdio/HTTP servers, e2e OAuth, filesystem storage) is
# otherwise only reached via test:coverage. Run it here without coverage
# so CI still exercises those paths even though the gate is local-only.
working-directory: ./clients/web working-directory: ./clients/web
run: npm run format:check run: npm run test:integration
- name: Run linter - name: Run cross-client smokes
working-directory: ./clients/web # End-to-end smokes through the built launcher (--help dispatch + prod
run: npm run lint # CLI/web). Not part of any client's `validate`: it needs the
# cli/tui/launcher bundles, which `validate` above already built
- name: Run Build # (smoke:web builds clients/web/dist on demand — #1486). smoke:tui
working-directory: ./clients/web # self-skips here — the Ink TUI needs a real TTY (raw mode) that headless
run: npm run build # CI lacks, so its boot/render check is local-only.
run: npm run smoke
- name: Run tests with coverage
working-directory: ./clients/web
run: npm run test:coverage
- name: Run CLI tests with coverage
# In-process runCli() tests under v8 instrumentation + a thin
# out-of-process E2E layer; enforces the per-file gate for
# clients/cli/src (#1484).
working-directory: ./clients/cli
run: npm run test:coverage
- name: Run TUI tests with coverage
# Enforces the per-file gate for the TUI's non-React logic modules
# (interim scope — see clients/tui/vitest.config.ts and #1501).
working-directory: ./clients/tui
run: npm run test:coverage
- name: Build CLI, TUI, and launcher
run: npm run build:cli && npm run build:tui && npm run build:launcher
- name: Smoke test launcher
run: |
node clients/launcher/build/index.js --help
node clients/launcher/build/index.js --cli --help
node clients/launcher/build/index.js --tui --help
- name: Smoke test prod web launcher (end-to-end)
# Starts `mcp-inspector --web` against the built clients/web/dist (from
# the "Run Build" step) and asserts GET / serves the SPA (HTTP 200) with
# the injected auth token — the prod web path the --help smoke can't
# reach (#1486).
run: npm run smoke:web
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v4 uses: actions/cache@v4

View file

@ -54,10 +54,10 @@ inspector/
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](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`. 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](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 install` at the repo root. - **Fresh clone / first-time setup:** run `npm install` at the repo root.
- **After a pull that changes a client's dependencies:** re-run `npm install` at the root (or `npm run install:clients`) to re-sync every client. - **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client (the `postinstall` cascade 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's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip 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's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it.
After installing, `npm run build` builds all clients. The launcher scripts (`npm run inspector` / `web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. 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`.
## Repository & Project Board ## Repository & Project Board
@ -120,8 +120,9 @@ All work should be driven by items on the project board.
- Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/` - Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/`
- Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`) - Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`)
- Run TUI tests with `npm run test` from `clients/tui/` - Run TUI tests with `npm run test` from `clients/tui/`
- From repo root, `npm run test` runs web unit tests, then CLI and TUI tests - The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/<name> && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration.
- Run `npm run test:coverage` to verify the per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered. From the repo root, `npm run test:coverage` runs the gate for web **and** CLI **and** TUI; each client also exposes its own `test:coverage`. - **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want the gate. **CI does NOT run `coverage`** — the gate is local-only; CI runs `validate` (fast) plus the web integration suite (`clients/web` `test:integration`, no coverage) so the integration paths are still exercised.
- Each client's `test:coverage` enforces a per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered.
- The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web:
- **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is 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.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is 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.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker.
- **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it.
@ -137,10 +138,14 @@ All work should be driven by items on the project board.
### Lint-fixed, Formatted code ### Lint-fixed, Formatted code
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- ALWAYS do `npm run validate` before pushing any changes — from the repo root it mirrors what CI enforces across every client: web `format:check` + `lint` + `build` + `test:coverage` (`validate:web`), then the CLI and TUI suites **with their coverage gates** (`test:cli` / `test:tui`, which now run each client's `test:coverage`), then a build of CLI + TUI + launcher, the launcher `--help` smoke (`smoke:launcher`), the prod CLI and TUI end-to-end smokes (`smoke:cli` / `smoke:tui`), and the prod web end-to-end smoke (`smoke:web`) — together `validate:launcher`. The sub-scripts (`validate:web`, `validate:launcher`, `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web`) can be run individually when iterating on one client. Storybook is the only CI step left out (see below). - ALWAYS do `npm run validate` before pushing any changes — from the repo root it chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in 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).
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`.
- Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. CI does **not** run `coverage` (the gate is local-only); it runs `validate` plus a standalone web `test:integration` step.
- **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the web `test:integration` step, then `smoke`. Storybook is the only CI step left out (see below).
- `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`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/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 (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/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 (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`.
- `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. Both build `test-servers/build` on demand if it's missing. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing.
- Also run `npm run test:storybook` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step after the unit/lint/build checks; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite. - Also run `npm run test:storybook` from `clients/web/` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step (from `clients/web`) after `validate`; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite. (There is no root-level `test:storybook` aggregate — run it in the web client.)
### Typescript instructions ### Typescript instructions
- Use TypeScript for all new code - Use TypeScript for all new code

View file

@ -110,13 +110,21 @@ While the Web Client provides a rich visual interface, the CLI is designed for:
## Development ## Development
Run the test suite (and coverage gate) from `clients/cli/`: Like the other clients, the CLI self-validates from its own folder:
```bash ```bash
npm test # build test-servers + binary, then run all tests npm run validate # format:check && lint && test:coverage
npm run test:coverage # same, under the per-file coverage gate npm test # build test-servers + binary, then run all tests
npm run test:coverage # build + tests under the per-file coverage gate
``` ```
The CLI's `test:coverage` **builds the binary first** (its out-of-process
`e2e.test.ts` spawns it, so it must run against a fresh build). `validate`
therefore folds the build into `test:coverage` rather than repeating it — it is
`format:check && lint && test:coverage`, with no separate `build` step (the
other clients, whose tests don't spawn their bundle, keep an explicit `build`).
The repo-root `validate:cli` just delegates here.
Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured
under coverage, with a thin out-of-process spawn layer for the real binary. See under coverage, with a thin out-of-process spawn layer for the real binary. See
[`__tests__/README.md`](./__tests__/README.md) for details. [`__tests__/README.md`](./__tests__/README.md) for details.

View file

@ -0,0 +1,19 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
// The CLI is plain Node TypeScript (no React/browser), so this mirrors the web
// client's flat config minus the React/Storybook plugins.
export default defineConfig([
globalIgnores(['build', 'coverage']),
{
files: ['**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: globals.node,
},
},
]);

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@
], ],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"pretest": "npm run test-servers:build && npm run build", "validate": "npm run format:check && npm run lint && npm run test",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",
@ -25,7 +25,11 @@
"test:cli": "vitest run cli.test.ts", "test:cli": "vitest run cli.test.ts",
"test:cli-tools": "vitest run tools.test.ts", "test:cli-tools": "vitest run tools.test.ts",
"test:cli-headers": "vitest run headers.test.ts", "test:cli-headers": "vitest run headers.test.ts",
"test:cli-metadata": "vitest run metadata.test.ts" "test:cli-metadata": "vitest run metadata.test.ts",
"pretest": "npm run test-servers:build && npm run build",
"lint": "eslint .",
"format": "prettier --write src __tests__",
"format:check": "prettier --check src __tests__"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
@ -37,10 +41,15 @@
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.4",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }

View file

@ -56,15 +56,16 @@ dependencies are missing — the launcher exits with an actionable error telling
you to run `npm run build` (from `clients/web`) or relaunch with `--dev` to use you to run `npm run build` (from `clients/web`) or relaunch with `--dev` to use
the Vite dev server. `--dev` never needs `dist/`; it runs Vite directly. the Vite dev server. `--dev` never needs `dist/`; it runs Vite directly.
CI and `npm run validate` (via `validate:launcher`) exercise this prod path CI and `npm run validate` (via the top-level `smoke` step) exercise this prod
end-to-end with `npm run smoke:web` (`scripts/smoke-web.mjs`): it starts path end-to-end with `npm run smoke:web` (`scripts/smoke-web.mjs`): it starts
`mcp-inspector --web` against the built `dist/` and asserts `GET /` returns the `mcp-inspector --web` against the built `dist/` and asserts `GET /` returns the
SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`.
### CLI and TUI smokes ### CLI and TUI smokes
`validate:launcher` also runs end-to-end smokes for the other two modes through The top-level `smoke` step also runs end-to-end smokes for the other two modes
the built launcher artifact (beyond the `--help` checks in `smoke:launcher`): through the built launcher artifact (beyond the `--help` checks in
`smoke:launcher`):
- `npm run smoke:cli` (`scripts/smoke-cli.mjs`) — runs `mcp-inspector --cli` - `npm run smoke:cli` (`scripts/smoke-cli.mjs`) — runs `mcp-inspector --cli`
against the bundled stdio test server via a temp `--catalog` and asserts against the bundled stdio test server via a temp `--catalog` and asserts
@ -78,6 +79,21 @@ the built launcher artifact (beyond the `--help` checks in `smoke:launcher`):
Both build `test-servers/build` on demand if it is missing. Both build `test-servers/build` on demand if it is missing.
## Development
Like the web client, the launcher self-validates from its own folder:
```bash
npm run validate # format:check && lint && build && test:coverage
```
This has **no** dependency on the other clients being built — it only checks the
launcher's own source. `eslint.config.js` is a Node-only flat config (the web
client's React/Storybook plugins stripped out), and the per-file coverage gate
covers `parse-launcher-argv.ts` (the pure arg-parsing logic); `src/index.ts` is
excluded as binary bootstrap and is instead exercised by the smokes above. The
repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm run validate`).
## Publishing ## Publishing
The root `@modelcontextprotocol/inspector` package ships as one fat tarball: `npm run build` at the repo root builds all clients, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install. The root `@modelcontextprotocol/inspector` package ships as one fat tarball: `npm run build` at the repo root builds all clients, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install.

View file

@ -23,13 +23,13 @@ describe("parseLauncherArgv", () => {
}); });
it("does not treat a trailing mode-like token as launcher mode", () => { it("does not treat a trailing mode-like token as launcher mode", () => {
expect(parseLauncherArgv([...EXEC, "node", "./server.js", "--cli"])).toEqual( expect(
{ parseLauncherArgv([...EXEC, "node", "./server.js", "--cli"]),
mode: "web", ).toEqual({
forwardedArgv: [...EXEC, "node", "./server.js", "--cli"], mode: "web",
hasPrefixModeFlag: false, forwardedArgv: [...EXEC, "node", "./server.js", "--cli"],
}, hasPrefixModeFlag: false,
); });
}); });
it("rejects multiple mode flags in the launcher prefix", () => { it("rejects multiple mode flags in the launcher prefix", () => {

View file

@ -0,0 +1,19 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
// The launcher is plain Node TypeScript (no React/browser), so this mirrors
// the web client's flat config minus the React/Storybook plugins.
export default defineConfig([
globalIgnores(['build', 'coverage']),
{
files: ['**/*.{ts,mts}'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: globals.node,
},
},
]);

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,25 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"postbuild": "node scripts/make-executable.js", "postbuild": "node scripts/make-executable.js",
"test": "vitest run" "lint": "eslint .",
"format": "prettier --write src __tests__",
"format:check": "prettier --check src __tests__",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"validate": "npm run format:check && npm run lint && npm run build && npm run test"
}, },
"dependencies": { "dependencies": {
"commander": "^13.1.0" "commander": "^13.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.4",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }

View file

@ -34,8 +34,7 @@ try {
const { mode, forwardedArgv, hasPrefixModeFlag } = parsedArgv; const { mode, forwardedArgv, hasPrefixModeFlag } = parsedArgv;
const helpOnly = const helpOnly = process.argv.includes("-h") || process.argv.includes("--help");
process.argv.includes("-h") || process.argv.includes("--help");
if (helpOnly && !hasPrefixModeFlag) { if (helpOnly && !hasPrefixModeFlag) {
program.outputHelp(); program.outputHelp();

View file

@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
include: ['src/**/*.ts'],
exclude: [
// Binary bootstrap: shebang + commander wiring that dynamically imports
// each client's build/index.js and dispatches via process.exit. It only
// runs when launched as the real binary and is covered end-to-end by the
// launcher smokes (smoke:launcher / smoke:cli / smoke:tui / smoke:web).
// Mirrors the cli/web `index.ts` exclusion. The pure arg-parsing logic
// lives in parse-launcher-argv.ts, which is unit-tested and gated below.
'src/index.ts',
],
thresholds: {
perFile: true,
lines: 90,
statements: 85,
functions: 80,
branches: 50,
},
},
},
});

View file

@ -61,13 +61,19 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se
## Development ## Development
Run the test suite from `clients/tui/`: Like the other clients, the TUI self-validates from its own folder:
```bash ```bash
npm run validate # format:check && lint && build && test:coverage
npm test # run all tests npm test # run all tests
npm run test:coverage # run tests under the per-file coverage gate npm run test:coverage # run tests under the per-file coverage gate
``` ```
The repo-root `validate:tui` just delegates here. `eslint.config.js` registers
`react-hooks` for the classic rules only (rules-of-hooks + exhaustive-deps); the
stricter react-hooks@7 rules are not enforced on the interim component surface
(#1501).
Tests live in `__tests__/`. The coverage gate currently covers the TUI's Tests live in `__tests__/`. The coverage gate currently covers the TUI's
non-React logic (server resolution, logger, tab metadata, and the `utils/` non-React logic (server resolution, logger, tab metadata, and the `utils/`
form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in

View file

@ -0,0 +1,33 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
// The TUI is Node + React (Ink) TypeScript. This mirrors the web client's flat
// config minus the browser-only plugins (react-refresh / Storybook).
//
// react-hooks is registered for the two classic rules the Ink components were
// written against (rules-of-hooks + exhaustive-deps, which the source already
// references in inline disable directives). We deliberately do NOT pull in the
// full react-hooks@7 "recommended" set: its newer error-level rules (e.g.
// set-state-in-effect) flag long-standing, intentional patterns in the
// component/hook surface that is itself the interim-excluded one tracked in
// #1501. Enforcing them would require refactoring code out of scope here.
export default defineConfig([
globalIgnores(['build', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks },
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: globals.node,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
]);

File diff suppressed because it is too large Load diff

View file

@ -17,10 +17,14 @@
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"build": "tsup",
"dev": "vite-node --config vitest.config.ts dev.ts", "dev": "vite-node --config vitest.config.ts dev.ts",
"build": "tsup",
"validate": "npm run format:check && npm run lint && npm run build && npm run test",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"lint": "eslint .",
"format": "prettier --write src __tests__ index.ts tui.tsx dev.ts",
"format:check": "prettier --check src __tests__ index.ts tui.tsx dev.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
@ -40,12 +44,18 @@
"ink-select-input": "^6.2.0" "ink-select-input": "^6.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-node": "^6.0.0", "vite-node": "^6.0.0",
"vitest": "^4.1.0" "vitest": "^4.1.0"

View file

@ -149,7 +149,10 @@ function App({
); );
useEffect(() => { useEffect(() => {
getTuiLogger().info({ serverNames: Object.keys(mcpServers) }, "TUI started"); getTuiLogger().info(
{ serverNames: Object.keys(mcpServers) },
"TUI started",
);
}, [mcpServers]); }, [mcpServers]);
const [selectedServer, setSelectedServer] = useState<string | null>(null); const [selectedServer, setSelectedServer] = useState<string | null>(null);
@ -711,7 +714,9 @@ function App({
oauthInProgressRef.current = true; oauthInProgressRef.current = true;
setOauthStatus("authenticating"); setOauthStatus("authenticating");
setOauthMessage(null); setOauthMessage(null);
getTuiLogger().info("OAuth authentication started (Guided Auth advance step)"); getTuiLogger().info(
"OAuth authentication started (Guided Auth advance step)",
);
try { try {
await selectedInspectorClient.proceedOAuthStep(); await selectedInspectorClient.proceedOAuthStep();
const state = selectedInspectorClient.getOAuthState(); const state = selectedInspectorClient.getOAuthState();

View file

@ -6,10 +6,7 @@ import type {
MCPServerConfig, MCPServerConfig,
InspectorClient, InspectorClient,
} from "@inspector/core/mcp/index.js"; } from "@inspector/core/mcp/index.js";
import type { import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/index.js";
AuthGuidedState,
OAuthStep,
} from "@inspector/core/auth/index.js";
const STEP_LABELS: Record<OAuthStep, string> = { const STEP_LABELS: Record<OAuthStep, string> = {
metadata_discovery: "Metadata Discovery", metadata_discovery: "Metadata Discovery",

View file

@ -15,21 +15,21 @@
"build": "tsc -b && vite build && npm run build:runner", "build": "tsc -b && vite build && npm run build:runner",
"build:client": "vite build", "build:client": "vite build",
"build:runner": "tsup --config tsup.runner.config.ts", "build:runner": "tsup --config tsup.runner.config.ts",
"lint": "eslint .", "build:storybook": "storybook build",
"format": "prettier --write src", "validate": "npm run format:check && npm run lint && npm run build && npm run test",
"format:check": "prettier --check src",
"validate": "npm run format:check && npm run lint && npm run build && npm run test:coverage",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest run --project=unit", "test": "vitest run --project=unit",
"test:watch": "vitest --project=unit", "test:watch": "vitest --project=unit",
"test:coverage": "npm run test-servers:build && vitest run --project=unit --project=integration --coverage", "test:coverage": "npm run test-servers:build && vitest run --project=unit --project=integration --coverage",
"test:storybook": "vitest run --project=storybook", "test:storybook": "vitest run --project=storybook",
"test:storybook:watch": "vitest --project=storybook", "test:storybook:watch": "vitest --project=storybook",
"test-servers:build": "tsc -p ../../test-servers --noCheck",
"test:integration": "npm run test-servers:build && vitest run --project=integration", "test:integration": "npm run test-servers:build && vitest run --project=integration",
"test:integration:watch": "npm run test-servers:build && vitest --project=integration" "test:integration:watch": "npm run test-servers:build && vitest --project=integration",
"test-servers:build": "tsc -p ../../test-servers --noCheck",
"lint": "eslint .",
"format": "prettier --write src",
"format:check": "prettier --check src",
"preview": "vite preview",
"storybook": "storybook dev -p 6006"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

Some files were not shown because too many files have changed in this diff Show more