feat(mcp): add pluggable service registry in common/mcp_service

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>
This commit is contained in:
Claude
2026-06-30 11:13:23 +00:00
committed by tao.chen
co-authored by Claude Fable 5
parent f44a08b0a1
commit 47e5a275ef
2 changed files with 315 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
# 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