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
9 changes: 5 additions & 4 deletions mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ authors = [{ name = "Flagsmith", email = "support@flagsmith.com" }]
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
"prometheus-client>=0.21.0,<1.0.0", # Export Prometheus metrics
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
"flagsmith-common[otel]>=3.10.0,<4.0.0", # Logging and OTel export
"prometheus-client>=0.21.0,<1.0.0", # Export Prometheus metrics
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
]

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

[dependency-groups]
dev = [
"flagsmith-common[test-tools]>=3.9.1,<4.0.0", # Shared test fixtures
"flagsmith-common[test-tools]>=3.10.0,<4.0.0", # Shared test fixtures
"mypy>=2.1.0,<3.0.0", # Static type checking
"openapi-pydantic>=0.5.0,<1.0.0", # Build OpenAPI specs as fixtures
"pytest>=9.0.3,<10.0.0", # Run tests
Expand Down
17 changes: 17 additions & 0 deletions mcp/src/flagsmith_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ class Settings(BaseSettings):
default=None,
)
"""Serve Prometheus metrics on this port. Disabled when unset."""
log_level: str = Field(
default="INFO",
)
"""Log level for application loggers."""
log_format: Literal["generic", "json"] = Field(
default="generic",
)
"""Log output format."""
otel_exporter_otlp_endpoint: str | None = Field(
default=None,
)
"""OTLP endpoint to export logs and traces to. Export is disabled when
unset."""
otel_service_name: str = Field(
default="flagsmith-mcp",
)
"""Service name reported to OpenTelemetry."""
mcp_server_url: str = Field(
default="http://127.0.0.1:8000",
)
Expand Down
13 changes: 12 additions & 1 deletion mcp/src/flagsmith_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from flagsmith_mcp.auth import FlagsmithAuth
from flagsmith_mcp.metrics import PrometheusMiddleware
from flagsmith_mcp.oauth import FlagsmithResourceAuth
from flagsmith_mcp.telemetry import setup_telemetry

ROUTE_MAPS = [
RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL),
Expand Down Expand Up @@ -79,7 +80,17 @@ async def health(request: Request) -> PlainTextResponse:

def run() -> None:
settings = config.Settings()
setup_telemetry(settings)
server = create_server(settings)
if settings.metrics_port is not None:
start_http_server(settings.metrics_port)
server.run(transport=settings.transport)
if settings.transport == "http":
server.run(
transport=settings.transport,
show_banner=False,
# Let uvicorn log records propagate to the root logger so they
# are rendered by the configured formatter.
uvicorn_config={"log_config": None},
)
else:
server.run(transport=settings.transport, show_banner=False)
43 changes: 43 additions & 0 deletions mcp/src/flagsmith_mcp/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from common.core.logging import setup_logging
from common.core.otel import (
add_otel_trace_context,
build_otel_log_provider,
build_tracer_provider,
make_structlog_otel_processor,
)
from opentelemetry import trace
from structlog.typing import Processor

from flagsmith_mcp import config

APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"]


def setup_telemetry(settings: config.Settings) -> None:
"""Set up logging, exporting structlog events and traces to OpenTelemetry
when an OTLP endpoint is configured."""
otel_processors: list[Processor] | None = None
if settings.otel_exporter_otlp_endpoint:
endpoint = settings.otel_exporter_otlp_endpoint.rstrip("/")
log_provider = build_otel_log_provider(
endpoint=f"{endpoint}/v1/logs",
service_name=settings.otel_service_name,
)
otel_processors = [
add_otel_trace_context,
make_structlog_otel_processor(log_provider),
]
# Setting a global tracer provider also activates FastMCP's built-in
# per-request server spans.
Comment thread
khvn26 marked this conversation as resolved.
trace.set_tracer_provider(
build_tracer_provider(
endpoint=f"{endpoint}/v1/traces",
service_name=settings.otel_service_name,
)
)
setup_logging(
log_level=settings.log_level,
log_format=settings.log_format,
application_loggers=APPLICATION_LOGGERS,
otel_processors=otel_processors,
)
27 changes: 26 additions & 1 deletion mcp/tests/unit/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,16 @@ def test_run__configured_transport__runs_server_with_it(
clear=True,
)
create_server_mock = mocker.patch.object(server, "create_server", autospec=True)
setup_telemetry_mock = mocker.patch.object(server, "setup_telemetry", autospec=True)

# When
server.run()

# Then
create_server_mock.return_value.run.assert_called_once_with(transport="stdio")
setup_telemetry_mock.assert_called_once()
create_server_mock.return_value.run.assert_called_once_with(
transport="stdio", show_banner=False
)


def test_run__metrics_port_unset__metrics_server_not_started(
Expand All @@ -148,6 +152,7 @@ def test_run__metrics_port_unset__metrics_server_not_started(
# Given
mocker.patch.dict(os.environ, {}, clear=True)
mocker.patch.object(server, "create_server", autospec=True)
mocker.patch.object(server, "setup_telemetry", autospec=True)
start_http_server_mock = mocker.patch.object(
server, "start_http_server", autospec=True
)
Expand All @@ -165,6 +170,7 @@ def test_run__metrics_port_set__metrics_server_started_with_it(
# Given
mocker.patch.dict(os.environ, {"METRICS_PORT": "9464"}, clear=True)
mocker.patch.object(server, "create_server", autospec=True)
mocker.patch.object(server, "setup_telemetry", autospec=True)
start_http_server_mock = mocker.patch.object(
server, "start_http_server", autospec=True
)
Expand All @@ -174,3 +180,22 @@ def test_run__metrics_port_set__metrics_server_started_with_it(

# Then
start_http_server_mock.assert_called_once_with(9464)


def test_run__http_transport__banner_off_uvicorn_logs_propagated(
mocker: MockerFixture,
) -> None:
# Given
mocker.patch.dict(os.environ, {}, clear=True)
create_server_mock = mocker.patch.object(server, "create_server", autospec=True)
mocker.patch.object(server, "setup_telemetry", autospec=True)

# When
server.run()

# Then
create_server_mock.return_value.run.assert_called_once_with(
transport="http",
show_banner=False,
uvicorn_config={"log_config": None},
)
83 changes: 83 additions & 0 deletions mcp/tests/unit/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import os

from common.core.otel import add_otel_trace_context
from pytest_mock import MockerFixture

from flagsmith_mcp import config, telemetry


def test_setup_telemetry__no_otlp_endpoint__configures_logging_only(
mocker: MockerFixture,
) -> None:
# Given
mocker.patch.dict(os.environ, {}, clear=True)
setup_logging_mock = mocker.patch.object(telemetry, "setup_logging", autospec=True)
set_tracer_provider_mock = mocker.patch(
"flagsmith_mcp.telemetry.trace.set_tracer_provider", autospec=True
)

# When
telemetry.setup_telemetry(config.Settings())

# Then
setup_logging_mock.assert_called_once_with(
log_level="INFO",
log_format="generic",
application_loggers=telemetry.APPLICATION_LOGGERS,
otel_processors=None,
)
set_tracer_provider_mock.assert_not_called()


def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces(
mocker: MockerFixture,
) -> None:
# Given
mocker.patch.dict(os.environ, {}, clear=True)
setup_logging_mock = mocker.patch.object(telemetry, "setup_logging", autospec=True)
set_tracer_provider_mock = mocker.patch(
"flagsmith_mcp.telemetry.trace.set_tracer_provider", autospec=True
)
build_otel_log_provider_mock = mocker.patch.object(
telemetry, "build_otel_log_provider", autospec=True
)
build_tracer_provider_mock = mocker.patch.object(
telemetry, "build_tracer_provider", autospec=True
)
make_structlog_otel_processor_mock = mocker.patch.object(
telemetry, "make_structlog_otel_processor", autospec=True
)
settings = config.Settings(
otel_exporter_otlp_endpoint="http://collector:4318/",
otel_service_name="flagsmith-mcp-test",
log_level="DEBUG",
log_format="json",
)

# When
telemetry.setup_telemetry(settings)

# Then
build_otel_log_provider_mock.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
service_name="flagsmith-mcp-test",
)
build_tracer_provider_mock.assert_called_once_with(
endpoint="http://collector:4318/v1/traces",
service_name="flagsmith-mcp-test",
)
make_structlog_otel_processor_mock.assert_called_once_with(
build_otel_log_provider_mock.return_value
)
set_tracer_provider_mock.assert_called_once_with(
build_tracer_provider_mock.return_value
)
setup_logging_mock.assert_called_once_with(
log_level="DEBUG",
log_format="json",
application_loggers=telemetry.APPLICATION_LOGGERS,
otel_processors=[
add_otel_trace_context,
make_structlog_otel_processor_mock.return_value,
],
)
Loading
Loading