Repository navigation
fix(mcp): ship only the runtime closure in the -mcp image (prod deps + tsx layer) - #1175
Conversation
…#1173) The mcp stage copied node_modules from the dev-inclusive deps stage because its entrypoint runs tsx, shipping the whole lint/test toolchain — including the accepted dev-only advisory chain eslint-config-next -> @next/eslint-plugin-next -> fast-glob -> micromatch -> braces (GHSA-vfj7-8cjw-p6xm) — in the published image. Add an mcp-deps stage (npm ci --omit=dev + a package.json rewrite that makes tsx a production dep of the layer copy only, then npm install --no-save) and copy the image tree and shipped manifest from it. Assert absence of the dev toolchain in the docker-mcp job after the handshake validation.
…patch/issue-1173 # Conflicts: # SECURITY-ACCEPTED-RISKS.md
|
Rebased onto latest main and resolved the SECURITY-ACCEPTED-RISKS.md conflict with #1172: kept both resolution-history rows and rewrote the "Shipping surface" bullet in the new Dev-Only Advisories section, which still described the -mcp image as shipping the lint chain (no longer true after this PR). No code changes beyond the merge. |
- fail the build (not just the handshake) if tsx disappears from devDependencies; emit a trailing newline in the layer-rewritten package.json - warn against copying .npmrc into mcp-deps (include=dev overrides --omit=dev) - assert step: also scan advisory-chain packages nested under scoped host packages (node_modules/@scope/host/node_modules/*) - Dockerfile.test.ts: pin the mcp stage to COPY --from=mcp-deps and keep mcp-deps a --omit=dev install (guards the #1173 wiring in-unit)
|
Review round pushed in be22380 (all findings from an independent review pass): (1) the mcp-deps package.json rewrite now fails the build if the tsx range disappears from devDependencies and writes a trailing newline; (2) advisory-chain scan in the image.yaml assert step also covers duplicates nested under scoped host packages; (3) Dockerfile.test.ts now pins the mcp stage to COPY --from=mcp-deps (node_modules + package.json), forbids COPY --from=deps there, and pins mcp-deps to an npm ci --omit=dev install with no ENV DATABASE_URL; (4) comment in mcp-deps warns against copying .npmrc (include=dev overrides --omit=dev, per #1166). Local: vitest Dockerfile.test.ts 5/5 pass, lint and typecheck clean. Rebased state unchanged; PR body invariants updated to match. |
…FOUND nodejs.org at Install Node step)
… currently available' at Extract metadata on identical tree)
|
Replying to the CHANGES_REQUESTED blocker (review on The failure was transient CI infra, not this change — both runs failed before the MCP build/assert steps ever executed:
Both were re-run as no-op commits (
Head is now No action items remain from the review: the blocker is green CI on the current head (the AI-reviewer check on |
…ests
Independent review pass (post-merge):
- rewrite now tolerates a manifest without a dependencies field
(p.dependencies = p.dependencies || {}) instead of a cryptic TypeError;
- fix inverted comment: typescript leaks into --omit=dev because it is
dev:false in the lock via optional-peer refs, not dev-flagged;
- Dockerfile.test.ts pins the tsx layer itself (rewrite reads the range
from devDependencies + npm install --no-save), so deleting the rewrite
fails the unit test, not only the CI handshake.
|
Nit-fix round pushed in
Also rebased-in main via merge |
Superseded by a newer automated review for this pull request.
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3 (anthropic) — primary route · pr-reviewer-action v3.2.0
Partial coverage: required-check coverage is incomplete — this review did not resolve every required check and must not be read as a complete pass.
Recommendation
Approve. The PR delivers what issue PR 1173 asks for: the -mcp image is rebuilt from a production-only install plus a layer-local tsx shim, the existing Validate MCP handshake step is followed by a new Assert MCP image ships no dev toolchain step, and regression tests in Dockerfile.test.ts pin the new wiring. CI is green on the head (f47f6a5); the prior CHANGES_REQUESTED blocker was diagnosed by the author as transient runner infra, supported by an identical-tree rerun that passed Docker Build (MCP) end-to-end.
Change-by-change findings
Dockerfile— newmcp-depsstage. Verified the stage exists betweenprod-depsandbuilder; it copiespackage.json+package-lock.json*only (no.npmrc), runsnpm ci --omit=dev, then performs anode -erewrite ofpackage.json(movestsxfromdevDependenciesintodependencies, deletesdevDependencies) followed bynpm install --no-save --no-audit --no-fund. The comment correctly cites PR 1166 as the reason.npmrcmust not beCOPYd (itsinclude=devoverrides--omit=dev), and the rewrite throws at build time iftsxever disappears fromdevDependencies(verified atDockerfile:37). This is the#1173task's core fix.Dockerfile— retargetedmcpstage. VerifiedmcpnowCOPY --from=mcp-depsfor bothnode_modulesandpackage.json; the oldCOPY --from=deps /app/node_modulesandCOPY package.jsonlines are gone. The entrypoint, import closure, andUSER mcpare unchanged, matching invariant PR 4 from the PR.ENV NODE_ENV=productionis preserved;ENV DATABASE_URLis not added (belt for PR 533, covered by test).Dockerfile—builder/runner/prod-deps/depsstages. Verified byte-identical to before:builderstill doesCOPY --from=deps,runnerstill doesCOPY --from=prod-deps, andmcp-depsadds noDATABASE_URL. Claim 4 and the corresponding invariant hold..github/workflows/image.yaml— new assert step. Verified the new step sits afterValidate MCP handshakein thedocker-mcpjob, runsdocker run --rm --entrypoint sh $IMAGE -c '…'on the sameIMAGE(handlespull_requestvs push the same way the handshake does), and exits 1 on any of the advisory-chain packages or top-levelvitest/typescript/ts-node. The nested-scope scan (node_modules/@*/*/node_modules/...) catches scoped duplicates hiding under a host package — matches the author's claim in the comment thread.Dockerfile.test.ts— newDockerfile MCP image wiringdescribe block. Verified both new tests:mcpstage must containCOPY --from=mcp-deps /app/node_modules ./node_modulesANDCOPY --from=mcp-deps /app/package.json ./package.json, must NOT containCOPY --from=deps;mcp-depsmust containnpm ci --omit=dev,devDependencies.tsx,npm install --no-save, and must not match^ENV\s+DATABASE_URL\b. The splitIntoStages regex (FROM\s+\S+\s+(?:AS\s+)?(\S+)) captures bothFROM base AS mcp-depsandFROM base AS mcp, so all four stages are wired into the assertion map. These are real predicates (contain/not-contain/not-match), not just assignments.SECURITY-ACCEPTED-RISKS.md. Verified theShipping surfacebullet for thebracesadvisory was rewritten to claim "none" with the samemcp-depsjustification, and a new row was added under retired risks with resolution#1173. The text correctly references.github/workflows/image.yamland theAssert MCP image ships no dev toolchainstep.
Claims disposition (hypotheses)
- Claim 1 (lint chain shipped in image;
ghcr.io/misospace/dispatch-mcp). Held for every listed item: the diff removes the dev chain fromDockerfile(mcpno longer copies fromdeps), adds the explicit assert inimage.yamlthat walks the advisory-chain list, and updatesSECURITY-ACCEPTED-RISKS.mdto mark the chain as absent. (8/8) - Claim 2 (
mcpcopiesnode_modules+package.jsonfrommcp-deps;docker-mcpgains a step). Held. (12/12) - Claim 3 (both
npm installshapes empirically rejected against npm 11.19). The Dockerfile comment and the--no-saverecipe match the claim; the empirical-rejection note is documented as observed rather than asserted as upstream contract, consistent with the author's reply in the PR thread. Held as far as the implementation can verify — I cannot independently reproduce the npm 11.19 dry runs from the corpus, but the recipe and its rationale are coherent and the--no-savepath is the standard escape hatch. (12/12) - Claim 4 (
builderinstalls the dev tree,runnerships production-only;deps/prod-depsbyte-identical; onlymcpretargeted). Held. The added lines live strictly inside the newmcp-depsblock and themcpretarget lines;builder,runner,deps, andprod-depsare untouched. (12/12) - Claim 5 (
ENV DATABASE_URLstays inbuilder;mcp-depshas none;Dockerfile.test.tsenforces). Held. The newDockerfile MCP image wiringdescribe adds an explicit^ENV\s+DATABASE_URL\bbelt onmcp-deps, and the existingkeeps ENV DATABASE_URL strictly inside the builder stagetest still iterates every non-builder stage and would fail ifmcp-depsever set it. (12/12)
Standards Compliance
- Docker conventions (AGENTS.md). Multi-stage pattern preserved;
baseisnode:24-bookworm-slim; OpenSSL/ca-certificates install path unchanged; thedocker-mcpjob'sValidate MCP handshakestep is still the live MCP signal. ✓ - Lint/typecheck blocks CI.
Lint,Build,Typecheckare green on the head per the CI status block. ✓ (req-e589bfe1ad7c) - Token secrecy. No tokens are echoed, logged, or persisted by the new step; the
docker run --rm --entrypoint shform passes no secrets. ✓ (req-ad902b2a08da) runnermust not gaintsx/dev tooling.tsxremains adevDependencyinpackage.json; only the layer-local copy is rewritten, andprod-depsdoes not touchtsx;npm run auditandsecurity-audit.yamlare unchanged. ✓ (req-b0d8b27b3b0e)
Linked Issue Fit
Issue PR 1173 asks for: build the -mcp image from a production-only install plus a tsx layer; verify the MCP handshake still passes. The PR does exactly this: a new mcp-deps stage performs npm ci --omit=dev then a layer-local package.json rewrite + npm install --no-save to install only tsx/esbuild; the mcp stage copies from mcp-deps; the existing Validate MCP handshake step is preserved (and is green on this head); Dockerfile.test.ts and the new CI assert step guard the regression. Acceptance criteria satisfied.
Tool Harness Findings
.npmrccould not be read directly (sensitive-file block). Itsinclude=devcontent was inferred from the consistent cross-file evidence:package.json's"//" auditblock,package-audit.test.ts:6, andSECURITY-ACCEPTED-RISKS.md:74all describe.npmrcas settinginclude=devand overriding--omit=dev; the newmcp-depscomment correctly warns against copying.npmrcinto the stage.- All other file reads succeeded. The
splitIntoStagesparser inDockerfile.test.tscorrectly captures bothFROM base AS mcp-depsandFROM base AS mcp(the regex's optional(?:AS\s+)?plus theAScapture works for both forms used in this Dockerfile). git log Dockerfileconfirms the three-commit history (afebaaa,be22380,f47f6a5) with the most recent commit pinning the tsx-layer recipe in tests, matching the comment thread.
Unknowns or Needs Verification
- The empirical claim "both direct
npm installshapes were empirically rejected against npm 11.19" cannot be independently reproduced from the corpus; I treat the Dockerfile-side recipe as evidence the author actually iterated on this rather than invented the rationale. The new CI assert is the durable guard if npm behavior shifts in the future. - The
.npmrccontent was not directly read (sensitive-file block); the inference from the surrounding code is consistent, but a reviewer who wants absolute certainty could re-read it locally. - The hand-shake-validates behavior is asserted by
Dockerfile.test.ts+ the CI assert step, but the actualinitializehandshake round-trip is verified by the livedocker-mcpjob (green on this head).
Verdict
Approve.
Requirement trace
3 of 3 requirement(s) not fully traced to enforcement and a test:
req-e589bfe1ad7c— unverifiable (no valid enforcement location)req-ad902b2a08da— unverifiable (no valid enforcement location)req-b0d8b27b3b0e— unverifiable (no valid enforcement location)
Approval withheld: this review's coverage is incomplete — required-check coverage or the tool-loop investigation did not finish, so it is publishing as an advisory comment rather than an approval.
|
Closing the loop on the advisory review of
State: head |
|
Courier: already addressed (run courier-0d332351100d97a86f8fe8b7c6646e2e) PR #1175 is already review-ready at head f47f6a5 (local = origin/courier/misospace/dispatch/issue-1173 = PR head; base 01b6bad = origin/main, no drift). All 13 checks green on the head incl. Docker Build (MCP) run 37214887060 job 111473273576 (build + initialize handshake + 'Assert MCP image ships no dev toolchain'), plus Lint/Typecheck/Build/Tests/Coverage/npm audit/smoke/scheduler jobs. The only prior CHANGES_REQUESTED (transient CI infra on 6fdf72f) is DISMISSED; mergeable_state clean. Zero open review threads; the latest AI review on f47f6a5 is a verdict 'Approve' (advisory partial-coverage; its 3 'unverifiable' requirement-trace items are its own tool-loop coverage limits and were answered in issue comment 5981921422). All review feedback rounds (be22380, f47f6a5) are pushed and replied to; PR body invariants match the diff; human reviewer (joryirving) pending per the no-merge rule. |
|
Courier: already addressed (run courier-6172c14b9e8687a14bd16ded6c3d0c31) PR #1175 is review-ready at head f47f6a5 (local = origin/courier/misospace/dispatch/issue-1173 = PR head; origin/main still 01b6bad = PR base, no drift). Verified live this run: all 13 checks on the head are completed/success, including Docker Build (MCP) run 37214887060 job 111473273576 (build + initialize handshake + Assert MCP image ships no dev toolchain), plus Lint/Typecheck/Build/Tests/Coverage/Database integration/Database migrations/Workflow lint/npm audit/smoke. The only prior CHANGES_REQUESTED (transient CI infra on 6fdf72f) is DISMISSED; latest its-saffron review on f47f6a5 is COMMENTED (advisory approve) and its 3 unverifiable items were answered in issue comment 5981921422; get_review_comments returns zero open threads. mergeable_state clean; PR body invariants section matches the diff (4 files, +116/-4). All review-feedback rounds (be22380, f47f6a5) are pushed and replied to. Human reviewer joryirving pending per the no-merge rule; nothing actionable remains. |
Reason: PR review: CHANGES_REQUESTED Total attempts: 76 Attempts by lane:
Failing run(s):
Last attempt: AI Automated ReviewFull PR review. Analysis engine: MiniMax-M3 (anthropic) — primary route · pr-reviewer-action v3.2.0
Findings (1 blocker, 1 info)
RecommendationI cannot approve this PR in its current state. The full CI result is reported as a failure for In addition, the only available CI signal is the harness's reported status; I did not see the failing log content. The lint/typecheck job is reported green ( I have also separately verified the rest of the diff against the linked issue and repository conventions, and the code-level claims hold up well; the failing CI is the only blocker I'm raising. Change-by-change findings
|
joryirving
left a comment
There was a problem hiding this comment.
Re-reviewed current head f47f6a5. Approve.
I independently checked the current 4-file diff against #1173 rather than relying on the earlier review rounds.
mcpnow sources bothnode_modulesand the shipped manifest from the production-onlymcp-depsstage; the main runner path remains untouched.- The layer-local manifest rewrite keeps
tsxsourced from the repo's devDependency range while preventing the repo dev tree from becoming the MCP runtime tree. - The root
postinstallis harmless in this stage becauseprisma/schema.prismais not present here; the subsequent install therefore does not introduce a hidden build/runtime dependency. - The authoritative Docker Build (MCP) job is green on this exact head: image build, live MCP initialize handshake, and
Assert MCP image ships no dev toolchainall succeeded. - The accepted-risk documentation now matches the built-image evidence.
- The
splitIntoStagesFROM --platformweakness is real but pre-existing and correctly tracked separately in #1178; the current Dockerfile does not use that form, so it does not invalidate this PR's tests.
The stale CHANGES_REQUESTED review was tied to the older transient red build, not this head. I found no remaining merge blocker or nit worth holding #1175 for.
Closes #1173
What
The
mcpstage copiednode_modulesfrom the dev-inclusivedepsstage (npm ci, devDependencies included) because its entrypoint runstsx. That shipped the entire lint/test toolchain — including the accepted dev-only advisory chaineslint-config-next -> @next/eslint-plugin-next -> fast-glob -> micromatch -> braces(GHSA-vfj7-8cjw-p6xm) — in the publishedghcr.io/misospace/dispatch-mcpimage.This adds a
mcp-depsstage:npm ci --omit=devplus a layer-local rewrite ofpackage.json(tsx moved intodependencies,devDependenciesdropped — the range is still read fromdevDependencies, so Renovate stays the source of truth; the rewrite fails the build if the tsx range disappears, tolerates a manifest without adependenciesfield, and writes a trailing newline) followed bynpm install --no-save, which adds onlytsx/esbuildand prunes the stragglers this lock reifies even under--omit=dev(typescript et al.:dev:falsein the lock via optional-peer refs). Themcpstage now copies itsnode_modulesand its shippedpackage.jsonfrommcp-deps, and thedocker-mcpCI job gains an "Assert MCP image ships no dev toolchain" step after the existing handshake validation.Both direct
npm installshapes were empirically rejected against npm 11.19 (documented in the Dockerfile comment): a baretsx@rangeinstall without--omit=devreifies the whole tree and reinstalls every devDependency, and with--omit=devnpm skips an explicit package thatpackage.jsonlists as dev-only.Verification
package.json/package-lock.json(npm 11.19 / node 24): after the stage recipe, the CI assert loop passes clean (no eslint chain, no vitest/typescript/ts-node, one nesting level scanned, scoped hosts included), and the MCP stdioinitializehandshake returnsserverInfoundertsxfrom the resulting tree, before and after the rewrittenpackage.jsonis placed in the image.node_modules/eslintmakes it exit 1);image.yamlparses as valid YAML.Dockerfile.test.ts(5 tests incl. the new MCP-wiring pins) passes locally under vitest; lint/typecheck clean locally.dependenciesfield now yields a clean rewrite, not aTypeError); assert step poison-tested (top-level eslint, nested duplicate under a plain and a scoped host, top-level typescript — all detected under dash withset -euo pipefail).Docker Build (MCP)failures on6fdf72fwere transient CI infra (runnerENOTFOUND nodejs.org; GHCR "No server is currently available") — the job is green on20e4c83and on the current head with a byte-identical MCP diff.docker build+ handshake is the CIdocker-mcpjob on this PR.Invariants this change must keep (and the other code paths enforcing the same rules)
builderinstalls the full dev tree (deps),runnerships the production-only tree (prod-deps). Untouched:Dockerfilestill hasCOPY --from=depsinbuilderandCOPY --from=prod-depsinrunner; thedepsandprod-depsstages are byte-identical to before. The only stage retargeted ismcp.ENV DATABASE_URLstays strictly insidebuilder(issue Dockerfile builds with hardcoded build-time databaseurl #533). Kept: the newmcp-depsstage sets noDATABASE_URL;Dockerfile.test.tsenforces this over every stage by parsing allFROM ... ASsections and still passes (plus a directmcp-depsbelt from the new tests).runnerimage must not gaintsx/dev tooling. Kept:tsxremains adevDependencyinpackage.json(unchanged in git); it is added only inside themcp-depslayer's rewritten copy, whichprod-depsnever sees.npm run audit(--omit=dev) andsecurity-audit.yamlare unaffected. A comment inmcp-depswarns against copying.npmrc(itsinclude=devwould override--omit=dev, per CI: Security Audit failing on the default branch (npm audit) #1166).-mcpimage keeps its entrypoint, file layout, and non-root user. Kept:ENTRYPOINT ["./node_modules/.bin/tsx", "src/mcp/server.ts"], the import-closureCOPYlines,USER mcp, and tag/push logic in thedocker-mcpjob are unchanged; only the dependency-tree source and the shipped manifest changed. The shippedpackage.jsonkeeps every runtime-relevant field (dependencies,overrides,allowScripts); onlydevDependenciesis removed, andsrc/mcp/server.tshardcodes its server version rather than reading the manifest. The layer-local rewrite now throws at build time iftsxever disappears fromdevDependencies(and tolerates a manifest with nodependenciesfield), instead of silently shipping an image whose entrypoint binary is missing.-mcpimage, now or later. Enforced four ways: the build recipe itself,Dockerfile.test.tspinningmcptoCOPY --from=mcp-deps(node_modules + package.json) and forbiddingCOPY --from=depsin that stage and pinning themcp-depsrecipe (npm ci --omit=dev, the rewrite reading the tsx range fromdevDependencies,npm install --no-save, noENV DATABASE_URL), the new assert step in.github/workflows/image.yaml(advisory-chain packages scanned at top level and one nesting level deep, including under scoped host packages), and the existing handshake step which provestsx+@modelcontextprotocol/sdk+zodstill work from that tree.package.json/package-lock.jsonare unchanged in git. Kept: the manifest rewrite happens only inside the layer;--no-savewrites nothing back. Confirmed by the diff (branch-vs-main delta contains no manifest/lockfile changes; lockfile movement in the branch history is only merges from main).Validate Prisma CLI runtime), Trivy, and tag/cache policy are untouched. Kept: only thedocker-mcpjob changed (one appended step); thedockerjob is byte-identical.SECURITY-ACCEPTED-RISKS.mdgains a resolution-history row: the GHSA-vfj7-8cjw-p6xm chain now ships in no published image (dev installs only), with the CI assert as the standing guard.