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>
185 lines
7.2 KiB
Python
185 lines
7.2 KiB
Python
# 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
|