Skip to content

Merge pull request #2655 from modelcontextprotocol/v2/chore/2653-bump… #6441

Merge pull request #2655 from modelcontextprotocol/v2/chore/2653-bump…

Merge pull request #2655 from modelcontextprotocol/v2/chore/2653-bump… #6441

Workflow file for this run

name: CI
on:
push:
# A published GitHub release triggers the `package` → `publish` jobs 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 `package`, `publish`
# and `publish-github-container-registry` jobs declare their own blocks below,
# which override this one entirely rather than adding to it — so each must list
# exactly the scopes it needs and no more. `publish` deliberately omits
# `contents: read`: it has no checkout, and its only scope is `id-token: write`
# (#2483). Do not re-add scopes to it.
permissions:
contents: read
# Serialize whole RELEASE runs, never cancelling an in-progress one (#2483).
# Packaging and publishing are separate jobs, so a job-level group on `publish`
# alone would let two releases race through `package` and publish in the order
# their packaging happened to finish — and because the dist-tag is passed
# explicitly, an older release finishing last would move `latest` back to it.
# Holding the group for the whole run restores what the single pre-split job
# gave: one release run at a time, packaging and publishing together.
# Push runs get a unique group (their run id), so they are unaffected.
# ⚠️ This is mutual exclusion, NOT a version-order guarantee. GitHub orders a
# group's queue by when each run starts waiting, not by release order, and
# keeps only ONE pending run per group, so a third release cut while one runs
# and one waits cancels the waiting one. Cut releases one at a time, and
# re-run a cancelled one by hand.
concurrency:
group: ${{ github.event_name == 'release' && 'release-npm' || format('run-{0}', github.run_id) }}
cancel-in-progress: false
# The `package`, `publish` and `publish-github-container-registry` jobs pin
# every action to a commit SHA with a `# vX.Y.Z` comment; `build` and
# `coverage` stay on moving major tags (#2235). A job holding a credential — or
# building what one publishes — must not run code a moved tag can replace
# (#2484); `npm run verify:action-pins` enforces it, and the monthly
# dependency-refresh sweep reads the comment to report a newer release.
# 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 runs for real here too (#2408): it gives the Ink TUI
# a pseudoterminal through util-linux script(1), which ubuntu-latest
# ships, and pins CI=false for the child so Ink renders interactively.
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 package.
#
# Two jobs, split on the credential (#2483). `package` does everything that
# executes third-party code — the dependency install (whose lifecycle scripts
# and the root `postinstall` client cascade all run), `pack:verify`, and the
# `npm pack` that builds the tarball — and holds NO `id-token`. `publish`
# holds `id-token: write` and does nothing but download that tarball and hand
# it to `npm publish`: no checkout, no dependency install, no build. Before
# the split both halves shared one job, so any install script from any
# transitive dependency ran in the process environment carrying the OIDC
# request variables and could mint a token and publish under the
# trusted-publisher identity. Permissions are scoped per JOB, not per step,
# so a separate job is the only way to keep that token away from the
# install. Do not fold them back together, and do not add an install or a
# checkout to `publish`.
package:
runs-on: ubuntu-latest
if: github.event_name == 'release'
needs: [build, coverage]
# Carries over the old single `publish` job's budget: it was observed at
# 2.3–2.8 min across the last three releases (2.4.0–2.6.0), of which
# `pack:verify` is about half, and all of that work now lives here.
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.x'
cache: 'npm'
- 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
- 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. Its TUI check is `--tui --help` only;
# the real TUI boot is `smoke:tui`, which the `build` job runs.
run: npm run pack:verify
- name: Pack the tarball to publish
# `pack:verify` deletes its own tarball on success, so this packs the
# one that ships. `prepack` (`npm run build`) runs here, so the build
# happens three times on this path (build job → pack:verify → this
# step); the redundancy is intentional — each is a clean-tree rebuild
# and this one is what actually populates the published tarball, so
# don't "optimize" it away. `publish` never builds: publishing a `.tgz`
# runs no lifecycle scripts, so what this step packs is what ships.
run: |
mkdir -p release-tarball
npm pack --pack-destination release-tarball
ls -l release-tarball
- name: Upload the tarball for the publish job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-tarball
path: release-tarball/*.tgz
if-no-files-found: error
retention-days: 7
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
# npm's trusted-publisher config keys off this workflow file and the
# `release` environment, so the environment stays on the job that
# publishes. `package` deliberately does not take it.
environment: release
needs: [package]
# Serialize publishes so two releases cut in quick succession can't run
# overlapping `npm publish`es. Never cancel an in-flight publish. The
# workflow-level `concurrency` above already orders whole release runs;
# this group is the narrower guarantee kept on the job that publishes.
concurrency:
group: publish-npm
cancel-in-progress: false
# Unmeasured since the split (#2483): it now only downloads an artifact,
# installs one pinned npm and publishes, so the old job's 10-minute budget
# is a generous ceiling. Re-size it from observed runs after a release or
# two. The publish itself is atomic on npm's side, so a cut-off here
# leaves nothing half-published.
timeout-minutes: 10
permissions:
# Required for npm provenance (`--provenance` mints a signed attestation
# via GitHub's OIDC token) and for trusted publishing. This is the only
# job in the workflow that runs npm with this token, and it deliberately
# executes no dependency code — see the comment on `package`.
id-token: write
env:
# The release tag, re-checked against the tarball below. It arrives via
# `env:` rather than spliced into the script, as in `package`.
TAG: ${{ github.event.release.tag_name }}
steps:
- name: Setup Node.js
# No `cache: 'npm'`: there is no checkout, so no lockfile to key it on,
# and nothing here installs dependencies to cache.
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
# 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. Pinned
# EXACTLY, not to a range (#2483): this install runs in the job that
# holds `id-token: write`, so it is the one package this job trusts, and
# a range would let whatever 11.x is current at release time into it.
# `--ignore-scripts` keeps even that package's lifecycle scripts out.
# Bump deliberately; stay on 11.x until 12's Node floor is checked.
- name: Install the pinned npm CLI (OIDC trusted publishing)
run: npm install -g --ignore-scripts npm@11.20.0
- name: Download the tarball built by the package job
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-tarball
path: release-tarball
- name: Publish to npm (single package, with provenance)
# Publishes the tarball `package` built — a file argument, so npm runs
# no lifecycle scripts and builds nothing here. Exactly one tarball is
# expected; anything else is a packaging fault, not something to pick
# from.
#
# 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. The
# version is read from the tarball's own `package.json`, since there is
# no checkout — and that file is what npm publishes anyway.
#
# 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: |
# `./` is load-bearing: `npm publish` reads a bare `dir/file`
# argument as GitHub `owner/repo` shorthand and tries to clone it
# over SSH instead of publishing the local tarball (#2551).
set -- ./release-tarball/*.tgz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "Expected exactly one tarball in release-tarball/, found: $*"
exit 1
fi
TARBALL="$1"
MANIFEST="$(tar -xOzf "$TARBALL" package/package.json)"
# The tarball was built after dependency install scripts ran, so its
# manifest is untrusted input here. npm merges a manifest's
# `publishConfig` into its own config before the OIDC exchange, so an
# injected registry, proxy or TLS setting could redirect this job's
# credential flow. This package declares no `publishConfig`, so any
# present is refused outright, and the registry is pinned on the
# command line below as well.
if printf '%s' "$MANIFEST" | node -e "process.exit('publishConfig' in JSON.parse(require('fs').readFileSync(0, 'utf8')) ? 0 : 1)"; then
echo "Refusing to publish: the tarball's package.json declares publishConfig"
exit 1
fi
# `package` asserts tag == version BEFORE its install runs, so that
# check cannot vouch for what was packed afterwards: an install
# script could rewrite the root manifest. Re-assert the tarball's own
# name and version against the expected package and the release tag.
NAME="$(printf '%s' "$MANIFEST" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")"
VERSION="$(printf '%s' "$MANIFEST" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")"
if [ "$NAME" != "@modelcontextprotocol/inspector" ]; then
echo "Refusing to publish: tarball package name is '$NAME'"
exit 1
fi
if [ "${TAG#v}" != "$VERSION" ]; then
echo "Refusing to publish: tarball version '$VERSION' does not match release tag '$TAG'"
exit 1
fi
case "$VERSION" in
*-*) NPM_TAG=next ;;
*) NPM_TAG=latest ;;
esac
echo "Publishing $TARBALL ($VERSION) under dist-tag '$NPM_TAG'"
npm publish "$TARBALL" --registry https://registry.npmjs.org/ --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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
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@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build and push Docker image
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
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:<version>
# --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/<digest>/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@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true