Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ jobs:
run: npm config set workspaces-update false
- name: Snapshot workflow-executor tags (pre-release)
run: git tag --list '@forestadmin/workflow-executor@*' | sort > /tmp/we-tags-before.txt
- name: Snapshot mcp-server tags (pre-release)
run: git tag --list '@forestadmin/mcp-server@*' | sort > /tmp/mcp-tags-before.txt
- name: "Run multi-semantic-release"
run: "$(yarn bin)/multi-semantic-release --deps.bump=override"
env:
Expand Down Expand Up @@ -274,6 +276,28 @@ jobs:
echo "workflow-executor@${VERSION} released — dispatching docker-publish.yml."
gh workflow run docker-publish.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION"

- name: Trigger mcp-server Docker image publish (if released)
if: ${{ !cancelled() }}
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [ ! -f /tmp/mcp-tags-before.txt ]; then
echo "Pre-release snapshot missing — release step never ran; nothing to publish."
exit 0
fi
git tag --list '@forestadmin/mcp-server@*' | sort > /tmp/mcp-tags-after.txt
NEW_TAGS=$(comm -13 /tmp/mcp-tags-before.txt /tmp/mcp-tags-after.txt)
if [ -z "$NEW_TAGS" ]; then
echo "No new @forestadmin/mcp-server tag in this run — nothing to publish."
exit 0
fi
# If more than one appears, pick the highest so :latest never regresses.
VERSION=$(echo "$NEW_TAGS" \
| sed 's|^@forestadmin/mcp-server@||' \
| sort -V | tail -n1)
echo "mcp-server@${VERSION} released — dispatching docker-publish-mcp-server.yml."
gh workflow run docker-publish-mcp-server.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION"

publish-api-reference:
name: Publish API Reference
runs-on: ubuntu-latest
Expand Down
268 changes: 268 additions & 0 deletions .github/workflows/docker-publish-mcp-server.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
name: Publish mcp-server Docker image

on:
# Releases publish via workflow_dispatch from build.yml's `release` job, not a
# tag-push trigger — the release commit's `[skip ci]` would suppress it.
pull_request:
# Validate on any change to the MCP server or to a dependency package's
# manifest — a dep bump in one of the 4 source packages can drift the
# image's dedicated lockfile (see packages/mcp-server/docker/).
paths:
- 'packages/mcp-server/**'
- 'packages/agent-client/package.json'
- 'packages/forestadmin-client/package.json'
- 'packages/datasource-toolkit/package.json'
- 'packages/agent-toolkit/package.json'
# The builder runs `yarn install --frozen-lockfile` from the repo root, so a
# lockfile-only change can alter the build (build.yml installs without
# --frozen-lockfile and wouldn't catch an inconsistency).
- 'yarn.lock'
- '.github/workflows/docker-publish-mcp-server.yml'
workflow_dispatch:
inputs:
version:
description: 'Version tag to publish (e.g. 1.18.0)'
required: true

permissions:
contents: read
packages: write

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.

cancel-in-progress: true

jobs:
# On PRs: build the image (no push) to catch Dockerfile breakage before release.
validate:
name: Validate Dockerfile build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"

- name: Check @forestadmin dependency closure
run: node packages/mcp-server/docker/check-deps-closure.js

- uses: docker/setup-buildx-action@v3
- name: Build (no push)
uses: docker/build-push-action@v6
with:
context: .
file: packages/mcp-server/Dockerfile
platforms: linux/amd64
push: false
load: true
tags: mcp-server:pr
cache-from: type=gha,scope=mcp-amd64

# The build only proves the image compiles. Run it to catch entrypoint
# breakage, a missing module (e.g. an un-copied @forestadmin package), or a
# startup crash — none of which a build-only step would surface.
- name: Smoke test (entrypoint + module graph + boot)
run: sh packages/mcp-server/docker/smoke-test.sh mcp-server:pr

# Gate by ORIGIN, not severity. OS packages can only be fixed here, so they
# BLOCK; the MCP server's npm deps are already shipped via the package, so
# blocking the image would just desync GHCR from npm — report-only.
- name: Scan OS packages (blocking)
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: mcp-server:pr
vuln-type: os
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '1'
- name: Scan libraries (report only)
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: mcp-server:pr
vuln-type: library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '0'

extract-version:
name: Extract version
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
full: ${{ steps.version.outputs.full }}
minor: ${{ steps.version.outputs.minor }}
major: ${{ steps.version.outputs.major }}
is_latest: ${{ steps.version.outputs.is_latest }}
steps:
- uses: actions/checkout@v4
with:
# Need all tags to determine whether this is the latest stable version.
fetch-depth: 0
- name: Parse version from input
id: version
run: |
VERSION="${{ github.event.inputs.version }}"
echo "full=$VERSION" >> $GITHUB_OUTPUT
echo "minor=${VERSION%.*}" >> $GITHUB_OUTPUT
echo "major=${VERSION%%.*}" >> $GITHUB_OUTPUT

# Move the mutable tags (:latest, :major, :minor) only when this is the
# highest STABLE version of this package — never for a prerelease, and
# never when rebuilding an older version via workflow_dispatch (which
# would regress :latest onto a stale image). We compare against the git
# tags of this package specifically (the repo-wide GitHub "latest
# release" belongs to whichever monorepo package shipped last).
PREFIX="@forestadmin/mcp-server@"
LATEST_STABLE=$(git tag --list "${PREFIX}*" \
| sed "s|^${PREFIX}||" \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -n1)
case "$VERSION" in
*-*) IS_LATEST=false ;; # prerelease
"$LATEST_STABLE") IS_LATEST=true ;;
*) IS_LATEST=false ;;
esac
echo "is_latest=$IS_LATEST" >> $GITHUB_OUTPUT

build:
name: Build (${{ matrix.arch }})
if: github.event_name != 'pull_request'
needs: extract-version
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
# Build from the requested version's tag, not the default branch.
ref: ${{ format('@forestadmin/mcp-server@{0}', github.event.inputs.version) }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Build and LOAD locally first (no push) so we can smoke-test and scan the
# exact image before it reaches the registry. Nothing is published until
# both gates pass — otherwise a vulnerable/broken image would be pullable
# by digest even when the gate "fails".
- name: Build (load for gating)
uses: docker/build-push-action@v6
with:
context: .
file: packages/mcp-server/Dockerfile
platforms: ${{ matrix.platform }}
load: true
tags: mcp-server:${{ matrix.arch }}
cache-from: type=gha,scope=mcp-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=mcp-${{ matrix.arch }}

- name: Smoke test (entrypoint + module graph + boot)
run: sh packages/mcp-server/docker/smoke-test.sh mcp-server:${{ matrix.arch }}

# Gate by origin (see the validate job): OS blocks; the MCP server's npm
# deps are report-only (already shipped via the package).
- name: Scan OS packages (blocking)
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: mcp-server:${{ matrix.arch }}
vuln-type: os
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '1'
- name: Scan libraries (report only)
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: mcp-server:${{ matrix.arch }}
vuln-type: library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '0'

# All gates passed (smoke + OS scan). Publish by digest (cache hit).
- name: Push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: packages/mcp-server/Dockerfile
platforms: ${{ matrix.platform }}
outputs: type=image,name=ghcr.io/forestadmin/mcp-server,push-by-digest=true,name-canonical=true,push=true
sbom: true
cache-from: type=gha,scope=mcp-${{ matrix.arch }}

- name: Export digest
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}

- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

merge:
name: Publish multi-arch manifest
if: github.event_name != 'pull_request'
needs: [extract-version, build]
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digest-*
merge-multiple: true
path: /tmp/digests

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Create and push multi-arch manifest
working-directory: /tmp/digests
env:
IMAGE: ghcr.io/forestadmin/mcp-server
FULL: ${{ needs.extract-version.outputs.full }}
MINOR: ${{ needs.extract-version.outputs.minor }}
MAJOR: ${{ needs.extract-version.outputs.major }}
IS_LATEST: ${{ needs.extract-version.outputs.is_latest }}
run: |
# Always publish the immutable full-version tag. The mutable tags
# (minor, major, latest) only move when this is the latest stable
# version, so neither a prerelease nor a rebuild of an older version
# ever overwrites the current stable :latest / :1 images.
TAGS="-t $IMAGE:$FULL"
if [ "$IS_LATEST" = "true" ]; then
TAGS="$TAGS -t $IMAGE:$MINOR -t $IMAGE:$MAJOR -t $IMAGE:latest"
fi
docker buildx imagetools create $TAGS \
$(printf "$IMAGE@sha256:%s " *)

- name: Inspect manifest
run: docker buildx imagetools inspect ghcr.io/forestadmin/mcp-server:${{ needs.extract-version.outputs.full }}
2 changes: 1 addition & 1 deletion packages/mcp-server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Overview

`@forestadmin/mcp-server` is a Model Context Protocol (MCP) server that exposes Forest Admin data operations (list, CRUD, relations, custom actions) to AI assistants over an OAuth-authenticated, streamable HTTP transport. It runs either standalone (`forest-mcp-server` CLI) or embedded in a Forest Admin agent via `agent.mountAiMcpServer()`.
`@forestadmin/mcp-server` is a Model Context Protocol (MCP) server that exposes Forest Admin data operations (list, CRUD, relations, custom actions) to AI assistants over an OAuth-authenticated, streamable HTTP transport. It runs either standalone (`forest-mcp-server` CLI) or embedded in a Forest Admin agent via `agent.mountAiMcpServer()`. It is also distributed as a Docker image (`ghcr.io/forestadmin/mcp-server`) for Node-less deployments — build assets live in `docker/` + `Dockerfile`, published by `.github/workflows/docker-publish-mcp-server.yml`.

## Architecture

Expand Down
Loading
Loading