Skip to content

fix(docker): publish on loopback by default; make a mounted state dir writable - #1974

Merged
cliffhall merged 2 commits into
v2/mainfrom
v2/docs/1964-docker-loopback
Aug 11, 2026
Merged

fix(docker): publish on loopback by default; make a mounted state dir writable#1974
cliffhall merged 2 commits into
v2/mainfrom
v2/docs/1964-docker-loopback

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #1964

Two Docker problems, both of which only show up in a real container.

1. The recipe published 6274 on every host interface

README.md recommended -p 6274:6274 with no address prefix, which publishes on all host interfaces. v1's own recipe used -p 127.0.0.1:6274:6274; v2 dropped the prefix.

The container's HOST=0.0.0.0 is a separate concern — it governs the container's interfaces, not the host's — so the DANGEROUSLY_BIND_ALL_INTERFACES opt-in that guards a wildcard bind outside a container never covered this case.

I confirmed the whole chain against a running container built from this branch, reaching it at the host's LAN address (192.168.2.6) rather than loopback. Output below is verbatim; image tags and container names are trimmed to the relevant part:

$ docker run -d -e MCP_INSPECTOR_API_TOKEN=demotoken1964 -p 6274:6274 mcp-inspector
--- bare -p 6274:6274 ---
loopback : 200
LAN      : 200
API token present in / HTML served over LAN: 1
LAN /api/config with harvested token, no Origin header: 200

So from any host on the network: GET / hands out the injected API token, and that token then unlocks /api/* — a backend that spawns processes — because a request with no Origin header skips the origin allow-list entirely (core/mcp/remote/node/server.ts:241).

With the prefix restored:

$ docker run -d ... -p 127.0.0.1:6275:6274 mcp-inspector
--- fixed -p 127.0.0.1:6275:6274 ---
loopback : 200
LAN      : 000 (Failed to connect to 192.168.2.6 port 6275 after 0 ms: Couldn't connect to server)

Fixed in both README.md recipes, both port-remap examples, and the one in clients/web/README.md.

2. You couldn't keep the servers you added

Raised separately while testing this. Two distinct causes:

Without a volume, the catalog is written to $HOME/.mcp-inspector/mcp.json = /home/node/.mcp-inspector/mcp.json, inside the container's writable layer — so --rm discards it and every run starts empty. That's expected once you know it, but nothing said so.

With a volume, it was outright broken. Docker seeds a named volume's ownership from the image's directory at the mount point, and when that directory doesn't exist it creates it root:root. The image runs as the non-root node user (uid 1000), so every add failed:

$ docker run -d -v insp-data:/home/node/.mcp-inspector ... mcp-inspector
$ docker exec insp-vol sh -c 'ls -ld /home/node/.mcp-inspector; id'
drwxr-xr-x 2 root root 4096 Aug 11 13:19 /home/node/.mcp-inspector
uid=1000(node) gid=1000(node) groups=1000(node)

$ curl -X POST .../api/servers -d '{...}'
{"error":"Failed to add server: EACCES: permission denied, open '/home/node/.mcp-inspector/mcp.json.tmp-64543864581192ef'"}

The Dockerfile now creates that directory up front owned by node, so a mounted volume inherits node:node. The README documents the volume recipe and the bind-mount --user caveat (a bind mount keeps its host ownership, so it still needs --user or a chown on Linux).

I chose mkdir+chown over a VOLUME instruction deliberately — VOLUME would create an anonymous volume on every plain docker run, which is a surprise of its own.

Proof

Before — unfixed image, named volume mounted, adding a server fails with the EACCES surfaced in the modal:

Add server modal showing "Failed to add server: EACCES: permission denied"

After — same volume mount on the fixed image, the add succeeds (new server highlighted):

Server list with the newly added "everything" server highlighted

After — container destroyed with docker rm -f and recreated against the same volume; the saved server is still there:

Server list still showing "everything" after the container was destroyed and recreated

Testing

  • npm run civalidate, verify:build-gate, smoke, and the Storybook run all pass. The coverage step exits 1 on my machine with all 4881 tests passing and 2 unhandled rejections at teardown (SdkError: Connection closed from InspectorClient.disconnect in src/test/integration/mcp/inspectorClient.test.ts). That is pre-existing and unrelated to this PR — git diff origin/v2/main..HEAD touches only Dockerfile, README.md, and clients/web/README.md, and GitHub CI is green on a09f2dcf, this branch's exact base. Reproduced twice locally; flagging it rather than papering over it.
  • Built the image and exercised both fixes against real containers, as shown above. Note the image build is not covered by npm run ci, so the Dockerfile change was verified by building and running it directly.

… writable

Closes #1964.

- README's Docker recipes dropped v1's `127.0.0.1:` prefix, so
  `-p 6274:6274` published the Inspector on every host interface. The
  container's `HOST=0.0.0.0` governs the container's interfaces, not the
  host's, so `DANGEROUSLY_BIND_ALL_INTERFACES` never covered this. Verified
  against a real container: from a LAN peer, `GET /` served the injected
  `MCP_INSPECTOR_API_TOKEN`, and that token then unlocked `/api/*` with no
  `Origin` header (server.ts:241 allows origin-less requests). Restore the
  prefix on both recipes, on the port-remap examples, and in the web README.

- Adding a server failed inside the container the moment you mounted a
  volume to keep it. Docker seeds a named volume's ownership from the
  image's directory at the mount point, and creates it `root:root` when
  that directory is absent — so the non-root `node` user got
  `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. Create the
  dir in the image, owned by `node`, and document the volume recipe (plus
  the bind-mount `--user` caveat) — without a volume the catalog lives in
  the writable layer and `--rm` throws it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 11, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 11, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves Docker security defaults and persistent state handling.

Changes:

  • Publishes the web port on loopback by default.
  • Pre-creates a writable runtime-state directory.
  • Documents persistent volumes and port remapping.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
README.md Updates Docker security and persistence guidance.
Dockerfile Creates the state directory with node ownership.
clients/web/README.md Uses loopback-only Docker port publishing.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread README.md
Comment thread README.md Outdated
…n claim

- A named volume created by an image predating the ownership fix keeps its
  root ownership if it is non-empty, since Docker only applies the image
  directory's ownership to an *empty* volume. Verified both halves: an empty
  pre-existing volume repairs itself on the first run of the fixed image,
  while one holding a file stays root-owned and still EACCESes. Document the
  distinction and the one-shot `chown` repair (verified to restore writes).

- "pair it with a known MCP_INSPECTOR_API_TOKEN" was bad advice for a wider
  publication: the same paragraph notes `GET /` discloses the token, so a
  custom one is harvested exactly as a generated one is. Point at a real
  access-control boundary instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a
@cliffhall

Copy link
Copy Markdown
Member Author

Round 1 review addressed in 5be2b3e — mirroring here since inline replies get collapsed once the threads go outdated.

1. Pre-existing volumes from an older image (README.md:339) — correct, and the fix was incomplete. I tested both halves to find the boundary:

  • Empty pre-existing volume — repairs itself. Docker applies the image directory's ownership to an empty volume, so the first run of the fixed image flips it to node:node. This is the common case, because the old image made the add fail before anything could be written.
  • Non-empty volume — exactly as described. Keeps root ownership, still EACCESes.

README now documents the distinction plus a one-shot repair (verified to restore writes):

docker run --rm -u 0 --entrypoint chown \
  -v mcp-inspector-data:/data ghcr.io/modelcontextprotocol/inspector \
  -R node:node /data

2. "pair it with a known MCP_INSPECTOR_API_TOKEN" (README.md:329) — agreed, and it contradicted the same paragraph. GET / discloses whatever token is in use, so a custom one is harvested as easily as a generated one. Replaced with a pointer to a real access-control boundary (authenticating reverse proxy, SSH tunnel, private network) and an explicit note that a custom token does not substitute.

Both were actionable; nothing skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@cliffhall cliffhall linked an issue Aug 11, 2026 that may be closed by this pull request
@cliffhall
cliffhall merged commit e7f0dc8 into v2/main Aug 11, 2026
8 checks passed
@cliffhall
cliffhall deleted the v2/docs/1964-docker-loopback branch August 11, 2026 14:54
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.

docs: README's Docker recipe publishes 6274 on all host interfaces

2 participants