Skip to content

Commit 4688419

Browse files
emyllerkhvn26
andauthored
feat(MCP): Run an MCP server out of OpenAPI specs (#7670)
Co-authored-by: Kim Gustyr <kim.gustyr@flagsmith.com>
1 parent de382aa commit 4688419

19 files changed

Lines changed: 2890 additions & 6 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: MCP Pull Request
2+
3+
permissions:
4+
contents: read # For actions/checkout
5+
6+
on:
7+
pull_request:
8+
paths:
9+
- mcp/**
10+
- .github/**
11+
types: [opened, synchronize, reopened, ready_for_review]
12+
push:
13+
paths:
14+
- mcp/**
15+
- .github/**
16+
branches:
17+
- main
18+
19+
defaults:
20+
run:
21+
working-directory: mcp
22+
23+
jobs:
24+
test:
25+
runs-on: ubuntu-latest
26+
name: MCP Tests
27+
28+
steps:
29+
- name: Cloning repo
30+
uses: actions/checkout@v5
31+
32+
- name: Install uv
33+
uses: astral-sh/setup-uv@v6
34+
with:
35+
python-version: "3.14"
36+
enable-cache: true
37+
cache-dependency-glob: mcp/uv.lock
38+
39+
- name: Install dependencies
40+
run: make install
41+
42+
- uses: liskin/gh-problem-matcher-wrap@v2
43+
with:
44+
action: add
45+
linters: mypy
46+
47+
- name: Check for new typing errors
48+
run: make typecheck
49+
50+
- uses: liskin/gh-problem-matcher-wrap@v2
51+
with:
52+
action: remove
53+
linters: mypy
54+
55+
- name: Run tests
56+
run: make test

‎.pre-commit-config.yaml‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
repos:
22
- repo: https://github.com/astral-sh/ruff-pre-commit
3-
# Ruff version.
43
rev: v0.15.12
4+
# Each project is linted under its own config; the global hook runs once per project.
55
hooks:
6-
# Run the linter.
76
- id: ruff
7+
name: api-lint
8+
files: ^api/
89
args: [--config, api/pyproject.toml, --config, "src = ['api']", --fix]
9-
# Run the formatter.
1010
- id: ruff-format
11+
name: api-format
12+
files: ^api/
1113
args: [--config, api/pyproject.toml, --config, "src = ['api']"]
14+
- id: ruff
15+
name: mcp-lint
16+
files: ^mcp/
17+
args: [--config, mcp/pyproject.toml, --config, "src = ['mcp/src']", --fix]
18+
- id: ruff-format
19+
name: mcp-format
20+
files: ^mcp/
21+
args: [--config, mcp/pyproject.toml, --config, "src = ['mcp/src']"]
1222

1323
- repo: https://github.com/pre-commit/pre-commit-hooks
1424
rev: v6.0.0
@@ -34,12 +44,22 @@ repos:
3444
entry: make -C api generate-docs
3545
pass_filenames: false
3646
types_or: [python, toml]
37-
- id: python-typecheck
38-
name: python-typecheck
47+
- id: api-typecheck
48+
name: api-typecheck
3949
language: system
4050
entry: uv run --directory api mypy .
4151
require_serial: true
4252
pass_filenames: false
53+
files: ^api/
54+
types: [python]
55+
stages: [pre-push]
56+
- id: mcp-typecheck
57+
name: mcp-typecheck
58+
language: system
59+
entry: uv run --directory mcp mypy .
60+
require_serial: true
61+
pass_filenames: false
62+
files: ^mcp/
4363
types: [python]
4464
stages: [pre-push]
4565

@@ -52,7 +72,11 @@ repos:
5272
rev: 0.11.14 # Ensure this matches the version in api/pyproject.toml
5373
hooks:
5474
- id: uv-lock
75+
name: api-lockcheck
5576
args: ["--project", "api", "--check"]
77+
- id: uv-lock
78+
name: mcp-lockcheck
79+
args: ["--project", "mcp", "--check"]
5680

5781
- repo: https://github.com/renovatebot/pre-commit-hooks
5882
rev: 43.205.2
@@ -65,5 +89,5 @@ default_install_hook_types: [pre-commit, pre-push]
6589
default_stages: [pre-commit]
6690

6791
ci:
68-
skip: [generate-docs, python-typecheck, renovate-config-validator]
92+
skip: [generate-docs, api-typecheck, mcp-typecheck, renovate-config-validator]
6993
autoupdate_commit_msg: "ci: pre-commit autoupdate"

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ Install Git hooks: `make install-hooks`
88
For backend code, read @api/README.md
99
For frontend code, read @frontend/README.md
1010
For docs.flagsmith.com source, read @docs/README.md
11+
For MCP code, read @mcp/README.md

‎mcp/.gitignore‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Python / uv
2+
.venv
3+
__pycache__/
4+
*.py[cod]
5+
dist/
6+
*.egg-info/
7+
8+
# Test / type / lint caches
9+
.coverage
10+
.coverage.*
11+
htmlcov/
12+
coverage.xml
13+
.pytest_cache/
14+
.mypy_cache/
15+
.ruff_cache/

‎mcp/Makefile‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.PHONY: install
2+
install:
3+
uv sync --frozen $(opts)
4+
5+
.PHONY: serve
6+
serve:
7+
uv run flagsmith-mcp
8+
9+
.PHONY: lint
10+
lint:
11+
uv run ruff check $(opts)
12+
uv run ruff format --check $(opts)
13+
14+
.PHONY: typecheck
15+
typecheck:
16+
uv run mypy .
17+
18+
.PHONY: test
19+
test:
20+
uv run pytest $(opts)

‎mcp/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Flagsmith MCP Server
2+
3+
## Local development
4+
5+
This application assumes the following software is installed:
6+
- [uv](https://docs.uv.dev/)
7+
- [GNU Make](https://www.gnu.org/software/make/)
8+
9+
Install dependencies with `make install`.
10+
11+
Run the server with `make serve`.
12+
13+
Run tests with `make test`.

‎mcp/pyproject.toml‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
[project]
2+
name = "flagsmith-mcp"
3+
version = "0.1.0"
4+
description = "An MCP server connecting to the Flagsmith API."
5+
authors = [{ name = "Flagsmith", email = "support@flagsmith.com" }]
6+
readme = "README.md"
7+
requires-python = ">=3.10"
8+
dependencies = [
9+
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
10+
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
11+
]
12+
13+
[project.scripts]
14+
flagsmith-mcp = "flagsmith_mcp.server:run"
15+
16+
[dependency-groups]
17+
dev = [
18+
"mypy>=2.1.0,<3.0.0", # Static type checking
19+
"openapi-pydantic>=0.5.0,<1.0.0", # Build OpenAPI specs as fixtures
20+
"pytest>=9.0.3,<10.0.0", # Run tests
21+
"pytest-asyncio>=1.3.0,<2.0.0", # Run asynchronous tests
22+
"pytest-cov>=7.0.0,<8.0.0", # Measure test coverage
23+
"pytest-httpx>=0.35.0,<1.0.0", # Mock HTTP interactions
24+
"ruff>=0.15.12,<0.16.0", # Lint and format
25+
]
26+
27+
[build-system]
28+
requires = ["uv_build>=0.11.0,<0.12.0"]
29+
build-backend = "uv_build"
30+
31+
[tool.pytest.ini_options]
32+
asyncio_mode = "auto"
33+
addopts = "--cov=flagsmith_mcp --cov-report=term-missing --cov-fail-under=100"
34+
35+
[tool.coverage.run]
36+
branch = true
37+
38+
[tool.ruff]
39+
line-length = 88
40+
target-version = "py310"
41+
42+
[tool.ruff.lint]
43+
# Establish parity with flake8 + isort
44+
select = ["C901", "E4", "E7", "E9", "F", "I", "W"]
45+
46+
[tool.mypy]
47+
strict = true
48+
show_absolute_path = true

‎mcp/src/flagsmith_mcp/__init__.py‎

Whitespace-only changes.

‎mcp/src/flagsmith_mcp/auth.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from collections.abc import Generator
2+
3+
import httpx
4+
from fastmcp.server.dependencies import get_http_headers
5+
6+
7+
class FlagsmithAuth(httpx.Auth):
8+
def __init__(self, global_master_api_key: str | None = None) -> None:
9+
self._global_master_api_key = global_master_api_key
10+
11+
def auth_flow(
12+
self, request: httpx.Request
13+
) -> Generator[httpx.Request, httpx.Response, None]:
14+
if "authorization" not in request.headers:
15+
# Prefer the caller's forwarded MCP `--header`; fall back to the
16+
# server's own static token (the only credential under stdio).
17+
forwarded = get_http_headers(include={"authorization"})
18+
if (
19+
authorization := forwarded.get("authorization")
20+
or self._global_authorization_value()
21+
):
22+
request.headers["authorization"] = authorization
23+
yield request
24+
25+
def _global_authorization_value(self) -> str | None:
26+
if self._global_master_api_key:
27+
return f"Api-Key {self._global_master_api_key}"
28+
return None

‎mcp/src/flagsmith_mcp/config.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Literal
2+
3+
from pydantic import Field, model_validator
4+
from pydantic_settings import BaseSettings
5+
6+
Transport = Literal["http", "stdio"]
7+
8+
9+
class Settings(BaseSettings):
10+
model_config = {"use_attribute_docstrings": True}
11+
12+
flagsmith_api_url: str = Field(
13+
default="https://api.flagsmith.com",
14+
)
15+
"""Flagsmith API base URL."""
16+
flagsmith_api_token: str | None = Field(
17+
default=None,
18+
)
19+
"""Flagsmith Master API Key. Required for stdio transport."""
20+
transport: Transport = Field(
21+
default="http",
22+
)
23+
"""MCP transport to use."""
24+
25+
@model_validator(mode="after")
26+
def validate_stdio_token(self) -> "Settings":
27+
# stdio has no inbound request to forward a credential from, so the
28+
# server must hold its own master API key.
29+
if self.transport == "stdio" and self.flagsmith_api_token is None:
30+
raise ValueError(
31+
"FLAGSMITH_API_TOKEN is required when TRANSPORT is 'stdio'"
32+
)
33+
return self

0 commit comments

Comments
 (0)