diff --git a/.github/workflows/mcp-docker-build-publish.yml b/.github/workflows/mcp-docker-build-publish.yml new file mode 100644 index 000000000000..61f4afc6f93b --- /dev/null +++ b/.github/workflows/mcp-docker-build-publish.yml @@ -0,0 +1,164 @@ +name: MCP Build Docker Image and Publish + +on: + pull_request: + paths: + - mcp/** + - .github/workflows/mcp-docker-build-publish.yml + - .github/workflows/.reusable-docker-build.yml + - .github/workflows/.reusable-docker-publish.yml + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: + - main + paths: + - mcp/** + - .github/workflows/mcp-docker-build-publish.yml + - .github/workflows/.reusable-docker-build.yml + - .github/workflows/.reusable-docker-publish.yml + release: + types: + - released + +jobs: + docker-build-mcp: + name: Build MCP Image + uses: ./.github/workflows/.reusable-docker-build.yml + with: + file: mcp/Dockerfile + image-name: flagsmith-mcp + # On pull requests, validate the build without pushing to the registry. + ephemeral: ${{ github.event_name == 'pull_request' }} + + # Publish to Docker Hub + + docker-publish-mcp: + needs: [docker-build-mcp] + uses: ./.github/workflows/.reusable-docker-publish.yml + if: github.event_name == 'release' + with: + source-images: ${{ needs.docker-build-mcp.outputs.image }} + target-images: flagsmith/flagsmith-mcp + secrets: inherit + + # Publish to Quay.io + + docker-publish-quay-mcp: + needs: [docker-build-mcp] + uses: ./.github/workflows/.reusable-docker-publish.yml + if: github.event_name == 'release' + with: + target-registry-url: quay.io + docker-username: ${{ vars.QUAY_PUBLISH_USERNAME }} + docker-password-secret-name: QUAY_PUBLISH_PASSWORD + source-images: ${{ needs.docker-build-mcp.outputs.image }} + target-images: quay.io/${{ vars.QUAY_ORGANISATION_NAME }}/flagsmith-mcp + secrets: inherit + + # Publish to staging Amazon ECR on every push to main + + docker-publish-ecr-staging-mcp: + name: Publish MCP image to staging ECR + needs: [docker-build-mcp] + if: github.event_name == 'push' + runs-on: depot-ubuntu-latest + # The OIDC role trusts jobs running in the staging environment. + environment: staging + permissions: + contents: read + packages: read + id-token: write + steps: + - name: Cloning repo + uses: actions/checkout@v5 + with: + sparse-checkout: depot.json + sparse-checkout-cone-mode: false + + - name: Login to Github Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.MCP_ECR_GITHUB_ROLE_ARN }} + aws-region: eu-west-2 + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v1 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ vars.MCP_ECR_REPOSITORY_URL }} + tags: | + type=ref,event=branch + type=sha + + # Setup Docker buildx with Depot builder so imagetools have access to Depot cache + - uses: depot/use-action@v1 + + - name: Publish Image + uses: kphrx/docker-buildx-imagetools-action@v0.1.2 + with: + sources: ${{ needs.docker-build-mcp.outputs.image }} + tags: ${{ steps.meta.outputs.tags }} + + # Publish to production Amazon ECR on release + + docker-publish-ecr-mcp: + name: Publish MCP image to ECR + needs: [docker-build-mcp] + if: github.event_name == 'release' + runs-on: depot-ubuntu-latest + # The OIDC role trusts jobs running in the production environment. + environment: production + permissions: + contents: read + packages: read + id-token: write + steps: + - name: Cloning repo + uses: actions/checkout@v5 + with: + sparse-checkout: depot.json + sparse-checkout-cone-mode: false + + - name: Login to Github Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.MCP_ECR_GITHUB_ROLE_ARN }} + aws-region: eu-west-2 + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v1 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ vars.MCP_ECR_REPOSITORY_URL }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + # Setup Docker buildx with Depot builder so imagetools have access to Depot cache + - uses: depot/use-action@v1 + + - name: Publish Image + uses: kphrx/docker-buildx-imagetools-action@v0.1.2 + with: + sources: ${{ needs.docker-build-mcp.outputs.image }} + tags: ${{ steps.meta.outputs.tags }} diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 000000000000..bf2afd240337 --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,68 @@ +# Flagsmith MCP server image. +# +# To build locally from the repo root: +# $ docker build -f mcp/Dockerfile -t flagsmith-mcp:dev . + +ARG CI_COMMIT_SHA=dev + +# Pin runtimes versions +ARG PYTHON_VERSION=3.13 +ARG UV_VERSION=0.11.18 + +FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv +FROM cgr.dev/chainguard/wolfi-base:latest AS wolfi-base + +# * build [wolfi-base] +FROM wolfi-base AS build +WORKDIR /build + +ARG PYTHON_VERSION +RUN apk add build-base \ + python-${PYTHON_VERSION} \ + python-${PYTHON_VERSION}-dev + +COPY --from=uv /uv /usr/local/bin/uv + +ENV UV_PROJECT_ENVIRONMENT=/build/.venv \ + UV_PYTHON_PREFERENCE=only-system \ + UV_PYTHON=python${PYTHON_VERSION} \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_CACHE_DIR=/root/.cache/uv + +# Resolve dependencies first so the layer is cached independently of source changes. +COPY mcp/pyproject.toml mcp/uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-install-project + +# Install the project itself as a non-editable wheel into the venv. +COPY mcp/README.md ./ +COPY mcp/src ./src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable + +# * runtime [wolfi-base] +FROM wolfi-base AS runtime + +# Install Python and make it available to the venv's entrypoints. +ARG PYTHON_VERSION +RUN apk add python-${PYTHON_VERSION} && \ + mkdir /build/ && ln -s /usr/local/ /build/.venv + +COPY --from=build /build/.venv/ /usr/local/ + +ARG CI_COMMIT_SHA +RUN echo ${CI_COMMIT_SHA} > /CI_COMMIT_SHA + +ENV TRANSPORT=http \ + FASTMCP_HOST=0.0.0.0 \ + FASTMCP_PORT=8000 + +EXPOSE 8000 + +USER nobody + +ENTRYPOINT ["flagsmith-mcp"] + +HEALTHCHECK --interval=10s --timeout=3s --retries=3 --start-period=10s \ + CMD ["python", "-c", "import os,urllib.request; urllib.request.urlopen(f\"http://127.0.0.1:{os.environ['FASTMCP_PORT']}/health\", timeout=2)"] diff --git a/mcp/src/flagsmith_mcp/server.py b/mcp/src/flagsmith_mcp/server.py index e755808c3da0..7393a862d3cc 100644 --- a/mcp/src/flagsmith_mcp/server.py +++ b/mcp/src/flagsmith_mcp/server.py @@ -6,6 +6,8 @@ from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.openapi.models import HttpMethod, HTTPRoute from mcp.types import ToolAnnotations +from starlette.requests import Request +from starlette.responses import PlainTextResponse from flagsmith_mcp import config, constants from flagsmith_mcp.auth import FlagsmithAuth @@ -51,7 +53,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]: resource_url=settings.mcp_server_url, authorization_server=settings.flagsmith_api_url, ) - return FastMCP.from_openapi( + server = FastMCP.from_openapi( openapi_spec=_fetch_spec(), client=httpx.AsyncClient( base_url=settings.flagsmith_api_url, @@ -64,6 +66,12 @@ def create_server(settings: config.Settings) -> FastMCP[None]: auth=auth, ) + @server.custom_route("/health", methods=["GET"]) + async def health(request: Request) -> PlainTextResponse: + return PlainTextResponse("OK") + + return server + def run() -> None: settings = config.Settings() diff --git a/mcp/tests/integration/conftest.py b/mcp/tests/integration/conftest.py index d9ea1edc5e14..79b25b9326b8 100644 --- a/mcp/tests/integration/conftest.py +++ b/mcp/tests/integration/conftest.py @@ -1,5 +1,7 @@ from collections.abc import AsyncIterator +from typing import Callable +import httpx import openapi_pydantic as openapi import pytest from fastmcp import Client, FastMCP @@ -9,6 +11,8 @@ from flagsmith_mcp import config, constants from flagsmith_mcp import server as server_module +HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]] + @pytest.fixture def openapi_spec() -> openapi.OpenAPI: @@ -55,3 +59,24 @@ def server() -> FastMCP: async def client(server: FastMCP) -> AsyncIterator[Client[FastMCPTransport]]: async with Client(transport=server) as connected: yield connected + + +@pytest.fixture +def http_client_factory() -> HTTPClientFactoryFixture: + async def factory(server: FastMCP) -> AsyncIterator[httpx.AsyncClient]: + transport = httpx.ASGITransport(app=server.http_app()) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as connected: + yield connected + + return factory + + +@pytest.fixture +async def http_client( + server: FastMCP, + http_client_factory: HTTPClientFactoryFixture, +) -> AsyncIterator[httpx.AsyncClient]: + async for client in http_client_factory(server): + yield client diff --git a/mcp/tests/integration/test_mcp_server.py b/mcp/tests/integration/test_mcp_server.py index 608369b35b6c..f90185643d9d 100644 --- a/mcp/tests/integration/test_mcp_server.py +++ b/mcp/tests/integration/test_mcp_server.py @@ -1,9 +1,10 @@ +import httpx from fastmcp import Client from fastmcp.client.transports import FastMCPTransport from mcp.types import LATEST_PROTOCOL_VERSION -async def test_mcp_server__running__succeeds_health_check( +async def test_mcp_server__ping__returns_expected( client: Client[FastMCPTransport], ) -> None: """https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping""" @@ -26,3 +27,15 @@ async def test_mcp_server__initialize__responds_per_protocol_version( # Then assert result is not None assert result.protocolVersion == LATEST_PROTOCOL_VERSION + + +async def test_mcp_server__health__returns_ok( + http_client: httpx.AsyncClient, +) -> None: + # Given the server started via the client fixture + # When + response = await http_client.get("/health") + + # Then + assert response.status_code == 200 + assert response.text == "OK" diff --git a/mcp/tests/integration/test_oauth.py b/mcp/tests/integration/test_oauth.py index 13a646ffd154..f32011f2ec41 100644 --- a/mcp/tests/integration/test_oauth.py +++ b/mcp/tests/integration/test_oauth.py @@ -1,40 +1,16 @@ from collections.abc import AsyncIterator -from typing import Callable import httpx import pytest +from conftest import HTTPClientFactoryFixture from fastmcp import FastMCP from flagsmith_mcp import config from flagsmith_mcp import server as server_module -HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]] - - PRM_PATH = "/.well-known/oauth-protected-resource/mcp" -@pytest.fixture -def http_client_factory() -> HTTPClientFactoryFixture: - async def factory(server: FastMCP) -> AsyncIterator[httpx.AsyncClient]: - transport = httpx.ASGITransport(app=server.http_app()) - async with httpx.AsyncClient( - transport=transport, base_url="http://testserver" - ) as connected: - yield connected - - return factory - - -@pytest.fixture -async def http_client( - server: FastMCP, - http_client_factory: HTTPClientFactoryFixture, -) -> AsyncIterator[httpx.AsyncClient]: - async for client in http_client_factory(server): - yield client - - @pytest.fixture def server_with_flagsmith_api_token() -> FastMCP: return server_module.create_server(