- main.py: import files_app, append McpService entry to MCP_SERVICES. Lifespan already iterates the list, so no edit to the lifespan function. - tests/conftest.py: autouse fixture rebinds files_mcp.core.path_guard._ROOT to a per-test tmp_path so the sandbox is fresh for every test. - files_mcp/__init__.py: drop the try/except around the server import now that server.py exists, restoring fail-fast on a missing/corrupt server module in production. The _init_root() try/except stays, since pytest collection can import the package before FILES_MCP_ROOT is set. Verified via uv run pytest: 327 tests pass, no regressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
# coding=utf-8
|
|
"""
|
|
@Time :2026/6/24
|
|
@Author :tao.chen
|
|
"""
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from common.factory import init_mcp_server
|
|
from common.logging import logger
|
|
from files_mcp import app as files_app
|
|
from spark_executor import app as spark_executor_app
|
|
|
|
|
|
# --- MCP service registry ---
|
|
#
|
|
# Each McpService entry becomes one mount under the root app. The lifespan
|
|
# iterates this list at startup, so adding a new MCP service is a one-line
|
|
# append here — no edits to the lifespan function needed.
|
|
#
|
|
# Example (future):
|
|
# McpService(name="metrics", app=metrics_app, mount_path="/metrics-mcp"),
|
|
# McpService(name="catalog", app=catalog_app, mount_path="/catalog-mcp"),
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class McpService:
|
|
"""One MCP-exposed FastAPI sub-app to be mounted under the root."""
|
|
|
|
name: str # short identifier used in startup logs (e.g. "spark_executor")
|
|
app: FastAPI # the sub-app whose routes will be exposed as MCP tools
|
|
mount_path: str # HTTP path under the root app (e.g. "/spark-executor-mcp")
|
|
|
|
|
|
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",
|
|
),
|
|
]
|
|
|
|
|
|
def _log_mcp_tools(mcp_server, service: McpService) -> 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}")
|
|
for svc in MCP_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)
|