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
+184
View File
@@ -0,0 +1,184 @@
# coding=utf-8
"""
@Time :2026/6/30
@Author :tao.chen
"""
import sys
import types
import pytest
from fastapi import FastAPI
from common import mcp_service
from common.mcp_service import McpService, _filter_by_env, discover_and_filter
# --- helpers ----------------------------------------------------------------
def _fake_app(title: str = "Fake") -> FastAPI:
return FastAPI(title=title)
def _make_module(monkeypatch, name: str, *, service: object = None) -> None:
"""Inject a fake module into sys.modules with the given SERVICE attribute.
For "broken import" tests, the test author should set BUILTIN_SERVICES
to a name that is NOT injected into sys.modules, simulating ImportError.
"""
mod = types.ModuleType(name)
if service is not None:
mod.SERVICE = service
monkeypatch.setitem(sys.modules, name, mod)
@pytest.fixture
def reset_builtin(monkeypatch):
"""Restore BUILTIN_SERVICES to a known good state after each test."""
monkeypatch.setattr(
mcp_service, "BUILTIN_SERVICES",
["fake_spark_executor", "fake_files"],
)
@pytest.fixture
def reset_env(monkeypatch):
"""Make sure MCP_SERVICES is unset at the start of each test."""
monkeypatch.delenv("MCP_SERVICES", raising=False)
# --- TestDiscover ----------------------------------------------------------
class TestDiscover:
def test_loads_builtin_services_in_order(
self, monkeypatch, reset_builtin, reset_env
):
a = McpService(name="fake_spark_executor", app=_fake_app(), mount_path="/a-mcp")
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_spark_executor", service=a)
_make_module(monkeypatch, "fake_files", service=b)
result = discover_and_filter()
assert [s.name for s in result] == ["fake_spark_executor", "fake_files"]
def test_skips_broken_service_if_not_requested(
self, monkeypatch, reset_builtin, reset_env, caplog
):
# "fake_spark_executor" is not importable; env doesn't mention it.
# Only "fake_files" is real.
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_files", service=b)
with caplog.at_level("WARNING", logger="common.logging"):
result = discover_and_filter()
assert [s.name for s in result] == ["fake_files"]
assert any("fake_spark_executor" in r.message for r in caplog.records)
def test_raises_on_broken_service_if_requested(
self, monkeypatch, reset_builtin, reset_env
):
# "fake_spark_executor" is not importable AND operator asked for it.
monkeypatch.setenv("MCP_SERVICES", "fake_spark_executor")
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_files", service=b)
with pytest.raises(RuntimeError, match="requested via MCP_SERVICES"):
discover_and_filter()
def test_raises_when_service_module_lacks_SERVICE(
self, monkeypatch, reset_builtin, reset_env
):
# Module exists but has no SERVICE attribute.
_make_module(monkeypatch, "fake_spark_executor", service=None)
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_files", service=b)
with pytest.raises(RuntimeError, match="does not export a SERVICE"):
discover_and_filter()
def test_raises_when_SERVICE_is_wrong_type(
self, monkeypatch, reset_builtin, reset_env
):
# SERVICE attribute exists but is not a McpService.
_make_module(monkeypatch, "fake_spark_executor", service="not a McpService")
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_files", service=b)
with pytest.raises(RuntimeError, match="is not a McpService"):
discover_and_filter()
def test_raises_on_duplicate_service_names(
self, monkeypatch, reset_builtin, reset_env
):
a = McpService(name="dup", app=_fake_app(), mount_path="/a-mcp")
b = McpService(name="dup", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_spark_executor", service=a)
_make_module(monkeypatch, "fake_files", service=b)
with pytest.raises(RuntimeError, match="duplicate MCP service name"):
discover_and_filter()
def test_raises_on_duplicate_mount_paths(
self, monkeypatch, reset_builtin, reset_env
):
a = McpService(name="alpha", app=_fake_app(), mount_path="/same")
b = McpService(name="beta", app=_fake_app(), mount_path="/same")
_make_module(monkeypatch, "fake_spark_executor", service=a)
_make_module(monkeypatch, "fake_files", service=b)
with pytest.raises(RuntimeError, match="duplicate MCP mount_path"):
discover_and_filter()
# --- TestFilter (unit-tests the private _filter_by_env directly) ----------
class TestFilter:
def test_unset_env_returns_all(self, monkeypatch):
monkeypatch.delenv("MCP_SERVICES", raising=False)
svcs = [
McpService(name="a", app=_fake_app(), mount_path="/a"),
McpService(name="b", app=_fake_app(), mount_path="/b"),
]
assert _filter_by_env(svcs) == svcs
def test_empty_env_returns_zero(self, monkeypatch):
monkeypatch.setenv("MCP_SERVICES", "")
svcs = [
McpService(name="a", app=_fake_app(), mount_path="/a"),
McpService(name="b", app=_fake_app(), mount_path="/b"),
]
assert _filter_by_env(svcs) == []
def test_whitelist_filters_by_name(self, monkeypatch):
monkeypatch.setenv("MCP_SERVICES", "a")
svcs = [
McpService(name="a", app=_fake_app(), mount_path="/a"),
McpService(name="b", app=_fake_app(), mount_path="/b"),
]
result = _filter_by_env(svcs)
assert [s.name for s in result] == ["a"]
def test_whitelist_preserves_bulitin_order(self, monkeypatch):
# env order: "b,a"; result order must follow svcs order, not env order.
monkeypatch.setenv("MCP_SERVICES", "b,a")
svcs = [
McpService(name="a", app=_fake_app(), mount_path="/a"),
McpService(name="b", app=_fake_app(), mount_path="/b"),
]
result = _filter_by_env(svcs)
assert [s.name for s in result] == ["a", "b"]
# --- TestEnvErrors ---------------------------------------------------------
class TestEnvErrors:
def test_unknown_name_in_env_raises_with_available_list(
self, monkeypatch, reset_builtin, reset_env
):
a = McpService(name="fake_spark_executor", app=_fake_app(), mount_path="/a-mcp")
b = McpService(name="fake_files", app=_fake_app(), mount_path="/b-mcp")
_make_module(monkeypatch, "fake_spark_executor", service=a)
_make_module(monkeypatch, "fake_files", service=b)
monkeypatch.setenv("MCP_SERVICES", "bogus")
with pytest.raises(RuntimeError) as exc_info:
discover_and_filter()
msg = str(exc_info.value)
assert "bogus" in msg
assert "fake_files" in msg
assert "fake_spark_executor" in msg