Corporate-proxy users could not connect to any remote MCP server from v2. Two independent defects, both reported in #2067. 1. The web and TUI bundles inlined undici. It was declared in the root and `clients/cli` manifests but named in no client's tsup `external` list, and tsup auto-externalizes only what the *nearest* manifest declares — so esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM `__require` shim and threw `Dynamic require of "assert" is not supported`. The rejected `import()` surfaced as "the `undici` package is not available", telling users to install a package that was already present — and could not have helped, because the specifier had been rewritten to a relative chunk at build time. 2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into Node's *built-in* fetch. That couples two different copies of undici at the dispatcher handler interface, which is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected with `fetch failed: invalid onRequestStart method`, and the request never leaves the process. So fixing the bundling alone would only have converted the web/TUI failure into the CLI's. Both halves of the pair now come from userland undici — its `fetch` and its `EnvHttpProxyAgent` — which keeps the handler inside one copy and works unchanged from the Node 22.19 engine floor through Node 26. Deleting the wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option: measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x. Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is false for callers that test it — including the SDK's OAuth error formatter, which would degrade every message to `Raw body: [object Response]`. The proxy now sits at the bottom of the fetch stack (`environment.fetch` for the Node clients, and the `createTransportNode` default for the web backend, its one caller that supplies no `fetchFn`) rather than wrapping the transport's fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping a supplied `fetchFn` would discard it; installing it underneath lets InspectorClient's own wrappers compose over it. A side effect worth having: OAuth discovery and token requests go through `environment.fetch` too, and were never proxied before. Guarding it: - `undici` is added to all three tsup `external` lists and removed from `clients/cli/package.json`, so it is root-declared only, per the placement rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli 8.9.0). - New `npm run verify:bundle-externals` fails if any package a client declares external was inlined into that client's bundle anyway. It reads the *built output*, not the config — those two disagreed for four releases — and derives what to check from each client's own `external` array, so a newly externalized package is covered without editing the guard. Verified against the real pre-fix artifact, which it flags. - The proxy tests now drive a real forwarding proxy rather than a mock. That matters: defect 2 was a runtime interface mismatch, which no mock can catch because no mock ever dispatches. AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici slipped past it, and notes the `node -e` trap — probing the bundled chunk with `node -e` falsely succeeds, because it exposes a global `require` that satisfies esbuild's guard. Closes #2067 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
275 lines
13 KiB
YAML
275 lines
13 KiB
YAML
name: Run install, format, lint, build, and test on every push
|
|
|
|
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
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
|
|
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 two coverage guards first (verify:format-coverage +
|
|
# verify:typecheck-coverage), 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 its own step below. Unit tests run in
|
|
# both steps (fast here, instrumented under coverage); the double unit
|
|
# run is an accepted trade-off for keeping this fast build step intact.
|
|
# 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: 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 step — a PR that regresses
|
|
# coverage below the threshold now 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 the former
|
|
# standalone `Run web integration tests` step was removed to avoid
|
|
# running the integration suite twice.
|
|
run: npm run coverage
|
|
|
|
- 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
|
|
# 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
|
|
|
|
# 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
|
|
# 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
|
|
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
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
attestations: 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
|
|
uses: actions/attest-build-provenance@v4
|
|
with:
|
|
subject-name: ghcr.io/${{ github.repository }}
|
|
subject-digest: ${{ steps.push.outputs.digest }}
|
|
push-to-registry: true
|