name: CI on: push: # A published GitHub release triggers the `publish` job below. The release's # target commit determines which workflow runs — so this only publishes when a # release is cut from a commit that carries this (v2) workflow. release: types: [published] # Default least-privilege scope for GITHUB_TOKEN. Without this, jobs inherit the # repository's default token permissions, which are broader than any job here # needs (CodeQL `actions/missing-workflow-permissions`). The `publish` and # `publish-github-container-registry` jobs declare their own blocks below, which # override this one entirely rather than adding to it — so each publish job must # continue to list every scope it needs, including `contents: read`. permissions: contents: read # Every job below declares `timeout-minutes` (#2333). It is a HUNG-JOB GUARD, # not a flake remedy: GitHub runners are not the contended machine the local # gate runs on, and nothing measured implicates them — so no value here is # sized for load. The rule, for every job in this file, is roughly TWICE the # slowest run observed, rounded up to the next five minutes: real headroom for # a slow runner, and a hang cut off in minutes instead of at GitHub's # 360-minute default. Each job states the range it was read from; when one # legitimately outgrows its number, raise it here with the new range. jobs: build: runs-on: ubuntu-latest # Observed 7.6–11.8 min across the 40 push runs before this landed # (2026-09-11 → 09-13), all since the coverage split (#2159). A release run # before that split reached 15.6 min, with the coverage gate still inside # this job — that shape no longer exists. timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' - name: Install dependencies (root + all clients) # The root postinstall (scripts/install-clients.mjs) cascades # `npm install` into clients/web, clients/cli, clients/tui, and # clients/launcher, so this single step sets up every client. run: npm install - name: Validate (coverage guards, format, lint, typecheck, build, fast tests) # Runs the five durable guards first (verify:format-coverage, # verify:skills, verify:typecheck-coverage, verify:dep-lockstep, # verify:test-timeouts), then # test:scripts, then validate:core, then each client's # self-validation: format:check + lint + typecheck + build + test (no # coverage instrumentation — fast). This also builds every client bundle # (web dist, cli/tui/launcher) that the smokes below need. The heavier # per-file coverage gate runs in the parallel `coverage` job below # (#2159), which consumes nothing this job produces — every client's # `test:coverage` builds whatever it needs itself. Unit tests run in # both jobs (fast here, instrumented there); now that the two run in # parallel that duplication costs no wall clock at all. (The local gate, # which runs serially, calls `local:validate` instead and runs them once # — #2341. Keep `validate` here: this job IS the fast inner loop.) # A future optimization could split `coverage` into per-client parallel # jobs, but that's a larger restructure and deliberately out of scope. run: npm run validate - name: Validate the skills with the authoritative CLI (#2163) # `npm run validate` above already ran `verify:skills`, whose own parser # reads each SKILL.md the way Claude Code does. This is the second # opinion: `claude plugin validate` is the authoritative schema, and the # guard skips it whenever the CLI is absent — which, without this step, # would mean always, in CI. Installing it here is what makes the # acceptance criterion true rather than aspirational. # # The script resolves the CLI itself — an installed one ONLY when it # matches the pin exactly, otherwise the pinned package via `npx -y` — # so this is the same command `npm run local:gate` runs, and the two # cannot drift. Exact rather than a floor: a newer local CLI is a # DIFFERENT schema, which is how a "reproducible" gate starts disagreeing # across machines. Pinned # rather than @latest: an unpinned validator can start failing a PR that # changed nothing. It needs no authentication — verified with a clean HOME. run: npm run verify:skills:cli - name: Verify the browser-externalized-builtin build gate (#1769) # Runs a real `vite build` with a Node built-in forced into the browser # graph and asserts the build FAILS via the #1769 gate. The unit tests # cover the detection logic against a captured message; only this real # build catches the risk the issue calls out — that the Vite warning # phrasing drifts across releases, silently disabling the message-keyed # gate. It restores the mutated entry afterward (see the script). run: npm run verify:build-gate - name: Verify no externalized dependency was inlined (#2067) # The mirror of the "must be bundled" rule. `undici` was declared only in # the root and `clients/cli` manifests, so tsup — which auto-externalizes # what the NEAREST manifest declares — inlined 1.05MB of it into the web # and TUI bundles. Inlined CommonJS in an ESM bundle throws # `Dynamic require of "assert" is not supported` on first use, and the # rewritten relative specifier meant no user-side install could fix it. # This reads the built output rather than the config, because those two # disagreed for four releases. run: npm run verify:bundle-externals # Playwright chromium is installed BEFORE the smokes because # `smoke:web:browser` (the headless-browser boot smoke, #1615) drives the # prod web bundle in chromium — restoring/installing it here lets that # smoke reuse the cache instead of downloading its own copy. The Storybook # step below reuses the same install. - name: Cache Playwright browsers uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} - name: Install Playwright browsers working-directory: ./clients/web run: npx playwright install --with-deps chromium - name: Run cross-client smokes # NOTE: this workflow runs GitHub CI's tier only. The LOCAL pre-push # gate is `npm run local:gate`, and it runs every check here plus the # local-only ones — do not add it, a `local:*` script, # `smoke:web:firefox`/`smoke:web:webkit`, `smoke:web:engine`, or a # non-Chromium `SMOKE_BROWSER` to any workflow. # `npm run test:scripts` fails if you do (scripts/lib/workflow-gate.mjs, # #2146). `npm run smoke` and `smoke:web:chromium` belong here and are # not affected. The canonical CI-vs-local split is docs/quality-gate.md. # # End-to-end smokes through the built launcher (--help dispatch + prod # CLI/web). Not part of any client's `validate`: it needs the # cli/tui/launcher bundles, which `validate` above already built # (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser # boots the prod web bundle in headless chromium (#1615); smoke:web:app # goes further and drives connect → open app → widget ready against a # composable MCP App server (#1859). Both reuse the chromium installed # above. smoke:tui self-skips here — the Ink TUI needs a real TTY (raw # mode) that headless CI lacks, so its boot/render check is local-only. run: npm run smoke - name: Run Storybook play-function tests working-directory: ./clients/web run: npm run test:storybook # The per-file coverage gate, in its own job so it runs in PARALLEL with # `build` rather than serially after it (#2159). The two together were 84% of # a ~17m wall clock; split, the workflow finishes in roughly the length of # `build` alone. # # This is safe because `coverage` consumes nothing `build` produces: every # client's `test:coverage` is self-sufficient (web and cli build the test # servers — and cli its own bin — themselves; tui and launcher run from # source). The only consumers of `clients/*/build` are the smokes, which stay # in `build` alongside the `validate` that produces them. # # Do NOT "optimize" this back into one job by backgrounding the two commands: # that puts two vitest fleets on one 4-core runner, which is already known to # time tests out at the 5s default. Separate jobs get separate runners. coverage: runs-on: ubuntu-latest # Observed 5.9–8.5 min across the same 40 push runs as `build`. timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' - name: Install dependencies (root + all clients) run: npm install - name: Enforce per-file coverage gate (≥90% on all four dimensions) # CI-ENFORCED coverage gate (#1550): runs `npm run coverage`, which # chains every client's `test:coverage` (v8-instrumented) and fails the # job if ANY file drops below 90% on lines, statements, functions, or # branches. This is the whole point of the job — a PR that regresses # coverage below the threshold blocks merge instead of relying on a # contributor remembering to run the gate locally. # # This also covers the web integration project: web's `test:coverage` # runs `--project=unit --project=integration --coverage`, so there is no # separate integration step anywhere in this workflow. run: npm run coverage # Publish the single `@modelcontextprotocol/inspector` package to npm on a # published GitHub release. v2 is not an npm workspace, so there is no # `publish-all` / `--workspaces` (v1) — just one `npm publish`, whose `prepack` # (`npm run build`) builds every client bundle into the `files` allowlist. # `pack:verify` runs first as the pre-publish gate: it builds, packs the real # tarball, installs it into a clean throwaway consumer, and drives the # installed `mcp-inspector` bin (web/cli/tui) end to end — so a broken package # is caught before it reaches npm rather than after. publish: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release needs: [build, coverage] # Serialize publishes so two releases cut in quick succession can't run # overlapping `npm publish`es. Never cancel an in-flight publish. concurrency: group: publish-npm cancel-in-progress: false # Observed 2.3–2.8 min across the last three releases (2.4.0–2.6.0), of # which `pack:verify` is about half and the only step that reaches the # registry. The publish itself is atomic on npm's side, so a cut-off here # leaves nothing half-published. timeout-minutes: 10 permissions: contents: read # Required for npm provenance (`--provenance` mints a signed attestation # via GitHub's OIDC token). The repo is public, so provenance is available. id-token: write steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' registry-url: 'https://registry.npmjs.org' - name: Assert release tag matches package version # `npm publish` ships whatever `version` is in the root package.json, # regardless of the release's tag. Fail fast (before the heavy install / # pack:verify) if they disagree — e.g. a release drafted without running # `npm version`, or cut from the wrong commit — since publishing is # irreversible. Tolerates the conventional leading `v` (npm version tags # as `vX.Y.Z`). The tag arrives via `env:` (not spliced into the script) # to avoid the script-injection surface of interpolating `${{ }}` into a # `run:` block. env: TAG: ${{ github.event.release.tag_name }} run: | PKG="$(node -p "require('./package.json').version")" if [ "${TAG#v}" != "$PKG" ]; then echo "Release tag '$TAG' does not match package.json version '$PKG'" exit 1 fi # OIDC trusted publishing requires npm >= 11.5.1; Node 22's bundled npm is # 10.x, which fails with ENEEDAUTH before OIDC is ever attempted. - name: Ensure npm CLI supports OIDC trusted publishing run: npm install -g npm@^11.5.1 - name: Install dependencies (root + all clients) run: npm install - name: Verify the publishable tarball end to end # Builds, `npm pack`s, installs the tarball into a clean consumer, and # drives the installed bin. Needs registry access to pull the tarball's # runtime deps — available here. `smoke:tui` inside it self-skips on CI. run: npm run pack:verify - name: Publish to npm (single package, with provenance) # `prepack` (`npm run build`) rebuilds the client bundles into the tarball. # The build runs three times on this path (build job → pack:verify → # prepack); the redundancy is intentional — each is a clean-tree rebuild # and the `prepack` one is what actually populates the published tarball, # so don't "optimize" it away. # # The dist-tag is derived from the version, and passing it explicitly is # NOT optional: `npm publish` defaults to `--tag latest` regardless of # semver prerelease status, so publishing `2.0.0-rc.1` without this would # point every `npx @modelcontextprotocol/inspector` at a release # candidate. A prerelease is a hyphen after the patch component # (`2.0.0-rc.1`); build metadata uses `+` and is not a prerelease. Done # in shell rather than with `semver` because that package is only a # transitive dependency here and must not be relied on in CI. # # There is deliberately NO `NODE_AUTH_TOKEN` here. Publishing uses npm # OIDC trusted publishing (`id-token: write` + `environment: release`), # which needs no token — and the repo has no `NPM_TOKEN` secret. Setting # it from a non-existent secret writes an EMPTY `_authToken` into the # `.npmrc` that `setup-node` generates, and npm then fails `ENEEDAUTH` # before OIDC is ever attempted. Do not "restore" it. run: | VERSION="$(node -p "require('./package.json').version")" case "$VERSION" in *-*) NPM_TAG=next ;; *) NPM_TAG=latest ;; esac echo "Publishing $VERSION under dist-tag '$NPM_TAG'" npm publish --access public --provenance --tag "$NPM_TAG" # Build and push the multi-arch container image to GHCR on a published # release. The image installs the packed tarball (`Dockerfile`) so it ships # the same artifact as npm. Independent of the npm `publish` job (both gated on # the release event) — a container failure doesn't block the npm publish. publish-github-container-registry: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release needs: [build, coverage] # Observed 14.5–15.4 min across the last three releases (2.4.0–2.6.0); # the linux/arm64 half of the build runs under QEMU and is most of it. timeout-minutes: 35 permissions: contents: read packages: write attestations: write # Lets `attest-build-provenance` write the artifact metadata storage # record alongside the provenance itself (#2228). See the long comment on # the "Generate artifact attestation" step for why this is separate from # `attestations: write` and what to check after the next release. artifact-metadata: write id-token: write steps: - name: Checkout code uses: actions/checkout@v7 - name: Log in to the Container registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} # Be explicit rather than relying on `flavor.latest=auto`: on a release # cut both the version tags and `latest` land, so the README's bare # `ghcr.io/…/inspector` (implicit `:latest`) never 404s. tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} flavor: | latest=true - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build and push Docker image id: push uses: docker/build-push-action@v7 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation # Two scopes, two different things, and only the first is the # attestation (#2228). `attestations: write` persists the signed SLSA # provenance — that half has always worked, and # `gh attestation verify oci://ghcr.io/modelcontextprotocol/inspector: # --repo modelcontextprotocol/inspector` passed on 2.5.0 without the # second scope. `artifact-metadata: write` persists the separate # *storage record*: GitHub's org-level index of where a published # artifact lives (registry, active/eol status), surfaced at # https://github.com/orgs/modelcontextprotocol/artifacts. The action # emits one automatically when `push-to-registry` is true AND the # workflow carries this scope; with only the first condition met, every # release logged two warning annotations on an otherwise-green job: # # Failed to create storage record: Error: Failed to persist storage # record: no artifacts found # Please check that the "artifact-metadata:write" permission has been # included # # `no artifacts found` reads like it is about this job uploading no # *workflow* artifact, and is not: it is the generic 404 body of the org # artifact-metadata API that @actions/attest POSTs to. The same message # comes back from the sibling read endpoint for 2.5.0's real digest, and # from an all-zeros digest that cannot exist — so it carries no # information beyond "nothing resolved", and the documented precondition # we were failing is this permission. # # ⚠️ Confirm at the next release rather than assuming: the job should be # annotation-free, and # `gh api /orgs/modelcontextprotocol/artifacts//metadata/storage-records` # should return a record instead of 404. If the warning persists, the # honest fix is `create-storage-record: false` plus a note here saying # the record is unavailable to us — not carrying an unexplained warning. uses: actions/attest-build-provenance@v4 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true