Adds common/mcp_service.py with the McpService dataclass (moved from main.py), BUILTIN_SERVICES list, _filter_by_env, and discover_and_filter. MCP_SERVICES env var acts as a whitelist (unset=all, empty=zero). Adds tests/unit/test_mcp_service.py with 12 cases covering: load order, broken-import handling (warn vs raise by env), missing/wrong-typed SERVICE, duplicate name and duplicate mount_path detection, env filter (unset/empty/subset/order), and unknown-name error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
4.7 KiB
Python
132 lines
4.7 KiB
Python
# coding=utf-8
|
|
"""
|
|
@Time :2026/6/30
|
|
@Author :tao.chen
|
|
|
|
The MCP service registry: a single place that knows which FastAPI sub-apps
|
|
to mount under the root app and how to gate them by the MCP_SERVICES env
|
|
var. Each service package declares itself via a `service.py` exporting a
|
|
module-level `SERVICE: McpService` instance.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from common.logging import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
|
|
# The codebase standardizes on loguru (see common.logging), but pytest's
|
|
# `caplog` fixture captures stdlib `logging` records, not loguru. We mirror
|
|
# the single skip-warning through a stdlib logger named `common.logging`
|
|
# (matching how `caplog.at_level(..., logger="common.logging")` is invoked
|
|
# in the tests) so the warning is assertable. A NullHandler keeps the
|
|
# stdlib logger silent in production; `caplog` attaches its own handler.
|
|
import logging as _logging
|
|
_stdlib_logger = _logging.getLogger("common.logging")
|
|
if not _stdlib_logger.handlers:
|
|
_stdlib_logger.addHandler(_logging.NullHandler())
|
|
_stdlib_logger.propagate = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class McpService:
|
|
"""One MCP-exposed FastAPI sub-app to be mounted under the root."""
|
|
|
|
name: str # short id used in startup logs and the MCP_SERVICES env whitelist
|
|
app: "FastAPI" # the sub-app whose routes fastapi-mcp exposes as tools
|
|
mount_path: str # HTTP path under the root app, e.g. "/spark-executor-mcp"
|
|
|
|
|
|
# Every known MCP service's import path. Add a new service by appending one
|
|
# line here and creating <package>/service.py that exports SERVICE.
|
|
BUILTIN_SERVICES: list[str] = [
|
|
"spark_executor.service",
|
|
"files_mcp.service",
|
|
]
|
|
|
|
|
|
def _filter_by_env(services: list[McpService]) -> list[McpService]:
|
|
"""Whitelist by `MCP_SERVICES` env. Unset = no filter (backward compat).
|
|
Empty string = zero services (explicit operator choice, distinct from unset)."""
|
|
if "MCP_SERVICES" not in os.environ:
|
|
return services
|
|
raw = os.environ["MCP_SERVICES"]
|
|
if not raw:
|
|
return []
|
|
allowed = {n.strip() for n in raw.split(",") if n.strip()}
|
|
return [s for s in services if s.name in allowed]
|
|
|
|
|
|
def discover_and_filter() -> list[McpService]:
|
|
"""Import each BUILTIN_SERVICES module, read its SERVICE attribute,
|
|
then apply the env whitelist.
|
|
|
|
Raises:
|
|
RuntimeError: see `Error Handling` in the spec.
|
|
"""
|
|
services: list[McpService] = []
|
|
raw = os.environ.get("MCP_SERVICES")
|
|
requested = {n.strip() for n in raw.split(",") if n.strip()} if raw else set()
|
|
origins: dict[str, str] = {} # service.name -> import_path (for diagnostics)
|
|
|
|
for import_path in BUILTIN_SERVICES:
|
|
try:
|
|
module = importlib.import_module(import_path)
|
|
except ImportError as e:
|
|
pkg_name = import_path.rsplit(".", 1)[0]
|
|
if pkg_name in requested or import_path in requested:
|
|
raise RuntimeError(
|
|
f"MCP service {import_path!r} is requested via MCP_SERVICES "
|
|
f"but cannot be imported: {e}"
|
|
) from e
|
|
logger.warning(f"Skipping missing MCP service {import_path!r}: {e}")
|
|
_stdlib_logger.warning(f"Skipping missing MCP service {import_path!r}: {e}")
|
|
continue
|
|
svc = getattr(module, "SERVICE", None)
|
|
if svc is None:
|
|
raise RuntimeError(
|
|
f"{import_path!r} does not export a SERVICE attribute"
|
|
)
|
|
if not isinstance(svc, McpService):
|
|
raise RuntimeError(
|
|
f"{import_path!r}.SERVICE is not a McpService "
|
|
f"(got {type(svc).__name__})"
|
|
)
|
|
services.append(svc)
|
|
origins[svc.name] = import_path
|
|
|
|
# Duplicate detection - surface typos at startup.
|
|
seen_names: dict[str, str] = {}
|
|
seen_mounts: dict[str, str] = {}
|
|
for s in services:
|
|
if s.name in seen_names:
|
|
raise RuntimeError(
|
|
f"duplicate MCP service name {s.name!r}: "
|
|
f"{seen_names[s.name]!r} and {origins[s.name]!r}"
|
|
)
|
|
seen_names[s.name] = origins[s.name]
|
|
if s.mount_path in seen_mounts:
|
|
raise RuntimeError(
|
|
f"duplicate MCP mount_path {s.mount_path!r}: "
|
|
f"{seen_mounts[s.mount_path]!r} and {origins[s.name]!r}"
|
|
)
|
|
seen_mounts[s.mount_path] = origins[s.name]
|
|
|
|
filtered = _filter_by_env(services)
|
|
|
|
found_names = {s.name for s in services}
|
|
missing = requested - found_names
|
|
if missing:
|
|
raise RuntimeError(
|
|
f"MCP_SERVICES env references unknown service names: "
|
|
f"{sorted(missing)}. Available: {sorted(found_names)}"
|
|
)
|
|
|
|
return filtered
|