Skip to content

Add v2 Dockerfile + GHCR publish job - #1648

Merged
cliffhall merged 3 commits into
v2/mainfrom
1646-v2-docker-ghcr
Jul 11, 2026
Merged

cliffhall merged 3 commits into
v2/mainfrom
1646-v2-docker-ghcr

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #1646

Decides + wires the v2 Docker image and GHCR publish (parent tracking: #1636). Decision: keep shipping a container image (v1 had one — dropping it would regress users), now built for the single-package / launcher architecture.

What's added

  • Dockerfile — two-stage build:

    1. install everything + npm pack the publishable tarball;
    2. npm install -g that tarball into a slim node:22-slim runtime.

    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 with browser auto-open off; args override to run --cli / --tui. .dockerignore keeps the build context lean and forces a clean in-container install/build.

  • publish-github-container-registry job in main.yml (ported from v1): release-gated (needs: build), multi-arch (linux/amd64 + linux/arm64) build & push to ghcr.io/${{ github.repository }} with a provenance attestation. Independent of the npm publish job — a container failure won't block the npm publish.

  • README — documents the image + docker run/docker build, and the release section now covers both publish jobs.

Verified locally

docker was available, so I build-tested the image (not just wrote it):

  • docker build . → success (~797 MB, node:22-slim keeps glibc for the native @napi-rs/keyring).
  • docker run -p 6274:6274 → GET / returns 200 with the injected __INSPECTOR_API_TOKEN__; banner shows web + sandbox up, no keyring/libsecret errors.
  • docker run … --cli --help → Usage: inspector-cli; --tui --help → Usage: mcp-inspector-tui.

Notes

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Jul 11, 2026
@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 1m 46s —— View job


Review: v2 Dockerfile + GHCR publish job

  • Read changed files (Dockerfile, .dockerignore, main.yml, README.md)
  • Analyze Dockerfile build/runtime correctness
  • Analyze GHCR publish job
  • Cross-check against v1 job & repo conventions
  • Post review

Overall this is a clean, well-documented PR. The two-stage "pack-then-install-the-tarball" approach is the right call — the image ships the exact artifact npm publishes, so there's no drift between npx and docker run. I verified the three env vars the Dockerfile sets actually match what the code reads:

  • HOST → web-server-config.ts:213, CLIENT_PORT → web-server-config.ts:212, MCP_AUTO_OPEN_ENABLED → web-server-config.ts:273. ✅ All correct.

Nothing blocking. A few observations, roughly in priority order.

1. Action major versions are behind current v1 (.github/workflows/main.yml)

The PR says the job is "ported from v1", but it pins older majors than the v1 workflow currently uses:

Action This PR v1 (origin/main)
docker/login-action @v3 @v4
docker/metadata-action @v5 @v6
docker/setup-buildx-action @v3 @v4
actions/attest-build-provenance @v3 @v4

(setup-qemu-action@v3 and build-push-action@v6 already match.) These all still work, but bumping to match v1 avoids immediate Dependabot churn and keeps the two workflows consistent. Worth aligning while it's fresh. (Note: I can't edit files under .github/workflows/ — GitHub App permissions block workflow modifications — so this is a suggestion for you to apply.)

2. Confirm the :latest tag actually gets pushed

The README's primary example is docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector — i.e. the implicit :latest. docker/metadata-action is used with no tags: input, so it relies on the default tag set + flavor.latest=auto. This mirrors the v1 config exactly, so if ghcr.io/modelcontextprotocol/inspector:latest is populated today it'll keep working — but since the release push can't be exercised outside a real release (as the PR notes), it's worth a conscious check on the first release that both the version tag and latest land, so the README's bare-URL example doesn't 404. If you want to be explicit rather than rely on auto, an explicit tags: block with type=semver + flavor: latest=true removes the ambiguity.

3. Container runs as root (Dockerfile)

node:22-slim ships a non-root node user, but the image doesn't switch to it, so the inspector runs as root and writes its runtime state (catalog file, OAuth token storage) under /root. Adding USER node (and ensuring its home is writable) is a low-effort hardening step. Optional — not a regression from v1 — but good practice for a published image.

Minor / non-blocking

  • No HEALTHCHECK — optional, but a GET / probe would let orchestrators know when the web UI is ready.
  • .dockerignore doesn't exclude test-servers/ or specification/ from the build context. They're installed/built in the builder but not in the packed tarball (root files allowlist), so this is purely a slightly larger context, not a correctness issue.

Nice work verifying the build/run locally — the keyring/libsecret note (glibc via node:22-slim) is exactly the kind of thing that bites Alpine-based images, so good catch keeping it on -slim.

@cliffhall cliffhall linked an issue Jul 11, 2026 that may be closed by this pull request
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
@cliffhall

Copy link
Copy Markdown
Member Author

Thanks for the review! Applied all of it (re-verified with a local docker build + run):

1. Action versions — bumped to match current v1: docker/login-action@v4, docker/metadata-action@v6, docker/setup-buildx-action@v4, actions/attest-build-provenance@v4. (setup-qemu@v3 / build-push@v6 already matched.)

2. Explicit :latest — replaced the implicit flavor.latest=auto with tags: type=semver,pattern={{version}} + {{major}}.{{minor}} and flavor: latest=true, so a release lands both the version tags and :latest — the README's bare-URL example won't 404.

3. Non-root — added USER node + WORKDIR /home/node. Verified the container now runs as uid=1000(node) and still serves / (200).

4. HEALTHCHECK — added a / probe via Node's global fetch (no curl/wget in slim). Verified it goes starting → healthy.

5. .dockerignore — trimmed specification/ + docs/. One correction: test-servers/ can't be excluded — npm run build's tsc -b typechecks the integration tests that import @modelcontextprotocol/inspector-test-server, so removing it fails the build (confirmed). Left it in.

Re-requesting review.

@claude review

@claude

claude Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 1m 6s —— View job


Re-review: v2 Dockerfile + GHCR publish job

  • Read updated files (Dockerfile, .dockerignore, main.yml, README.md)
  • Verify each of the 5 applied changes
  • Check for anything newly introduced
  • Post review

All five items are applied correctly. Verified against the code:

1. Action versions ✅ login-action@v4, metadata-action@v6, setup-buildx-action@v4, attest-build-provenance@v4 — matches current v1.

2. Explicit :latest ✅ type=semver,pattern={{version}} + {{major}}.{{minor}} with flavor: latest=true. On a release cut from tag vX.Y.Z this lands X.Y.Z, X.Y, and latest, so the README's bare-URL example won't 404. Nicely commented too.

3. Non-root ✅ USER node + WORKDIR /home/node. I traced the runtime-state path to confirm this doesn't break writes: core/storage/store-io.ts:23 resolves the storage dir as process.env.HOME || process.env.USERPROFILE || ".". Docker doesn't auto-set HOME on USER node, so it falls through to "." — which resolves against WORKDIR /home/node, a directory the node user owns. So state lands in /home/node/.mcp-inspector and is writable. The WORKDIR choice is load-bearing here — good that it's set.

4. HEALTHCHECK ✅ Node-global fetch against / (no auth needed on that route — confirmed via CLAUDE.md's auth notes), port derived from CLIENT_PORT, 127.0.0.1 reachable because the server binds 0.0.0.0. Sound.

5. .dockerignore ✅ Trimmed specification + docs; test-servers/ correctly retained with a comment explaining the tsc -b typecheck dependency. Your correction is right — the build's project-references typecheck pulls in the integration tests that import the @modelcontextprotocol/inspector-test-server alias, so excluding it would fail the build. Also good that .claude is excluded.

One new, non-blocking observation

The HEALTHCHECK is unconditional, but the container can be run in non-web modes (docker run … --cli/--tui, which the README documents). In those modes there's no web server on CLIENT_PORT, so the probe will always fail and Docker will mark the container unhealthy even though it's working as intended. Purely cosmetic (health status is advisory; nothing gates on it here), but if you want it clean you could note in the README that the healthcheck assumes the default --web mode, or disable it per-run with docker run --no-healthcheck … --cli. Not worth blocking on.

Optional belt-and-suspenders: ENV HOME=/home/node would make the storage path explicit rather than relying on the "." → WORKDIR fallback, but as shown above it already works, so this is genuinely just-in-case.

LGTM — clean, well-documented, and the local build/run verification (non-root uid, health starting → healthy, / → 200) covers everything reachable outside a real release. 👍
· 1646-v2-docker-ghcr

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
@cliffhall

Copy link
Copy Markdown
Member Author

Applied both optional notes:

  • Explicit HOME — added ENV HOME=/home/node so the storage-dir resolution is explicit rather than relying on the "." → WORKDIR fallback you traced. Verified $HOME=/home/node in the running container.
  • Healthcheck / non-web modes — documented in the README that the HEALTHCHECK assumes --web and to add --no-healthcheck for --cli/--tui runs.

Re-verified locally: build ok, runs as node (uid 1000) with HOME=/home/node, / → 200, health → healthy.

@claude review

@claude

claude Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 39s —— View job


Re-review: ENV HOME + healthcheck doc

  • Read updated files (Dockerfile, README.md)
  • Verify ENV HOME=/home/node
  • Verify README healthcheck note
  • Post review

Both applied correctly.

1. Explicit HOME ✅ Dockerfile:41 now sets ENV HOME=/home/node before USER node / WORKDIR /home/node. I re-traced the storage path against the source to confirm this is now explicit rather than incidental:

  • core/storage/store-io.ts:23 — const homeDir = process.env.HOME || process.env.USERPROFILE || "." → getDefaultStorageDir() returns /home/node/.mcp-inspector/storage.

So runtime state (catalog + OAuth token storage) lands under /home/node, which the node user owns and can write. This removes the reliance on the "." → WORKDIR fallback the earlier review noted — the path is now deterministic even if WORKDIR ever changes. Nicely commented too.

2. Healthcheck / non-web modes ✅ README.md:181 now states the HEALTHCHECK assumes the default --web mode and to pass --no-healthcheck for --cli/--tui. That closes the "always-unhealthy in non-web modes" gap I flagged — documented rather than gated, which is the right call since health status is advisory here. The Dockerfile:48-49 comment says the same thing at the source, so both places agree.

Everything else still checks out

  • HEALTHCHECK uses Node-global fetch (present in Node 22), derives the port from CLIENT_PORT, probes 127.0.0.1 (reachable since the server binds 0.0.0.0), and / needs no auth. Sound.
  • Non-root node (uid 1000), multi-arch build, explicit :latest + semver tags, provenance attestation — all as reviewed previously.

LGTM. Both optional notes are cleanly resolved and the local re-verification (uid 1000, HOME=/home/node, / → 200, health → healthy) covers everything reachable outside a real release. 👍

Reminder per AGENTS.md: on merge, close #1646 and move its board card to Done manually (the Closes #1646 line is only a cross-reference against v2/main).
· 1646-v2-docker-ghcr

@cliffhall
cliffhall merged commit 4e8e772 into v2/main Jul 11, 2026
3 checks passed
@cliffhall
cliffhall deleted the 1646-v2-docker-ghcr branch July 11, 2026 14:33
@cliffhall cliffhall linked an issue Jul 11, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide + wire v2 Docker image (GHCR) — v2 has no Dockerfile

1 participant