Skip to content

Commit 0f29de7

Browse files
authored
feat(MCP): Authentication, configuration and HTTP transport (#7680)
1 parent 83b460a commit 0f29de7

8 files changed

Lines changed: 993 additions & 11 deletions

File tree

mcp/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
description = "An MCP server connecting to the Flagsmith API."
55
authors = [{ name = "Flagsmith", email = "support@flagsmith.com" }]
66
readme = "README.md"
7-
requires-python = ">=3.14,<3.15"
7+
requires-python = ">=3.10"
88
dependencies = [
99
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
1010
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
@@ -37,7 +37,7 @@ branch = true
3737

3838
[tool.ruff]
3939
line-length = 88
40-
target-version = "py314"
40+
target-version = "py310"
4141

4242
[tool.ruff.lint]
4343
# Establish parity with flake8 + isort

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: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Literal
22

3-
from pydantic import Field
3+
from pydantic import Field, model_validator
44
from pydantic_settings import BaseSettings
55

66
Transport = Literal["http", "stdio"]
@@ -16,8 +16,18 @@ class Settings(BaseSettings):
1616
flagsmith_api_token: str | None = Field(
1717
default=None,
1818
)
19-
"""Flagsmith Master API Key. Required for stdio transport, optional for http transport (caller-supplied credential will be used instead)."""
19+
"""Flagsmith Master API Key. Required for stdio transport."""
2020
transport: Transport = Field(
2121
default="http",
2222
)
2323
"""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

mcp/src/flagsmith_mcp/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mcp.types import ToolAnnotations
99

1010
from flagsmith_mcp import config, constants
11+
from flagsmith_mcp.auth import FlagsmithAuth
1112

1213
ROUTE_MAPS = [
1314
RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL),
@@ -41,10 +42,14 @@ def _fetch_spec() -> dict[str, Any]:
4142
def create_server(settings: config.Settings) -> FastMCP[None]:
4243
return FastMCP.from_openapi(
4344
openapi_spec=_fetch_spec(),
44-
client=httpx.AsyncClient(base_url=settings.flagsmith_api_url),
45+
client=httpx.AsyncClient(
46+
base_url=settings.flagsmith_api_url,
47+
auth=FlagsmithAuth(settings.flagsmith_api_token),
48+
),
4549
name="Flagsmith",
4650
route_maps=ROUTE_MAPS,
4751
mcp_component_fn=_customise,
52+
validate_output=False, # TODO https://github.com/Flagsmith/flagsmith/issues/7679
4853
)
4954

5055

mcp/tests/unit/test_auth.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import httpx
2+
from fastmcp.server.http import set_http_request
3+
from starlette.requests import Request
4+
5+
from flagsmith_mcp import auth
6+
7+
8+
def test_flagsmith_auth__header_in_http_request__forwards_to_upstream() -> None:
9+
# Given an inbound HTTP request carrying an Authorization header, exactly as
10+
# FastMCP's RequestContextMiddleware sets it under HTTP transport (--header)
11+
inbound = Request(
12+
{"type": "http", "headers": [(b"authorization", b"Api-Key caller")]}
13+
)
14+
upstream = httpx.Request("GET", "https://api.flagsmith.com/api/v1/organisations/")
15+
16+
# When the upstream auth flow runs within that request context
17+
with set_http_request(inbound):
18+
next(auth.FlagsmithAuth().auth_flow(upstream))
19+
20+
# Then the real get_http_headers picks it up and forwards it
21+
assert upstream.headers["authorization"] == "Api-Key caller"
22+
23+
24+
def test_flagsmith_auth__no_http_request__leaves_upstream_unchanged() -> None:
25+
# Given no active HTTP request (e.g. stdio transport)
26+
upstream = httpx.Request("GET", "https://api.flagsmith.com/api/v1/organisations/")
27+
28+
# When
29+
next(auth.FlagsmithAuth().auth_flow(upstream))
30+
31+
# Then nothing is forwarded
32+
assert "authorization" not in upstream.headers
33+
34+
35+
def test_flagsmith_auth__static_token_no_http_request__uses_api_key() -> None:
36+
# Given a static token and no active HTTP request (e.g. stdio transport)
37+
upstream = httpx.Request("GET", "https://api.flagsmith.com/api/v1/organisations/")
38+
39+
# When
40+
next(auth.FlagsmithAuth("ser.secret").auth_flow(upstream))
41+
42+
# Then the server's own token is sent as an Api-Key credential
43+
assert upstream.headers["authorization"] == "Api-Key ser.secret"
44+
45+
46+
def test_flagsmith_auth__forwarded_header__wins_over_static_token() -> None:
47+
# Given both a forwarded caller header and a static token
48+
inbound = Request(
49+
{"type": "http", "headers": [(b"authorization", b"Api-Key caller")]}
50+
)
51+
upstream = httpx.Request("GET", "https://api.flagsmith.com/api/v1/organisations/")
52+
53+
# When
54+
with set_http_request(inbound):
55+
next(auth.FlagsmithAuth("ser.secret").auth_flow(upstream))
56+
57+
# Then the caller's forwarded credential takes precedence
58+
assert upstream.headers["authorization"] == "Api-Key caller"
59+
60+
61+
def test_flagsmith_auth__upstream_already_authorized__does_not_override() -> None:
62+
# Given an upstream request that already carries a credential, and an inbound
63+
# header that differs
64+
inbound = Request(
65+
{"type": "http", "headers": [(b"authorization", b"Api-Key context")]}
66+
)
67+
upstream = httpx.Request(
68+
"GET",
69+
"https://api.flagsmith.com/api/v1/organisations/",
70+
headers={"authorization": "Api-Key static"},
71+
)
72+
73+
# When
74+
with set_http_request(inbound):
75+
next(auth.FlagsmithAuth().auth_flow(upstream))
76+
77+
# Then the existing credential wins
78+
assert upstream.headers["authorization"] == "Api-Key static"

mcp/tests/unit/test_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,15 @@ def test_settings__unsupported_transport__raises(
4242
# When / Then
4343
with pytest.raises(ValidationError):
4444
config.Settings()
45+
46+
47+
def test_settings__stdio_without_token__raises(
48+
monkeypatch: pytest.MonkeyPatch,
49+
) -> None:
50+
# Given stdio transport but no API token
51+
monkeypatch.setenv("TRANSPORT", "stdio")
52+
monkeypatch.delenv("FLAGSMITH_API_TOKEN", raising=False)
53+
54+
# When / Then
55+
with pytest.raises(ValidationError, match="FLAGSMITH_API_TOKEN is required"):
56+
config.Settings()

mcp/tests/unit/test_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def run(self, transport: str) -> None:
137137

138138
monkeypatch.setattr(server, "create_server", lambda settings: FakeServer())
139139
monkeypatch.setenv("TRANSPORT", "stdio")
140+
monkeypatch.setenv("FLAGSMITH_API_TOKEN", "ser.secret")
140141

141142
# When
142143
server.run()

0 commit comments

Comments
 (0)