docs(mcp): implementation plan for pluggable MCP service registry
Adds docs/superpowers/plans/2026-06-30-pluggable-mcp.md - 3-task implementation plan: Task 1: common/mcp_service.py + tests/unit/test_mcp_service.py (12 tests) Task 2: spark_executor/service.py + files_mcp/service.py + main.py refactor Task 3: tests/integration/test_main_lifespan.py (5 tests) + smoke test Branch: feat/files-mcp Expected: 327 existing + 17 new = 344 tests passing Backward compat: unset MCP_SERVICES reproduces today's behavior exactly pyproject.toml / uv.lock unchanged; spark_executor and files_mcp core modules byte-for-byte unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
# Pluggable MCP Service Registry — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor the multi-MCP-service wiring in `main.py` from a hand-maintained hard-coded list into a pluggable, config-gated registry so contributors can add a new MCP service without touching `main.py` (just append one import-path string), and deployments can opt services in/out via the `MCP_SERVICES` env var.
|
||||
|
||||
**Architecture:** Move the `McpService` dataclass from `main.py` to a new `common/mcp_service.py`, alongside a `BUILTIN_SERVICES` list of import paths and a `discover_and_filter()` helper that imports each, reads the `SERVICE` attribute, validates uniqueness, and applies the env whitelist. Each service package (`spark_executor/`, `files_mcp/`) gains a `service.py` exporting one `SERVICE: McpService` instance. `main.py`'s lifespan shrinks to `services = discover_and_filter(); for svc in services: mount(svc)`.
|
||||
|
||||
**Tech Stack:** Python 3.12+, FastAPI 0.138+, Pydantic 2.13+, loguru; stdlib `importlib`, `dataclasses`, `os`. No new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-30-pluggable-mcp-design.md` (commit `2ad4a3e` on `feat/files-mcp`).
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Python 3.12+** (per `pyproject.toml`).
|
||||
- **No new dependencies.**
|
||||
- **`MCP_SERVICES` env var** (optional, comma-separated whitelist of service names).
|
||||
- Unset: all built-in services mount (backward compat with current behavior).
|
||||
- Empty string (`MCP_SERVICES=`): zero services mount.
|
||||
- Subset (e.g. `MCP_SERVICES=files`): only listed services mount.
|
||||
- Unknown name: startup raises with a clear error listing available names.
|
||||
- **`common/mcp_service.py`** is the single source of truth for the registry:
|
||||
- `McpService` dataclass (moved from `main.py`, byte-for-byte identical fields).
|
||||
- `BUILTIN_SERVICES: list[str]` - every known service's import path.
|
||||
- `discover_and_filter() -> list[McpService]` - does import + validation + env gating in one call.
|
||||
- **Per-service contract**: each `service.py` exports a module-level `SERVICE: McpService` instance.
|
||||
- **Strict `MCP_SERVICES` / unset distinction**: `_filter_by_env` checks `"MCP_SERVICES" not in os.environ` to distinguish unset from empty.
|
||||
- **Duplicate detection**: `discover_and_filter` raises if two services share a `name` or `mount_path`.
|
||||
- **No `spark_executor/` or `files_mcp/` core module changes**: only NEW `service.py` files in each, plus the `main.py` refactor.
|
||||
- **Module-level file header:** Every new Python file starts with `# coding=utf-8` and `@Time` / `@Author` docstring.
|
||||
- **Branch:** `feat/files-mcp`. All commits land on this branch.
|
||||
- **All work uses `uv run`** for environment consistency.
|
||||
- **Conventions reused from `spark_executor/`:** `from common.logging import logger`; `from typing import TYPE_CHECKING` for forward refs.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Files this plan creates:
|
||||
|
||||
```
|
||||
common/mcp_service.py # McpService + BUILTIN_SERVICES + discover_and_filter
|
||||
spark_executor/service.py # SERVICE = McpService(...)
|
||||
files_mcp/service.py # SERVICE = McpService(...)
|
||||
tests/unit/test_mcp_service.py # 12 unit tests
|
||||
tests/integration/test_main_lifespan.py # 5 integration tests
|
||||
```
|
||||
|
||||
Files this plan modifies:
|
||||
|
||||
```
|
||||
main.py # shrink: remove McpService class + MCP_SERVICES, use discover_and_filter
|
||||
```
|
||||
|
||||
Files this plan explicitly does NOT touch (must remain byte-for-byte unchanged):
|
||||
|
||||
```
|
||||
spark_executor/__init__.py
|
||||
spark_executor/server.py
|
||||
spark_executor/tools/*
|
||||
spark_executor/core/*
|
||||
spark_executor/models.py
|
||||
|
||||
files_mcp/__init__.py
|
||||
files_mcp/server.py
|
||||
files_mcp/tools/*
|
||||
files_mcp/core/*
|
||||
files_mcp/models.py
|
||||
|
||||
common/__init__.py
|
||||
common/factory.py
|
||||
common/logging.py
|
||||
common/config.py
|
||||
|
||||
tests/conftest.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `common/mcp_service.py` with unit tests
|
||||
|
||||
**Files:**
|
||||
- Create: `common/mcp_service.py`
|
||||
- Create: `tests/unit/test_mcp_service.py`
|
||||
|
||||
**Interfaces (this task produces; later tasks consume):**
|
||||
- `common.mcp_service.McpService` - frozen dataclass with `name: str`, `app: FastAPI`, `mount_path: str`.
|
||||
- `common.mcp_service.BUILTIN_SERVICES: list[str]` - module-level constant.
|
||||
- `common.mcp_service._filter_by_env(services: list[McpService]) -> list[McpService]` - private helper, public for testing.
|
||||
- `common.mcp_service.discover_and_filter() -> list[McpService]` - main entry point.
|
||||
|
||||
- [ ] **Step 1.1: Create `common/mcp_service.py`**
|
||||
|
||||
```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
|
||||
|
||||
|
||||
@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}")
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Create `tests/unit/test_mcp_service.py`**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Run the test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/unit/test_mcp_service.py -v`
|
||||
Expected: 12 PASS
|
||||
|
||||
(If a test fails, debug and fix the production code, NOT the test.)
|
||||
|
||||
- [ ] **Step 1.4: Commit**
|
||||
|
||||
```bash
|
||||
git add common/mcp_service.py tests/unit/test_mcp_service.py
|
||||
git commit -m "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>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: per-service `service.py` files + main.py refactor
|
||||
|
||||
**Files:**
|
||||
- Create: `spark_executor/service.py`
|
||||
- Create: `files_mcp/service.py`
|
||||
- Modify: `main.py` (replace `McpService` class + `MCP_SERVICES` list with `discover_and_filter()` call; remove the `from files_mcp import app` and `from spark_executor import app` imports; update the explanatory comment block)
|
||||
|
||||
**Interfaces (this task produces; Task 3 consumes):**
|
||||
- `spark_executor.service.SERVICE: McpService`
|
||||
- `files_mcp.service.SERVICE: McpService`
|
||||
- `main.py:app` constructed with the same lifespan; lifespan now calls `discover_and_filter()`.
|
||||
|
||||
- [ ] **Step 2.1: Create `spark_executor/service.py`**
|
||||
|
||||
```python
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/6/30
|
||||
@Author :tao.chen
|
||||
|
||||
Pluggable manifest for the spark_executor MCP service. The name and
|
||||
mount_path here are matched against the MCP_SERVICES env whitelist and
|
||||
the BUILTIN_SERVICES list in common.mcp_service.
|
||||
"""
|
||||
from common.mcp_service import McpService
|
||||
from spark_executor import app
|
||||
|
||||
SERVICE: McpService = McpService(
|
||||
name="spark_executor",
|
||||
app=app,
|
||||
mount_path="/spark-executor-mcp",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Create `files_mcp/service.py`**
|
||||
|
||||
```python
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/6/30
|
||||
@Author :tao.chen
|
||||
|
||||
Pluggable manifest for the files MCP service.
|
||||
"""
|
||||
from common.mcp_service import McpService
|
||||
from files_mcp import app
|
||||
|
||||
SERVICE: McpService = McpService(
|
||||
name="files",
|
||||
app=app,
|
||||
mount_path="/files-mcp",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Read the current `main.py`**
|
||||
|
||||
Run: `cat main.py`
|
||||
Confirm: current `main.py` has the `McpService` dataclass, the `MCP_SERVICES` list, the two `from ... import app` imports at the top, and the lifespan that iterates `MCP_SERVICES`. (No other code in `main.py` is affected.)
|
||||
|
||||
- [ ] **Step 2.4: Replace `main.py` with the refactored version**
|
||||
|
||||
The full new content of `main.py` is:
|
||||
|
||||
```python
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/6/30
|
||||
@Author :tao.chen
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from common.factory import init_mcp_server
|
||||
from common.logging import logger
|
||||
from common.mcp_service import discover_and_filter
|
||||
|
||||
|
||||
# --- MCP service registry ---
|
||||
#
|
||||
# Each service package declares itself via a `service.py` that exports a
|
||||
# `SERVICE: McpService` instance. The list of known service import paths
|
||||
# lives in `common.mcp_service.BUILTIN_SERVICES`. The lifespan below calls
|
||||
# `discover_and_filter()` to import each, validate them, apply the
|
||||
# MCP_SERVICES env whitelist, and mount the survivors.
|
||||
#
|
||||
# To add a new MCP service:
|
||||
# 1. Build a package with a FastAPI app.
|
||||
# 2. Add `<package>/service.py` exporting `SERVICE: McpService`.
|
||||
# 3. Append `"<package>.service"` to `BUILTIN_SERVICES` in
|
||||
# `common/mcp_service.py`.
|
||||
|
||||
|
||||
def _log_mcp_tools(mcp_server, service) -> None:
|
||||
"""Log every tool the MCP server exposes, with its description.
|
||||
|
||||
fastapi-mcp appends an auto-generated "### Responses: ..." block to
|
||||
every tool description; we strip that here so the startup log stays
|
||||
scannable. Each tool is logged on one line: ` N. tool_name - desc`.
|
||||
"""
|
||||
tools = mcp_server.tools
|
||||
# Single INFO line per service summarizing how many tools it exposes.
|
||||
# Per-tool name + description is DEBUG-level so production logs (INFO)
|
||||
# stay scannable but operators can flip to DEBUG to see the full list.
|
||||
logger.info(f"MCP service {service.name!r}: {len(tools)} tool(s) registered")
|
||||
for i, tool in enumerate(tools, 1):
|
||||
desc = (tool.description or "").strip()
|
||||
if "### Responses" in desc:
|
||||
desc = desc.split("### Responses", 1)[0].rstrip()
|
||||
desc = " ".join(desc.split())
|
||||
logger.debug(f" {service.name}.{tool.name} - {desc}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: "FastAPI"):
|
||||
logger.info(f"Startup {app.title}")
|
||||
services = discover_and_filter()
|
||||
for svc in services:
|
||||
logger.info(
|
||||
f"Mounting MCP service {svc.name!r} at {svc.mount_path} "
|
||||
f"(source: {svc.app.title} v{svc.app.version})"
|
||||
)
|
||||
mcp = init_mcp_server(svc.app)
|
||||
mcp.mount_http(app, mount_path=svc.mount_path)
|
||||
_log_mcp_tools(mcp, svc)
|
||||
yield
|
||||
logger.info(f"Shutdown {app.title}")
|
||||
|
||||
|
||||
app = FastAPI(title="Main App", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.5: Run the FULL test suite to verify the refactor preserves behavior**
|
||||
|
||||
Run: `uv run pytest -v`
|
||||
Expected: `327 passed, 50 warnings in 0.9s` (or similar - same total as before this plan started; the file-MCP plan ended at 327 tests, and this refactor adds 0 new tests in Task 2)
|
||||
|
||||
If a test fails:
|
||||
- Check that `main.py` only has the changes from Step 2.4.
|
||||
- Check that the per-service `service.py` files (Step 2.1, 2.2) are exactly as written.
|
||||
- Check that the existing 327 tests still import their targets directly (e.g. `from files_mcp.server import app`) - they should not be affected by the registry refactor.
|
||||
- Most likely failure: a missing or wrong import in `service.py`. Fix the service.py file (NOT the test) and re-run.
|
||||
|
||||
- [ ] **Step 2.6: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py spark_executor/service.py files_mcp/service.py
|
||||
git commit -m "refactor(mcp): use discover_and_filter in main.py; add per-service manifests
|
||||
|
||||
- main.py: drops the hard-coded McpService class and MCP_SERVICES list.
|
||||
Lifespan now calls discover_and_filter() which does import + env
|
||||
filtering. Unset MCP_SERVICES reproduces today's behavior exactly.
|
||||
- spark_executor/service.py: new file exporting SERVICE = McpService(
|
||||
name='spark_executor', app=spark_executor.app, mount_path='/spark-executor-mcp').
|
||||
- files_mcp/service.py: new file exporting SERVICE = McpService(
|
||||
name='files', app=files_mcp.app, mount_path='/files-mcp').
|
||||
|
||||
The McpService dataclass itself moved to common/mcp_service.py in the
|
||||
prior commit. 327 existing tests pass unchanged.
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: integration tests for the new lifespan behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/integration/test_main_lifespan.py`
|
||||
|
||||
**Strategy:** The existing 327 tests verify individual sub-apps in isolation. This task adds 5 integration tests that verify the **end-to-end lifespan wiring** in `main.py` - specifically, that the env var correctly gates which services get mounted.
|
||||
|
||||
The tests use `TestClient(main.app)` and **mock `init_mcp_server` in the `main` namespace** to record which `mount_path`s get mounted. This avoids depending on fastapi-mcp's HTTP response codes (which 404 for bare GETs regardless of mount state - the MCP transport requires an `initialize` handshake first).
|
||||
|
||||
- [ ] **Step 3.1: Create `tests/integration/test_main_lifespan.py`**
|
||||
|
||||
```python
|
||||
# coding=utf-8
|
||||
"""
|
||||
@Time :2026/6/30
|
||||
@Author :tao.chen
|
||||
|
||||
End-to-end tests for main.app's lifespan: verify that MCP_SERVICES env
|
||||
var correctly gates which services get mounted. Mocks main.init_mcp_server
|
||||
to record mount calls rather than relying on fastapi-mcp's HTTP
|
||||
response codes (which 404 for bare GETs regardless of mount state
|
||||
because the MCP transport requires an initialize handshake first).
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _get_main_app():
|
||||
"""Lazily import main.app so the conftest autouse fixture has run
|
||||
before files_mcp is first imported (via discover_and_filter inside
|
||||
the lifespan)."""
|
||||
import main
|
||||
return main.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mount(monkeypatch):
|
||||
"""Replace main.init_mcp_server with a recorder.
|
||||
|
||||
Returns a list that gets appended with mount_path each time a
|
||||
service is mounted. The lifespan inside the TestClient context
|
||||
manager triggers all mounts during __enter__.
|
||||
"""
|
||||
mounted: list[str] = []
|
||||
|
||||
def fake_init(sub_app):
|
||||
class _FakeMcp:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
def mount_http(self, parent, mount_path):
|
||||
mounted.append(mount_path)
|
||||
return _FakeMcp(sub_app)
|
||||
|
||||
monkeypatch.setattr("main.init_mcp_server", fake_init)
|
||||
return mounted
|
||||
|
||||
|
||||
class TestLifespanMounting:
|
||||
def test_default_mounts_both_services(self, monkeypatch, mock_mount):
|
||||
monkeypatch.delenv("MCP_SERVICES", raising=False)
|
||||
with TestClient(_get_main_app()):
|
||||
pass
|
||||
# Both mount paths are present, in BUILTIN_SERVICES order.
|
||||
assert mock_mount == ["/spark-executor-mcp", "/files-mcp"]
|
||||
|
||||
def test_MCP_SERVICES_files_only_mounts_one(self, monkeypatch, mock_mount):
|
||||
monkeypatch.setenv("MCP_SERVICES", "files")
|
||||
with TestClient(_get_main_app()):
|
||||
pass
|
||||
assert mock_mount == ["/files-mcp"]
|
||||
|
||||
def test_MCP_SERVICES_empty_mounts_none(self, monkeypatch, mock_mount):
|
||||
monkeypatch.setenv("MCP_SERVICES", "")
|
||||
with TestClient(_get_main_app()):
|
||||
pass
|
||||
assert mock_mount == []
|
||||
|
||||
def test_MCP_SERVICES_unknown_raises_at_startup(self, monkeypatch, mock_mount):
|
||||
monkeypatch.setenv("MCP_SERVICES", "bogus")
|
||||
with pytest.raises(RuntimeError, match="unknown service names"):
|
||||
with TestClient(_get_main_app()):
|
||||
pass
|
||||
# No mounts attempted
|
||||
assert mock_mount == []
|
||||
|
||||
def test_health_endpoint_unaffected_by_mcp_filtering(self, monkeypatch):
|
||||
# /health is on the root app, independent of MCP service mounting.
|
||||
monkeypatch.delenv("MCP_SERVICES", raising=False)
|
||||
with TestClient(_get_main_app()) as c:
|
||||
assert c.get("/health").status_code == 200
|
||||
|
||||
monkeypatch.setenv("MCP_SERVICES", "")
|
||||
with TestClient(_get_main_app()) as c:
|
||||
assert c.get("/health").status_code == 200
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Run the integration test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/integration/test_main_lifespan.py -v`
|
||||
Expected: 5 PASS
|
||||
|
||||
If a test fails:
|
||||
- "No module named 'main'" or import-time error: `tests/conftest.py` is missing the autouse fixture. Verify with `cat tests/conftest.py`.
|
||||
- "Service requested via MCP_SERVICES but cannot be imported": FILES_MCP_ROOT is unset when the lifespan runs. Verify the conftest autouse fixture is `autouse=True`.
|
||||
- A test expecting `mock_mount == ["/spark-executor-mcp", "/files-mcp"]` fails: check the order in `common.mcp_service.BUILTIN_SERVICES` - spark_executor must come first.
|
||||
- Do NOT change the test to match buggy behavior. Fix the production code (main.py or common/mcp_service.py) to match the spec.
|
||||
|
||||
- [ ] **Step 3.3: Run the FULL test suite to confirm zero regression**
|
||||
|
||||
Run: `uv run pytest -v`
|
||||
Expected: `344 passed, 50 warnings in 0.9s` (327 + 12 + 5)
|
||||
|
||||
(If the warning count is slightly different, that's fine. The test count must be exactly 344.)
|
||||
|
||||
- [ ] **Step 3.4: Smoke test**
|
||||
|
||||
Create a one-off script `scripts/smoke_pluggable_mcp.py` (NOT committed):
|
||||
|
||||
```python
|
||||
# coding=utf-8
|
||||
"""One-off smoke test: verify MCP_SERVICES env gates mounts at runtime."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Set FILES_MCP_ROOT so files_mcp.__init__ can call _init_root().
|
||||
os.environ.setdefault("FILES_MCP_ROOT", "/tmp/pluggable-mcp-smoke")
|
||||
os.makedirs(os.environ["FILES_MCP_ROOT"], exist_ok=True)
|
||||
|
||||
import importlib
|
||||
from fastapi.testclient import TestClient
|
||||
import main
|
||||
|
||||
|
||||
def scenario(env_value, expected_mounts):
|
||||
for k in ("MCP_SERVICES",):
|
||||
os.environ.pop(k, None)
|
||||
if env_value is not None:
|
||||
os.environ["MCP_SERVICES"] = env_value
|
||||
importlib.reload(main)
|
||||
mounted = []
|
||||
real_init = main.init_mcp_server
|
||||
def recorder(sub_app):
|
||||
class _M:
|
||||
def __init__(self, app): self.app = app
|
||||
def mount_http(self, parent, mount_path):
|
||||
mounted.append(mount_path)
|
||||
return _M(sub_app)
|
||||
main.init_mcp_server = recorder
|
||||
try:
|
||||
with TestClient(main.app):
|
||||
pass
|
||||
finally:
|
||||
main.init_mcp_server = real_init
|
||||
label = f"MCP_SERVICES={env_value!r}"
|
||||
ok = mounted == expected_mounts
|
||||
print(f" {label}: mounted={mounted} expected={expected_mounts} {'OK' if ok else 'FAIL'}")
|
||||
return ok
|
||||
|
||||
|
||||
print("Smoke test: pluggable MCP service registry")
|
||||
ok = True
|
||||
ok &= scenario(None, ["/spark-executor-mcp", "/files-mcp"]) # unset -> all
|
||||
ok &= scenario("files", ["/files-mcp"]) # subset
|
||||
ok &= scenario("", []) # empty -> zero
|
||||
try:
|
||||
ok &= scenario("bogus", []) # unknown -> raise
|
||||
print(" bogus: expected RuntimeError but got none FAIL")
|
||||
ok = False
|
||||
except RuntimeError as e:
|
||||
print(f" bogus: raised RuntimeError as expected OK ({e})")
|
||||
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
Run: `uv run python scripts/smoke_pluggable_mcp.py`
|
||||
Expected: 4 OK lines + 1 OK for the bogus case, exit code 0.
|
||||
|
||||
Then **delete** the smoke script: `rm scripts/smoke_pluggable_mcp.py` (and `rmdir scripts` if empty).
|
||||
|
||||
- [ ] **Step 3.5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/integration/test_main_lifespan.py
|
||||
git commit -m "test(mcp): integration tests for pluggable lifespan wiring
|
||||
|
||||
Adds tests/integration/test_main_lifespan.py with 5 cases that exercise
|
||||
the end-to-end main.app lifespan:
|
||||
|
||||
- default (no MCP_SERVICES) mounts both services in BUILTIN order
|
||||
- MCP_SERVICES=files mounts only files
|
||||
- MCP_SERVICES='' mounts zero
|
||||
- MCP_SERVICES=bogus raises RuntimeError at startup
|
||||
- /health on the root app is unaffected by MCP filtering
|
||||
|
||||
Tests use TestClient and mock main.init_mcp_server to record mount
|
||||
calls, since fastapi-mcp's HTTP endpoints 404 for bare GETs regardless
|
||||
of mount state (the MCP transport requires an initialize handshake
|
||||
before any tool call). Mocking avoids that coupling.
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
Run mentally before declaring the plan done:
|
||||
|
||||
1. **Spec coverage** - every spec requirement has a task:
|
||||
- `McpService` dataclass -> Task 1
|
||||
- `BUILTIN_SERVICES` constant -> Task 1
|
||||
- `discover_and_filter()` with all error modes -> Task 1
|
||||
- `_filter_by_env` distinguishing unset vs empty -> Task 1
|
||||
- Duplicate detection (name + mount_path) -> Task 1
|
||||
- Per-service `service.py` manifests -> Task 2 (spark_executor) + Task 2 (files_mcp)
|
||||
- `main.py` refactor (no more hard-coded list) -> Task 2
|
||||
- Smoke test (no env, with env, unknown name) -> Task 3
|
||||
- 0 changes to spark_executor core / files_mcp core / pyproject / uv.lock -> enforced in Step 2.5 verification + Global Constraints
|
||||
- 327 + 17 = 344 tests pass -> Task 3 Step 3.3 acceptance
|
||||
|
||||
2. **Placeholder scan** - no TBD / TODO / "implement later" / "see above" in the plan.
|
||||
|
||||
3. **Type / name consistency** -
|
||||
- `McpService(name: str, app: FastAPI, mount_path: str)` defined in Task 1, used in Task 1 (helpers), Task 2 (manifests), Task 3 (no direct use).
|
||||
- `BUILTIN_SERVICES: list[str]` defined in Task 1, modified in Task 1 tests via `monkeypatch.setattr` (resets to known good list), never modified in Task 2 or Task 3.
|
||||
- `_filter_by_env` defined in Task 1, tested directly in Task 1, used by `discover_and_filter` in Task 1.
|
||||
- `discover_and_filter` defined in Task 1, used by `main.py` lifespan in Task 2, mocked indirectly in Task 3 via `main.init_mcp_server` (which `discover_and_filter` -> lifespan -> `init_mcp_server` calls).
|
||||
- `SERVICE` attribute name consistent across both manifests (Task 2) and `discover_and_filter` (Task 1 uses `getattr(module, "SERVICE", None)`).
|
||||
- `mount_path` strings match exactly between `service.py` files and historical `MCP_SERVICES` list (`/spark-executor-mcp`, `/files-mcp`).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All 3 task checklists complete.
|
||||
- [ ] `uv run pytest -v` shows exactly 344 tests passing.
|
||||
- [ ] `git diff main --name-only` shows the expected scope:
|
||||
- `common/mcp_service.py` (new)
|
||||
- `spark_executor/service.py` (new)
|
||||
- `files_mcp/service.py` (new)
|
||||
- `main.py` (modified)
|
||||
- `tests/unit/test_mcp_service.py` (new)
|
||||
- `tests/integration/test_main_lifespan.py` (new)
|
||||
- [ ] `pyproject.toml` / `uv.lock` unchanged.
|
||||
- [ ] All `spark_executor/` and `files_mcp/` core files (everything except the new `service.py` per package) are byte-for-byte unchanged.
|
||||
- [ ] Smoke test in Task 3 Step 3.4 prints 4 OK + 1 OK for the bogus case, exits 0.
|
||||
- [ ] Smoke script is deleted (no `scripts/` in `git status`).
|
||||
Reference in New Issue
Block a user