diff --git a/dev-server-mcp/Dockerfile b/dev-server-mcp/Dockerfile index f435602..9728d18 100644 --- a/dev-server-mcp/Dockerfile +++ b/dev-server-mcp/Dockerfile @@ -23,15 +23,28 @@ RUN apt-get update \ # No Maven. A Spring project carries ./mvnw, which pins the Maven version the project is built # with; installing a second one here would only give an agent a way to use the wrong one. -RUN groupadd -g 10001 mcp \ - && useradd -r -u 10001 -g mcp -M -d /nonexistent mcp \ - && mkdir -p /tmp/dev-server \ - && chown -R mcp:mcp /tmp/dev-server +# One number decides three things that must agree: who the process is, who owns the volumes, and +# who owns the workspace directory on the host. Passing it in as a build argument is what keeps +# them from drifting apart - a mismatch here is only discovered when an execution cannot write. +ARG MCP_UID=10001 +ARG MCP_GID=10001 +RUN set -eux; \ + if ! getent group "${MCP_GID}" >/dev/null; then groupadd -g "${MCP_GID}" mcp; fi; \ + if ! getent passwd "${MCP_UID}" >/dev/null; then useradd -r -u "${MCP_UID}" -g "${MCP_GID}" -M -d /nonexistent mcp; fi; \ + mkdir -p /tmp/dev-server /instances /npm-cache; \ + chown -R "${MCP_UID}:${MCP_GID}" /tmp/dev-server /instances /npm-cache + +# /instances and /npm-cache exist here only so the named volumes mounted over them inherit this +# ownership. A volume whose mount point is absent from the image is created root-owned, and a +# container that drops root then cannot write a single byte into it - which surfaces as a refused +# start on the first execution, far from the line that caused it. WORKDIR /app -COPY --chown=mcp:mcp package.json ./ -COPY --chown=mcp:mcp src ./src +# By number, not by name: with MCP_UID set to an id the base image already uses, the mcp user +# is never created and a copy chowned to it would fail the build. +COPY --chown=${MCP_UID}:${MCP_GID} package.json ./ +COPY --chown=${MCP_UID}:${MCP_GID} src ./src ENV NODE_ENV=production -USER mcp +USER ${MCP_UID}:${MCP_GID} CMD ["node", "src/index.js"] diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index 1847e36..02f6605 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -29,12 +29,15 @@ services: dev-server-mcp: build: context: ./dev-server-mcp + args: + MCP_UID: ${MCP_UID:-10001} + MCP_GID: ${MCP_GID:-10001} command: ["node", "src/index.js"] environment: DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS} DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} DEV_SERVER_WORKER_URL: http://dev-server-worker:4000 - user: "10001:10001" + user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m @@ -52,6 +55,9 @@ services: dev-server-worker: build: context: ./dev-server-mcp + args: + MCP_UID: ${MCP_UID:-10001} + MCP_GID: ${MCP_GID:-10001} command: ["node", "src/worker-index.js"] environment: DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} @@ -155,7 +161,7 @@ services: mcp-gateway: image: caddy:2 - user: "10001:10001" + user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: # /config only. /data is a volume instead, because Caddy keeps the certificate and its ACME