Skip to content

feat(mcp-server): publish a Docker image + /health endpoint#1768

Open
Scra3 wants to merge 2 commits into
mainfrom
feat/mcp-server-docker-image
Open

feat(mcp-server): publish a Docker image + /health endpoint#1768
Scra3 wants to merge 2 commits into
mainfrom
feat/mcp-server-docker-image

Conversation

@Scra3

@Scra3 Scra3 commented Jul 22, 2026

Copy link
Copy Markdown
Member

Why

Riipen (Ruby app, Forest v2 agent via the Ruby gem) wants the MCP server but it's Node-only — no "mounted" version on the Ruby agent, and none planned. To remove the friction of running Node next to Ruby, we ship a Docker image they deploy on their own infra. Same approach as workflow-executor (and Qonto before).

What

Mirrors the workflow-executor image setup, trimmed of executor-specific parts.

  • GET /health — unauthenticated liveness probe (Docker HEALTHCHECK / k8s), returns 200 {"status":"ok"}. Registered before allowedMethods(['POST']) so the GET isn't rejected. + test.
  • Dockerfile — 3-stage, digest-pinned node:22-bookworm-slim, non-root node user. Isolated prod-deps install over the 5-package @forestadmin closure (mcp-server + agent-client + forestadmin-client + datasource-toolkit + agent-toolkit). EXPOSE 3931, healthcheck on /health.
  • docker/ — deps-manifest generator, closure-drift check, dedicated deps/yarn.lock, boot smoke-test, README.
  • docker-publish-mcp-server.yml — PR validate (closure check + build + smoke-test + trivy OS blocking, npm libs report-only) and, on release, multi-arch (amd64+arm64) push-by-digest to ghcr.io/forestadmin/mcp-server with mutable :latest/:minor/:major + immutable :version tags.
  • build.yml — release job dispatches the image publish on a new @forestadmin/mcp-server@* tag (same pattern as the executor).
  • README + CLAUDE docs.

Differences vs the executor image (intentional)

  • No OpenTelemetry → simpler entrypoint, no OTel deps/gate.
  • No notify-server job → the client deploys the image themselves; no Forest prod auto-deploy.

Verification (local)

  • yarn workspace @forestadmin/mcp-server test609 passing (incl. the new /health test); lint clean (0 errors).
  • node packages/mcp-server/docker/check-deps-closure.js → closure OK (5 packages).
  • docker build -f packages/mcp-server/Dockerfile -t mcp-server:local . → OK (~274 MB).
  • smoke-test.sh → passes (boots, answers /health, full module graph loads).
  • Sanity on the running container: GET /health → 200, OAuth well-known → 200, POST /mcp without token → 401.

Result

After merge + release, the image is available:

docker pull ghcr.io/forestadmin/mcp-server:latest   # or :<version>, :<minor>, :<major>
docker run -p 3931:3931 -e FOREST_ENV_SECRET=... -e FOREST_AUTH_SECRET=... ghcr.io/forestadmin/mcp-server:latest

Riipen deploys it with just the two Forest secrets — no Node install.

🤖 Generated with Claude Code

Note

Add Docker image publishing and /health endpoint to mcp-server

  • Adds a multi-stage Dockerfile that builds the MCP server and its @forestadmin workspace dependencies, installs isolated runtime deps, runs as a non-root node user, and exposes port 3931.
  • Adds a GET /health route in server.ts that returns 200 {"status":"ok"} without authentication, used by both the Docker healthcheck and external monitors.
  • Adds a CI workflow that builds, smoke-tests, Trivy-scans, and publishes a multi-arch (amd64/arm64) image to GHCR with versioned and mutable (latest, major, minor) tags.
  • Extends the release workflow to detect new @forestadmin/mcp-server tags and dispatch the Docker publish workflow automatically.
  • Adds a docker-compose.yml and supporting scripts (entrypoint, smoke test, deps manifest generator) for local use and CI validation.

Macroscope summarized 680ecf0.

Lets clients deploy the MCP server without a Node toolchain (e.g. a Ruby or
Python agent stack) — pull the image and run it, config via env vars only.
Mirrors the workflow-executor image setup.

- add an unauthenticated GET /health liveness probe (Docker HEALTHCHECK, k8s)
- multi-stage Dockerfile with an isolated prod-deps install (5-package
  @ForestAdmin closure: mcp-server + agent-client + forestadmin-client +
  datasource-toolkit + agent-toolkit), digest-pinned base, non-root user
- docker/ assets: deps-manifest generator, closure drift check, dedicated
  yarn.lock, boot smoke-test, README
- docker-publish-mcp-server.yml: PR validate (build + smoke + trivy OS gate,
  npm libs report-only) and multi-arch push-by-digest to
  ghcr.io/forestadmin/mcp-server on release
- build.yml release job dispatches the image publish on a new mcp-server tag
- README/CLAUDE docs

No OpenTelemetry (unlike the executor) and no prod auto-deploy: the image is
deployed by the client on their own infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Jul 22, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Duplication Found 24 lines of similar code in 2 locations (mass = 141) 2
qlty Structure Function with high complexity (count = 15): generate 1

concurrency:
# Include the dispatch version so two manual runs for different versions from the
# same branch don't cancel each other mid-release.
group: docker-publish-mcp-server-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || '' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium workflows/docker-publish-mcp-server.yml:34

The concurrency group includes github.event.inputs.version, so manual dispatches for different versions run simultaneously. If an older version's build/merge jobs finish after a newer version's, the older run overwrites :latest, :major, and :minor with the stale image — even though its extract-version job correctly computed IS_LATEST=true at startup. The IS_LATEST check only compares against tags that existed when the run started, so a concurrent newer release doesn't prevent the older run from moving the mutable tags. Consider serializing mutable-tag publication across versions (e.g. a shared concurrency group for the merge job) or re-checking the current highest stable tag immediately before pushing the manifest.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.github/workflows/docker-publish-mcp-server.yml around line 34:

The concurrency group includes `github.event.inputs.version`, so manual dispatches for different versions run simultaneously. If an older version's `build`/`merge` jobs finish after a newer version's, the older run overwrites `:latest`, `:major`, and `:minor` with the stale image — even though its `extract-version` job correctly computed `IS_LATEST=true` at startup. The `IS_LATEST` check only compares against tags that existed when the run started, so a concurrent newer release doesn't prevent the older run from moving the mutable tags. Consider serializing mutable-tag publication across versions (e.g. a shared concurrency group for the `merge` job) or re-checking the current highest stable tag immediately before pushing the manifest.

@qltysh

qltysh Bot commented Jul 22, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (1)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/mcp-server/src/server.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

…a timeout

The health-poll swallowed `docker exec` stderr, so a container that crashed on
boot (the most likely failure — an un-copied @ForestAdmin package) spun the full
30s and was then mislabelled "did not answer within 30s", pointing at a slow
boot instead of an instant crash. Probe container liveness each iteration and
fail fast with the real reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant