Coverage for src/ecnl/adapters/inbound/mcp_adapter.py: 91%
35 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""Inbound adapter — exposes the application service as MCP tools.
3Composition root for the MCP layer:
4- Creates the FastMCP server instance
5- Registers liveness/readiness/health endpoints
6- Delegates per-domain tool registration to the modules in ``tools/``
8FastMCP generates tool schemas from Python type hints and docstrings, so the
9docstrings on each registered tool are the LLM's primary guide.
11Logging note (STDIO transport): NEVER use print() here. It writes to stdout
12and corrupts the JSON-RPC stream.
13"""
15import logging
17from mcp.server.fastmcp import FastMCP
18from starlette.requests import Request
19from starlette.responses import JSONResponse
21from ...application.service import ECNLService
22from ...ports.inbound import Authorizer
23from .authorization import PassThroughAuthorizer
24from .tools._base import _safe_call as _safe_call_internal
25from .tools.analytics import register_analytics_tools
26from .tools.events import register_event_tools
27from .tools.matches import register_match_tools
28from .tools.schedule import register_schedule_tools
29from .tools.standings import register_standings_tools
30from .tools.teams import register_team_tools
32logger = logging.getLogger(__name__)
34# Re-exported for tests that import `_safe_call` from this module.
35_safe_call = _safe_call_internal
38async def _handle_livez(request: Request) -> JSONResponse:
39 """Liveness probe — returns 200 OK if the HTTP server is up."""
40 return JSONResponse({"status": "ok"})
43async def _handle_readyz(request: Request) -> JSONResponse:
44 """Readiness probe — returns 200 when the server is ready to serve traffic."""
45 return JSONResponse({"status": "ok"})
48async def _handle_health(request: Request) -> JSONResponse:
49 """Aggregate health endpoint for monitoring systems."""
50 return JSONResponse({"status": "ok", "checks": {"liveness": "ok", "readiness": "ok"}})
53def create_mcp_server(
54 service: ECNLService,
55 host: str = "0.0.0.0",
56 port: int = 8000,
57 path: str = "/mcp",
58 auth_settings=None,
59 token_verifier=None,
60 authorizer: Authorizer | None = None,
61) -> FastMCP:
62 """Wire the application service into a FastMCP instance and register tools.
64 Args:
65 service: The ECNLService to expose as MCP tools.
66 host: Bind address for HTTP transport (ignored for stdio). Defaults to 0.0.0.0.
67 port: TCP port for HTTP transport (ignored for stdio). Defaults to 8000.
68 path: URL path the streamable-http transport is served at (ignored for
69 stdio). Each MCP server uses a distinct path (e.g. ``/mcp/ecnl``).
70 auth_settings: Optional ``mcp.server.auth.settings.AuthSettings`` that
71 enables bearer-token enforcement on the streamable-http transport.
72 token_verifier: Optional ``TokenVerifier`` consulted on every request.
73 """
74 mcp = FastMCP(
75 "ecnl",
76 host=host,
77 port=port,
78 stateless_http=True,
79 streamable_http_path=path,
80 auth=auth_settings,
81 token_verifier=token_verifier,
82 )
84 mcp.custom_route("/livez", methods=["GET"])(_handle_livez)
85 mcp.custom_route("/readyz", methods=["GET"])(_handle_readyz)
86 mcp.custom_route("/health", methods=["GET"])(_handle_health)
88 authz = authorizer or PassThroughAuthorizer()
89 register_event_tools(mcp, service, authz)
90 register_standings_tools(mcp, service, authz)
91 register_schedule_tools(mcp, service, authz)
92 register_team_tools(mcp, service, authz)
93 register_match_tools(mcp, service, authz)
94 register_analytics_tools(mcp, service, authz)
96 return mcp