Adds docs/superpowers/specs/2026-06-30-pluggable-mcp-design.md describing a refactor of main.py: McpService dataclass moves to common/mcp_service.py alongside BUILTIN_SERVICES list and discover_and_filter() helper. Each service package gets a service.py exporting a SERVICE McpService instance. Deployments opt in/out via MCP_SERVICES env whitelist (unset = all). Branch: feat/files-mcp (continues from file-MCP service work; same branch, not pushed; can be split into separate PRs later) Backward compat: 0 existing tools / tests / dependencies changed; unsetting MCP_SERVICES reproduces today's behavior exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
468 lines
18 KiB
Markdown
468 lines
18 KiB
Markdown
# Pluggable MCP Service Registry — Design Spec
|
|
|
|
**Date:** 2026-06-30
|
|
**Branch:** `feat/files-mcp` (continues from the file-MCP service work; same branch because the work is logically related and the branch has not been pushed)
|
|
**Status:** Design approved; awaiting implementation plan
|
|
|
|
## Goal
|
|
|
|
Refactor the multi-MCP-service wiring in `main.py` from a hand-maintained
|
|
hard-coded list into a pluggable, config-gated registry so that:
|
|
|
|
1. **Contributors can add a new MCP service without editing `main.py` other
|
|
than appending a single import-path string.** Each service package owns its
|
|
own manifest; main.py no longer imports the service's FastAPI app.
|
|
2. **Deployments can opt services in or out per environment** via the
|
|
`MCP_SERVICES` env var (whitelist). Unset = all built-in services (backward
|
|
compatible with the current behavior).
|
|
|
|
Out of scope: third-party pip packages auto-registering via Python entry
|
|
points. That is a heavier design (separate versioning, namespace packages,
|
|
install-order semantics) and is not needed for the in-repo services.
|
|
|
|
## Background
|
|
|
|
The current `main.py` has:
|
|
|
|
```python
|
|
from files_mcp import app as files_app
|
|
from spark_executor import app as spark_executor_app
|
|
|
|
@dataclass(frozen=True)
|
|
class McpService: ...
|
|
MCP_SERVICES: list[McpService] = [
|
|
McpService(name="spark_executor", app=spark_executor_app, mount_path="/spark-executor-mcp"),
|
|
McpService(name="files", app=files_app, mount_path="/files-mcp"),
|
|
]
|
|
```
|
|
|
|
To add a third service today, the contributor must:
|
|
|
|
1. Build the service's package.
|
|
2. Add a new `from <pkg> import app as <pkg>_app` import to `main.py`.
|
|
3. Add a new `McpService(...)` entry to `MCP_SERVICES`.
|
|
|
|
Steps 2 and 3 are mechanical and add no value — they only couple `main.py` to
|
|
every service package's identity. This spec removes that coupling.
|
|
|
|
## Non-Goals
|
|
|
|
- **Third-party pip-package registration via Python entry points.** Not
|
|
needed for in-repo services; would add namespace/versioning concerns.
|
|
- **Hot-reload of services at runtime.** Services are discovered at startup
|
|
in the `lifespan` once. Re-discovery mid-process is out of scope.
|
|
- **Per-service instance count / sharding.** Each service has exactly one
|
|
instance. Multi-tenant sharding is Stage 3.
|
|
- **A web UI for toggling services.** Configuration is env-var only.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
common/mcp_service.py # NEW: McpService + BUILTIN_SERVICES + discover_and_filter
|
|
- McpService dataclass (moved from main.py)
|
|
- BUILTIN_SERVICES: list[str] (module-paths, e.g. "files_mcp.service")
|
|
- discover_and_filter() -> list[McpService] (does import + env gating in one call)
|
|
|
|
spark_executor/service.py # NEW: exports SERVICE = McpService(...)
|
|
files_mcp/service.py # NEW: exports SERVICE = McpService(...)
|
|
|
|
main.py # MODIFIED: shrinks; lifespan calls discover_and_filter()
|
|
```
|
|
|
|
### Why a separate `common/mcp_service.py` (not a new top-level package)?
|
|
|
|
- The `common/` package already exists. Adding one file there is the
|
|
lowest-disruption option.
|
|
- A new top-level package (`mcp_common/`) would be a larger refactor with
|
|
no functional benefit at this scale.
|
|
- The dependency graph stays acyclic: `common/mcp_service.py` imports nothing
|
|
project-specific; both `service.py` files and `main.py` import from it.
|
|
|
|
### Why a per-package `service.py` (not a `MANIFEST.toml`)?
|
|
|
|
- Python import-time module = the manifest. No file-format dance.
|
|
- The `SERVICE = McpService(...)` line is a one-liner the contributor cannot
|
|
get wrong structurally.
|
|
- IDE/type-checker support is automatic; TOML manifests would need a parser
|
|
and a schema.
|
|
- A `.toml` would force runtime errors for typos; a Python attribute surfaces
|
|
the error at import time.
|
|
|
|
## Data Model
|
|
|
|
```python
|
|
# common/mcp_service.py
|
|
from __future__ import annotations
|
|
import importlib
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
from common.logging import logger
|
|
|
|
|
|
@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 env whitelist
|
|
app: "FastAPI" # the sub-app whose routes fastapi-mcp exposes as tools
|
|
mount_path: str # HTTP path under the root app
|
|
|
|
|
|
# Module-level constant: every known MCP service's import path.
|
|
# Add a new service by appending one line here.
|
|
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)."""
|
|
raw = os.environ.get("MCP_SERVICES")
|
|
if not raw:
|
|
return services
|
|
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.
|
|
|
|
Failure modes:
|
|
- Import fails AND `MCP_SERVICES` did not request it: WARN, skip.
|
|
- Import fails AND `MCP_SERVICES` requested it: RAISE.
|
|
- Module lacks `SERVICE` attribute: RAISE.
|
|
- `SERVICE` is not a McpService instance: RAISE.
|
|
- `MCP_SERVICES` names a service not in BUILTIN_SERVICES: RAISE.
|
|
- Duplicate `name` across discovered services: RAISE.
|
|
- Duplicate `mount_path` across discovered services: RAISE.
|
|
"""
|
|
services: list[McpService] = []
|
|
raw = os.environ.get("MCP_SERVICES")
|
|
requested = {n.strip() for n in raw.split(",") if n.strip()} if raw else set()
|
|
|
|
# Track each service's import path for diagnostic messages.
|
|
origins: dict[str, str] = {} # service.name -> import_path
|
|
|
|
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
|
|
```
|
|
|
|
## Per-Service Manifest Contract
|
|
|
|
Each MCP service package MUST provide a `service.py` that exports a module-level
|
|
`SERVICE: McpService` instance. Example:
|
|
|
|
```python
|
|
# files_mcp/service.py
|
|
# coding=utf-8
|
|
from files_mcp import app
|
|
from common.mcp_service import McpService
|
|
|
|
SERVICE: McpService = McpService(
|
|
name="files",
|
|
app=app,
|
|
mount_path="/files-mcp",
|
|
)
|
|
```
|
|
|
|
```python
|
|
# spark_executor/service.py
|
|
# coding=utf-8
|
|
from spark_executor import app
|
|
from common.mcp_service import McpService
|
|
|
|
SERVICE: McpService = McpService(
|
|
name="spark_executor",
|
|
app=app,
|
|
mount_path="/spark-executor-mcp",
|
|
)
|
|
```
|
|
|
|
**Contract** (enforced by `discover_and_filter`):
|
|
|
|
- The `SERVICE` attribute exists and is a `McpService` instance.
|
|
- The `app` is already constructed (the import in the manifest is what
|
|
triggers app construction).
|
|
- The `name` and `mount_path` are unique across all built-in services.
|
|
|
|
## main.py After
|
|
|
|
```python
|
|
# main.py (refactored)
|
|
# coding=utf-8
|
|
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
|
|
|
|
|
|
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
|
|
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)
|
|
```
|
|
|
|
## Configuration
|
|
|
|
- **`MCP_SERVICES`** (env var, optional): comma-separated whitelist of service
|
|
names, e.g. `MCP_SERVICES=spark_executor,files`.
|
|
- Unset: all built-in services are mounted (current behavior).
|
|
- Empty string (`MCP_SERVICES=`): no services are mounted.
|
|
- Subset (e.g. `MCP_SERVICES=files`): only listed services are mounted.
|
|
- Unknown name: startup fails with a clear error listing available names.
|
|
- **No new config files, no `pyproject.toml` changes, no new dependencies.**
|
|
|
|
## Error Handling
|
|
|
|
| Scenario | Behavior | Rationale |
|
|
|---|---|---|
|
|
| `MCP_SERVICES` unset | All built-in services mount | Backward compat. |
|
|
| `MCP_SERVICES=""` (empty) | Zero services mount | Operator explicit choice. |
|
|
| `MCP_SERVICES=files` (subset) | Only `files` mounts | Per-deployment opt-in. |
|
|
| `MCP_SERVICES=files,bogus` | Startup raises; error lists available names | Typo guard. |
|
|
| BUILTIN path import fails, not requested in env | WARN, skip | Tolerable. |
|
|
| BUILTIN path import fails, requested in env | Startup raises | Operator asked for it, we owe it. |
|
|
| `service.py` lacks `SERVICE` attribute | Startup raises | Service package bug. |
|
|
| `SERVICE` is wrong type | Startup raises | Service package bug. |
|
|
| Duplicate `name` across services | Startup raises | Service package collision. |
|
|
| Duplicate `mount_path` across services | Startup raises | Service package collision. |
|
|
|
|
## Data Flow
|
|
|
|
```
|
|
process start
|
|
-> main module import
|
|
-> main.app created with lifespan
|
|
-> uvicorn invokes lifespan:
|
|
1. discover_and_filter()
|
|
a. for each path in BUILTIN_SERVICES:
|
|
- importlib.import_module(path) <- may raise ImportError
|
|
- read module.SERVICE <- may be missing / wrong type
|
|
b. check for duplicate name / mount_path <- raises if any
|
|
c. apply MCP_SERVICES env whitelist
|
|
d. check that env names match a discovered service
|
|
e. return list[McpService]
|
|
2. for svc in result:
|
|
a. log "Mounting ..."
|
|
b. mcp = init_mcp_server(svc.app)
|
|
c. mcp.mount_http(app, mount_path=svc.mount_path)
|
|
d. _log_mcp_tools(mcp, svc)
|
|
-> app serves requests
|
|
```
|
|
|
|
## Backward Compatibility
|
|
|
|
- **No existing tool / route / model / test changes.** The refactor only
|
|
moves `McpService` out of `main.py` and adds a manifest module per service.
|
|
- **Unset `MCP_SERVICES` = identical behavior to today.** A deployment that
|
|
doesn't set the env var will mount both `spark_executor` and `files` exactly
|
|
as before.
|
|
- **Existing 327 tests pass unchanged** (no test code touches main.py's
|
|
service list; tests use direct `TestClient` on the sub-apps).
|
|
- **`gunicorn.conf.py` and the `GUNICORN_WORKERS=1` warning** are unaffected.
|
|
|
|
## Testing
|
|
|
|
### `tests/unit/test_mcp_service.py` - 12 cases
|
|
|
|
```python
|
|
class TestDiscover:
|
|
def test_loads_builtin_services_in_order() # BUILTIN_SERVICES path -> SERVICE list, preserves BUILTIN_SERVICES order
|
|
def test_skips_broken_service_if_not_requested(caplog) # BUILTIN has a bad path, env doesn't mention -> WARN, no raise
|
|
def test_raises_on_broken_service_if_requested() # BUILTIN has a bad path, env mentions it -> RuntimeError
|
|
def test_raises_when_service_module_lacks_SERVICE() # Module exists but no SERVICE attribute
|
|
def test_raises_when_SERVICE_is_wrong_type() # SERVICE = "not a McpService"
|
|
def test_raises_on_duplicate_service_names() # Two paths export same name -> raises
|
|
def test_raises_on_duplicate_mount_paths() # Two paths export same mount_path -> raises
|
|
|
|
class TestFilter:
|
|
def test_unset_env_returns_all() # monkeypatch.delenv; all BUILTIN services present
|
|
def test_empty_env_returns_zero() # MCP_SERVICES='' -> []
|
|
def test_whitelist_filters_by_name() # MCP_SERVICES='files' -> only files
|
|
def test_whitelist_preserves_bulitin_order() # MCP_SERVICES='files,spark_executor' -> order is BUILTIN, not env
|
|
|
|
class TestEnvErrors:
|
|
def test_unknown_name_in_env_raises_with_available_list() # MCP_SERVICES='bogus' -> RuntimeError mentions available names
|
|
```
|
|
|
|
### `tests/integration/test_main_lifespan.py` - 5 cases
|
|
|
|
```python
|
|
class TestLifespanMounting:
|
|
def test_default_mounts_both_services() # no env -> /spark-executor-mcp and /files-mcp both reachable
|
|
def test_MCP_SERVICES_files_only_mounts_one() # /files-mcp reachable, /spark-executor-mcp 404
|
|
def test_MCP_SERVICES_empty_mounts_none() # both 404 (only /health on root)
|
|
def test_MCP_SERVICES_unknown_raises_at_startup() # bad env -> TestClient startup raises
|
|
def test_health_endpoint_unaffected_by_mcp_filtering() # /health always 200
|
|
```
|
|
|
|
(Integration test uses `TestClient(app)` from `main:app` and exercises the
|
|
real `lifespan`. `monkeypatch` is used to set/unset the env var per test.)
|
|
|
|
### Existing tests (327)
|
|
|
|
- All pass unchanged.
|
|
- `tests/integration/test_files_mcp_routes.py` still works (it uses
|
|
`files_mcp.server:app` directly, not the registry).
|
|
- `tests/integration/test_mcp_routes.py` for spark_executor still works
|
|
the same way.
|
|
|
|
## Risks
|
|
|
|
1. **`service.py` import triggers the package's `__init__`.** Each
|
|
`service.py` does `from <pkg> import app`, which runs the package's
|
|
`__init__.py`. For `files_mcp`, that means `_init_root()` runs (and may
|
|
raise `RuntimeError` if `FILES_MCP_ROOT` is unset).
|
|
- **Mitigation:** the existing `files_mcp/__init__.py` keeps its first
|
|
try/except (tolerating unset `FILES_MCP_ROOT` at import time) - this
|
|
was already documented and approved in the file-MCP spec. The conftest
|
|
autouse fixture sets `FILES_MCP_ROOT` before tests run.
|
|
- **In production:** operators must set `FILES_MCP_ROOT` before starting
|
|
the server, as documented in the file-MCP spec.
|
|
|
|
2. **Service-name / package-name coupling.** The convention is "service
|
|
name == package name" so the env whitelist is intuitive. A package that
|
|
declares a different name will still work (the env var is matched by
|
|
`McpService.name`, not by package), but the convention is the
|
|
documentation hint.
|
|
- **Mitigation:** the `MCP_SERVICES` env error message lists available
|
|
service names explicitly.
|
|
|
|
3. **Mount path collision across services.** Two services with the same
|
|
`mount_path` would cause `fastapi-mcp` to error at the second `mount_http`.
|
|
- **Mitigation:** `discover_and_filter` checks for duplicate mount paths
|
|
and raises a clear error before any mount happens.
|
|
|
|
4. **Behavioral change at the wiring layer.** Operators who read `main.py`
|
|
looking for the service list will need to know about `BUILTIN_SERVICES`
|
|
in `common/mcp_service.py` and the per-package `service.py` files.
|
|
- **Mitigation:** the comment block in `main.py` (currently explaining
|
|
the registry) is updated to point to `common.mcp_service.BUILTIN_SERVICES`
|
|
and the manifest convention.
|
|
|
|
## Implementation Order (preview - full plan comes from `writing-plans`)
|
|
|
|
1. `common/mcp_service.py` (dataclass + discover + filter) +
|
|
`tests/unit/test_mcp_service.py`
|
|
2. `spark_executor/service.py` + `files_mcp/service.py`
|
|
3. Refactor `main.py` (remove old list + dataclass, use `discover_and_filter`)
|
|
4. `tests/integration/test_main_lifespan.py`
|
|
5. Run full suite; commit.
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `uv run pytest -v` passes (327 existing + 17 new = 344).
|
|
- [ ] `git diff main --stat` shows changes only in: `common/mcp_service.py`
|
|
(new), `spark_executor/service.py` (new), `files_mcp/service.py` (new),
|
|
`main.py` (modified), and 2 new test files.
|
|
- [ ] `pyproject.toml` / `uv.lock` unchanged.
|
|
- [ ] `spark_executor/server.py`, `spark_executor/tools/*`, `spark_executor/core/*`,
|
|
`files_mcp/server.py`, `files_mcp/tools/*`, `files_mcp/core/*`,
|
|
`files_mcp/models.py`, `files_mcp/__init__.py` are byte-for-byte
|
|
unchanged.
|
|
- [ ] Smoke test: with no env var, both `/spark-executor-mcp` and
|
|
`/files-mcp` mount successfully (same as today).
|
|
- [ ] Smoke test: with `MCP_SERVICES=files`, only `/files-mcp` mounts;
|
|
`/spark-executor-mcp` returns 404.
|
|
- [ ] Smoke test: with `MCP_SERVICES=files,bogus`, startup raises with an
|
|
error that lists `Available: ['files', 'spark_executor']`.
|