Skip to content
Merged
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
164 changes: 164 additions & 0 deletions .github/workflows/mcp-docker-build-publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
khvn26 marked this conversation as resolved.
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:
Comment thread
khvn26 marked this conversation as resolved.
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:
Comment thread
khvn26 marked this conversation as resolved.
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 }}
68 changes: 68 additions & 0 deletions mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -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)"]
10 changes: 9 additions & 1 deletion mcp/src/flagsmith_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -64,6 +66,12 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
auth=auth,
)

@server.custom_route("/health", methods=["GET"])
Comment thread
germangarces marked this conversation as resolved.
async def health(request: Request) -> PlainTextResponse:
return PlainTextResponse("OK")

return server


def run() -> None:
settings = config.Settings()
Expand Down
25 changes: 25 additions & 0 deletions mcp/tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
15 changes: 14 additions & 1 deletion mcp/tests/integration/test_mcp_server.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand All @@ -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"
26 changes: 1 addition & 25 deletions mcp/tests/integration/test_oauth.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
Loading