Skip to content

Commit 8ef95e0

Browse files
authored
feat(MCP): Expose Prometheus metrics (#7705)
1 parent cf0d7b0 commit 8ef95e0

7 files changed

Lines changed: 307 additions & 298 deletions

File tree

mcp/pyproject.toml

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,27 @@ 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.10"
7+
requires-python = ">=3.11"
88
dependencies = [
9-
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
10-
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
9+
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
10+
"prometheus-client>=0.21.0,<1.0.0", # Export Prometheus metrics
11+
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
1112
]
1213

1314
[project.scripts]
1415
flagsmith-mcp = "flagsmith_mcp.server:run"
1516

1617
[dependency-groups]
1718
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-
"respx>=0.22,<1.0", # Mock HTTP interactions
24-
"ruff>=0.15.12,<0.16.0", # Lint and format
19+
"flagsmith-common[test-tools]>=3.9.1,<4.0.0", # Shared test fixtures
20+
"mypy>=2.1.0,<3.0.0", # Static type checking
21+
"openapi-pydantic>=0.5.0,<1.0.0", # Build OpenAPI specs as fixtures
22+
"pytest>=9.0.3,<10.0.0", # Run tests
23+
"pytest-asyncio>=1.3.0,<2.0.0", # Run asynchronous tests
24+
"pytest-cov>=7.0.0,<8.0.0", # Measure test coverage
25+
"pytest-mock>=3.15.1,<4.0.0", # Mock via fixtures
26+
"respx>=0.22,<1.0", # Mock HTTP interactions
27+
"ruff>=0.15.12,<0.16.0", # Lint and format
2528
]
2629

2730
[build-system]
@@ -37,7 +40,7 @@ branch = true
3740

3841
[tool.ruff]
3942
line-length = 88
40-
target-version = "py310"
43+
target-version = "py311"
4144

4245
[tool.ruff.lint]
4346
# Establish parity with flake8 + isort

mcp/src/flagsmith_mcp/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class Settings(BaseSettings):
2121
default="http",
2222
)
2323
"""MCP transport to use."""
24+
metrics_port: int | None = Field(
25+
default=None,
26+
)
27+
"""Serve Prometheus metrics on this port. Disabled when unset."""
2428
mcp_server_url: str = Field(
2529
default="http://127.0.0.1:8000",
2630
)

mcp/src/flagsmith_mcp/metrics.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import time
2+
from collections.abc import Sequence
3+
4+
import mcp.types as mt
5+
import pydantic_core
6+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
7+
from fastmcp.tools.base import Tool, ToolResult
8+
from prometheus_client import Gauge, Histogram
9+
10+
flagsmith_mcp_tool_call_duration_seconds = Histogram(
11+
"flagsmith_mcp_tool_call_duration_seconds",
12+
"Time spent serving an MCP tool call, including the upstream "
13+
"Flagsmith API request.",
14+
labelnames=["tool", "status"],
15+
)
16+
flagsmith_mcp_tool_result_bytes = Histogram(
17+
"flagsmith_mcp_tool_result_bytes",
18+
"Serialised size of a tool call result's text content and "
19+
"structuredContent. Helps approximate the call's token cost: clients "
20+
"render either or both into the agent's context.",
21+
labelnames=["tool", "content"],
22+
buckets=(256, 1024, 4096, 16384, 65536, 262144, 1048576, float("inf")),
23+
)
24+
flagsmith_mcp_tool_catalogue_bytes = Gauge(
25+
"flagsmith_mcp_tool_catalogue_bytes",
26+
"Serialised size of the tool catalogue returned by tools/list. A proxy "
27+
"for the token cost every MCP session pays before any tool is called.",
28+
)
29+
30+
31+
class PrometheusMiddleware(Middleware):
32+
"""Record Prometheus metrics for MCP tool calls."""
33+
34+
async def on_call_tool(
35+
self,
36+
context: MiddlewareContext[mt.CallToolRequestParams],
37+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
38+
) -> ToolResult:
39+
tool = context.message.name
40+
start = time.perf_counter()
41+
try:
42+
result = await call_next(context)
43+
except Exception:
44+
flagsmith_mcp_tool_call_duration_seconds.labels(
45+
tool=tool, status="error"
46+
).observe(time.perf_counter() - start)
47+
raise
48+
flagsmith_mcp_tool_call_duration_seconds.labels(
49+
tool=tool, status="success"
50+
).observe(time.perf_counter() - start)
51+
# Text blocks already hold the serialised payload; structuredContent
52+
# costs one compact dump, matching its wire encoding.
53+
unstructured_bytes = sum(
54+
len(block.text.encode())
55+
for block in result.content
56+
if isinstance(block, mt.TextContent)
57+
)
58+
structured_bytes = (
59+
len(pydantic_core.to_json(result.structured_content, fallback=str))
60+
if result.structured_content is not None
61+
else 0
62+
)
63+
for content, size in (
64+
("unstructured", unstructured_bytes),
65+
("structured", structured_bytes),
66+
):
67+
flagsmith_mcp_tool_result_bytes.labels(
68+
tool=tool,
69+
content=content,
70+
).observe(size)
71+
return result
72+
73+
async def on_list_tools(
74+
self,
75+
context: MiddlewareContext[mt.ListToolsRequest],
76+
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
77+
) -> Sequence[Tool]:
78+
tools = await call_next(context)
79+
flagsmith_mcp_tool_catalogue_bytes.set(
80+
len(
81+
pydantic_core.to_json(
82+
[
83+
tool.to_mcp_tool().model_dump(exclude_none=True, by_alias=True)
84+
for tool in tools
85+
]
86+
)
87+
)
88+
)
89+
return tools

mcp/src/flagsmith_mcp/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from fastmcp.utilities.components import FastMCPComponent
77
from fastmcp.utilities.openapi.models import HttpMethod, HTTPRoute
88
from mcp.types import ToolAnnotations
9+
from prometheus_client import start_http_server
910
from starlette.requests import Request
1011
from starlette.responses import PlainTextResponse
1112

1213
from flagsmith_mcp import config, constants
1314
from flagsmith_mcp.auth import FlagsmithAuth
15+
from flagsmith_mcp.metrics import PrometheusMiddleware
1416
from flagsmith_mcp.oauth import FlagsmithResourceAuth
1517

1618
ROUTE_MAPS = [
@@ -66,6 +68,8 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
6668
auth=auth,
6769
)
6870

71+
server.add_middleware(PrometheusMiddleware())
72+
6973
@server.custom_route("/health", methods=["GET"])
7074
async def health(request: Request) -> PlainTextResponse:
7175
return PlainTextResponse("OK")
@@ -75,4 +79,7 @@ async def health(request: Request) -> PlainTextResponse:
7579

7680
def run() -> None:
7781
settings = config.Settings()
78-
create_server(settings).run(transport=settings.transport)
82+
server = create_server(settings)
83+
if settings.metrics_port is not None:
84+
start_http_server(settings.metrics_port)
85+
server.run(transport=settings.transport)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import pytest
2+
from common.test_tools import AssertMetricFixture
3+
from fastmcp import Client
4+
from fastmcp.client.transports import FastMCPTransport
5+
from fastmcp.exceptions import ToolError
6+
from prometheus_client import REGISTRY
7+
from respx import MockRouter
8+
9+
10+
async def test_metrics__successful_tool_call__records_duration_and_result_size(
11+
client: Client[FastMCPTransport],
12+
respx_mock: MockRouter,
13+
assert_metric: AssertMetricFixture,
14+
) -> None:
15+
# Given
16+
respx_mock.get("https://api.flagsmith.com/environments/").respond(
17+
json={"results": []}
18+
)
19+
20+
# When
21+
await client.call_tool("list_environments", {})
22+
23+
# Then
24+
assert_metric(
25+
name="flagsmith_mcp_tool_call_duration_seconds_count",
26+
labels={"tool": "list_environments", "status": "success"},
27+
value=1,
28+
)
29+
for content in ("unstructured", "structured"):
30+
assert_metric(
31+
name="flagsmith_mcp_tool_result_bytes_count",
32+
labels={"tool": "list_environments", "content": content},
33+
value=1,
34+
)
35+
content_sum = REGISTRY.get_sample_value(
36+
"flagsmith_mcp_tool_result_bytes_sum",
37+
{"tool": "list_environments", "content": content},
38+
)
39+
assert content_sum is not None
40+
assert content_sum > 0
41+
42+
43+
async def test_metrics__failing_tool_call__records_error_duration_only(
44+
client: Client[FastMCPTransport],
45+
respx_mock: MockRouter,
46+
assert_metric: AssertMetricFixture,
47+
) -> None:
48+
# Given
49+
respx_mock.get("https://api.flagsmith.com/environments/").respond(status_code=502)
50+
51+
# When
52+
with pytest.raises(ToolError):
53+
await client.call_tool("list_environments", {})
54+
55+
# Then
56+
assert_metric(
57+
name="flagsmith_mcp_tool_call_duration_seconds_count",
58+
labels={"tool": "list_environments", "status": "error"},
59+
value=1,
60+
)
61+
assert (
62+
REGISTRY.get_sample_value(
63+
"flagsmith_mcp_tool_result_bytes_count",
64+
{"tool": "list_environments", "content": "unstructured"},
65+
)
66+
is None
67+
)
68+
69+
70+
async def test_metrics__tools_list__records_catalogue_size(
71+
client: Client[FastMCPTransport],
72+
) -> None:
73+
# Given the server started via the client fixture
74+
# When
75+
tools = await client.list_tools()
76+
77+
# Then a catalogue of two tools weighs at least a name and a schema each
78+
assert tools
79+
catalogue_bytes = REGISTRY.get_sample_value("flagsmith_mcp_tool_catalogue_bytes")
80+
assert catalogue_bytes is not None
81+
assert catalogue_bytes > len(tools) * 50

mcp/tests/unit/test_server.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from typing import Any
1+
import os
22

33
import openapi_pydantic as openapi
44
import pytest
55
from fastmcp import Client
66
from mcp.types import ToolAnnotations
7+
from pytest_mock import MockerFixture
78
from respx import MockRouter
89

910
from flagsmith_mcp import config, constants, server
@@ -124,21 +125,52 @@ async def test_create_server__untagged_route__excluded_from_tools(
124125

125126

126127
def test_run__configured_transport__runs_server_with_it(
127-
monkeypatch: pytest.MonkeyPatch,
128+
mocker: MockerFixture,
128129
) -> None:
129130
# Given
130-
calls: dict[str, Any] = {}
131+
mocker.patch.dict(
132+
os.environ,
133+
{"TRANSPORT": "stdio", "FLAGSMITH_API_TOKEN": "ser.secret"},
134+
clear=True,
135+
)
136+
create_server_mock = mocker.patch.object(server, "create_server", autospec=True)
137+
138+
# When
139+
server.run()
140+
141+
# Then
142+
create_server_mock.return_value.run.assert_called_once_with(transport="stdio")
143+
131144

132-
class FakeServer:
133-
def run(self, transport: str) -> None:
134-
calls["transport"] = transport
145+
def test_run__metrics_port_unset__metrics_server_not_started(
146+
mocker: MockerFixture,
147+
) -> None:
148+
# Given
149+
mocker.patch.dict(os.environ, {}, clear=True)
150+
mocker.patch.object(server, "create_server", autospec=True)
151+
start_http_server_mock = mocker.patch.object(
152+
server, "start_http_server", autospec=True
153+
)
135154

136-
monkeypatch.setattr(server, "create_server", lambda settings: FakeServer())
137-
monkeypatch.setenv("TRANSPORT", "stdio")
138-
monkeypatch.setenv("FLAGSMITH_API_TOKEN", "ser.secret")
155+
# When
156+
server.run()
157+
158+
# Then
159+
start_http_server_mock.assert_not_called()
160+
161+
162+
def test_run__metrics_port_set__metrics_server_started_with_it(
163+
mocker: MockerFixture,
164+
) -> None:
165+
# Given
166+
mocker.patch.dict(os.environ, {"METRICS_PORT": "9464"}, clear=True)
167+
mocker.patch.object(server, "create_server", autospec=True)
168+
start_http_server_mock = mocker.patch.object(
169+
server, "start_http_server", autospec=True
170+
)
139171

140172
# When
141173
server.run()
142174

143175
# Then
144-
assert calls == {"transport": "stdio"}
176+
start_http_server_mock.assert_called_once_with(9464)

0 commit comments

Comments
 (0)