From 941bf13587944186848810650664a2bfb11b9a7a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 00:36:39 -0400 Subject: [PATCH 1/3] ci(docker): add v2 Dockerfile + GHCR publish job (#1646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 published a container image but v2 arrived with no Dockerfile (the client/server/cli layout is gone). Add a v2 image for the single-package / launcher architecture and wire the release-gated GHCR publish job. - `Dockerfile`: two-stage build — stage 1 installs + `npm pack`s the publishable tarball; stage 2 `npm install -g`s that tarball, so the image ships the exact same artifact as npm with a clean `mcp-inspector` bin. Defaults to `--web` on `0.0.0.0:6274`, browser auto-open off. `.dockerignore` keeps the context lean and forces a clean in-container install/build. - `publish-github-container-registry` job in main.yml (ported from v1): release-gated, multi-arch (amd64/arm64) build+push to GHCR with a provenance attestation. Independent of the npm `publish` job. - README: document the image + how to run it; the release section now covers both publish jobs. Verified locally: `docker build` succeeds, the container serves `/` (HTTP 200 with the injected token) and dispatches `--cli` / `--tui`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .dockerignore | 32 ++++++++++++++++++++++ .github/workflows/main.yml | 54 ++++++++++++++++++++++++++++++++++++++ Dockerfile | 36 +++++++++++++++++++++++++ README.md | 20 ++++++++++++-- 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..df1dad5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Keep the build context small and force a clean, in-container install/build. +# Host node_modules and build outputs are excluded so nothing stale or +# wrong-architecture is copied in — the builder stage runs its own +# `npm install && npm pack`. + +.git +.github + +# Installed deps (root + every client) — reinstalled in the image +node_modules +clients/*/node_modules +test-servers/node_modules + +# Build outputs — rebuilt in the image +clients/*/build +clients/web/dist +test-servers/build +storybook-static +coverage + +# Packed tarballs (the image makes its own) +*.tgz + +# Local / editor / env +.env +.env.* +.DS_Store +.vscode +.idea +.claude +.playwright-mcp +*.log diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 74c16615..2e4fbf0c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -146,3 +146,57 @@ jobs: run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # 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@v6 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + 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@v3 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..07fddb10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Build stage — install everything, build all clients, and pack the exact +# tarball npm would publish. `npm install` runs the root postinstall cascade +# (per-client installs, since v2 is not a workspace); `npm pack` runs `prepack` +# (`npm run build`) and emits `modelcontextprotocol-inspector-.tgz`. +# --------------------------------------------------------------------------- +FROM node:22-slim AS builder +WORKDIR /build +COPY . . +RUN npm install && npm pack + +# --------------------------------------------------------------------------- +# Runtime stage — install just the packed tarball (production deps only), +# yielding a clean global `mcp-inspector` bin: the same artifact a user gets +# from `npm i -g @modelcontextprotocol/inspector`. The package's postinstall +# early-exits under node_modules, and the tarball ships only each client's +# build/ output, so there is nothing to build here. +# --------------------------------------------------------------------------- +FROM node:22-slim AS runner +ENV NODE_ENV=production + +COPY --from=builder /build/modelcontextprotocol-inspector-*.tgz /tmp/inspector.tgz +RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz + +# Serve on all interfaces so the UI is reachable from outside the container, and +# never try to open a browser from inside it. +ENV HOST=0.0.0.0 \ + CLIENT_PORT=6274 \ + MCP_AUTO_OPEN_ENABLED=false +EXPOSE 6274 + +# Default to the web UI; override the args to run --cli / --tui. +ENTRYPOINT ["mcp-inspector"] +CMD ["--web"] diff --git a/README.md b/README.md index 500a5641..91f3e518 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,10 @@ The `smoke:*` scripts run against the in-repo build tree, which is **not** the p ### Cutting a release -Publishing is automated by the `publish` job in [`.github/workflows/main.yml`](.github/workflows/main.yml), gated on a **published GitHub release** (`github.event_name == 'release'`). On release it runs `npm run pack:verify` as the pre-publish gate, then `npm publish --access public --provenance` — a single `npm publish` (v2 is not an npm workspace, so there is no v1-style `publish-all`/`--workspaces`), with a signed provenance attestation via GitHub OIDC (`id-token: write`, `environment: release`, `NPM_TOKEN`). +Publishing is automated by two release-gated jobs in [`.github/workflows/main.yml`](.github/workflows/main.yml) (`github.event_name == 'release'`, both `needs: build`): + +- **`publish`** — the npm package. Runs `npm run pack:verify` as the pre-publish gate, asserts the release tag matches the root `package.json` version, then `npm publish --access public --provenance` — a single `npm publish` (v2 is not an npm workspace, so there is no v1-style `publish-all`/`--workspaces`), with a signed provenance attestation via GitHub OIDC (`id-token: write`, `environment: release`, `NPM_TOKEN`). +- **`publish-github-container-registry`** — the container image (see [Docker](#docker)). Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: @@ -162,7 +165,20 @@ git push --follow-tags The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. -> **Docker / GHCR is not wired up yet.** v1 published a container image to GHCR, but v2 has no Dockerfile (its `client/server/cli` layout is gone). A v2 image + GHCR job is tracked separately in [#1646](https://github.com/modelcontextprotocol/inspector/issues/1646). +### Docker + +A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin. + +```bash +# run the web UI (reads the auth token from the container logs) +docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector + +# or build the image locally +docker build -t mcp-inspector . +docker run --rm -p 6274:6274 mcp-inspector +``` + +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. ## Contributing — `AGENTS.md` and `CLAUDE.md` From f5e095e8f0c601f0b7a74968f631c1e4e3038ff6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 09:43:04 -0400 Subject: [PATCH 2/3] review: harden the Docker image + align GHCR action versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #1648 review: - Bump the GHCR action majors to match current v1 (docker/login-action v4, docker/metadata-action v6, docker/setup-buildx-action v4, actions/attest-build-provenance v4) to avoid Dependabot churn. - Make the image tags explicit (`type=semver` + `flavor: latest=true`) instead of relying on `flavor.latest=auto`, so a release lands both the version tags and `:latest` — the README's bare `ghcr.io/…/inspector` URL won't 404. - Run the container as the non-root `node` user with /home/node as workdir (its writable home is where the inspector stores the catalog / OAuth state). - Add a HEALTHCHECK that probes `/` via Node's global fetch (no curl in slim). - Trim specification/ and docs/ from the build context. (test-servers/ stays — `tsc -b` typechecks the integration tests that import its alias, so excluding it breaks the build.) Re-verified locally: `docker build` succeeds, the container runs as uid 1000 (node), serves `/` (200), and the HEALTHCHECK reports healthy. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .dockerignore | 6 ++++++ .github/workflows/main.yml | 16 ++++++++++++---- Dockerfile | 11 +++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index df1dad5e..5f7a290f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,12 @@ test-servers/build storybook-static coverage +# Docs/specs aren't referenced by the build — trim them from the context. +# (test-servers/ stays: `tsc -b` typechecks the integration tests that import +# the `@modelcontextprotocol/inspector-test-server` alias.) +specification +docs + # Packed tarballs (the image makes its own) *.tgz diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2e4fbf0c..fbdd32a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@v6 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -174,15 +174,23 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + 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@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push Docker image id: push @@ -195,7 +203,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@v4 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/Dockerfile b/Dockerfile index 07fddb10..c41c415e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,17 @@ ENV HOST=0.0.0.0 \ MCP_AUTO_OPEN_ENABLED=false EXPOSE 6274 +# Run as the non-root `node` user the base image ships. Its home (/home/node) is +# where the inspector writes runtime state (the default catalog, OAuth token +# storage), so use it as the working directory. +USER node +WORKDIR /home/node + +# Report readiness by probing the served SPA (`/` needs no auth). Uses Node's +# global fetch — no curl/wget in the slim image. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.CLIENT_PORT||6274)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + # Default to the web UI; override the args to run --cli / --tui. ENTRYPOINT ["mcp-inspector"] CMD ["--web"] From 070f02e85f9b57267eac00eac67cef68319625dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 09:47:25 -0400 Subject: [PATCH 3/3] review: set HOME explicitly + note healthcheck assumes --web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the re-review's two optional notes: - Set `ENV HOME=/home/node` so the runtime-state dir (core/storage/store-io.ts resolves it from HOME) is explicit, rather than relying on the "." → WORKDIR fallback that happens to work because Docker doesn't set HOME on USER. - Document that the HEALTHCHECK assumes the default --web mode; suggest `--no-healthcheck` for --cli/--tui runs (no web server to probe). Re-verified: build ok, HOME=/home/node, runs as node (uid 1000), `/` → 200, health → healthy. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- Dockerfile | 12 ++++++++---- README.md | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index c41c415e..0651b7d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,14 +31,18 @@ ENV HOST=0.0.0.0 \ MCP_AUTO_OPEN_ENABLED=false EXPOSE 6274 -# Run as the non-root `node` user the base image ships. Its home (/home/node) is -# where the inspector writes runtime state (the default catalog, OAuth token -# storage), so use it as the working directory. +# Run as the non-root `node` user the base image ships. The inspector resolves +# its runtime-state dir (default catalog, OAuth token storage) from `HOME` +# (core/storage/store-io.ts), so set it explicitly to the node user's writable +# home and work from there. +ENV HOME=/home/node USER node WORKDIR /home/node # Report readiness by probing the served SPA (`/` needs no auth). Uses Node's -# global fetch — no curl/wget in the slim image. +# global fetch — no curl/wget in the slim image. Assumes the default `--web` +# mode; running `--cli`/`--tui` has no web server, so add `--no-healthcheck` to +# `docker run` for those. HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.CLIENT_PORT||6274)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" diff --git a/README.md b/README.md index 91f3e518..1cc32ed6 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ docker build -t mcp-inspector . docker run --rm -p 6274:6274 mcp-inspector ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md`