refactor: make lifespan extensible for additional MCP services

The lifespan was hardcoded to mount just one MCP service
(spark_executor_app at /spark-executor-mcp). To add a second MCP
service, you'd have to edit the lifespan function body — error-prone
and not obvious where to add a new entry.

Refactor:
  - New McpService dataclass (name, app, mount_path)
  - MCP_SERVICES list at module top — adding a new service is one append
  - Lifespan iterates MCP_SERVICES; the per-service mount + tool-list
    logic is now a helper (_log_mcp_tools)
  - Tool-list log now prefixes the service name:
      'MCP tools registered (spark_executor, 14):'
    instead of just '(14):', so multi-service startups are unambiguous

116/116 still pass. Live verified: gunicorn 2-worker startup logs
each service with its tool list and the source app version.

Future services just need a McpService entry — no lifespan edit. The
MCP_SERVICES list has a docstring example showing the one-liner.
This commit is contained in:
Claude
2026-06-25 12:33:14 +08:00
parent 5970fadcd2
commit 15e9a75bc7
+55 -16
View File
@@ -4,6 +4,7 @@
@Author :tao.chen
"""
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import FastAPI
@@ -12,30 +13,63 @@ from common.logging import logger
from spark_executor import app as spark_executor_app
@asynccontextmanager
async def lifespan(app: "FastAPI"):
logger.info(f"Startup {app.title}")
spark_executor_mcp = init_mcp_server(spark_executor_app)
spark_executor_mcp.mount_http(app, mount_path="/spark-executor-mcp")
logger.info(
f"MCP server mounted at /spark-executor-mcp "
f"(source: {spark_executor_app.title} v{spark_executor_app.version})"
)
# --- 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"),
# Enumerate every MCP tool + its description so operators can see at a
# glance what the server exposes (useful for debugging / change review).
tools = spark_executor_mcp.tools
logger.info(f"MCP tools registered ({len(tools)}):")
@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",
),
]
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
logger.info(f"MCP tools registered ({service.name}, {len(tools)}):")
for i, tool in enumerate(tools, 1):
desc = (tool.description or "").strip()
# fastapi-mcp appends an auto-generated "### Responses: ..." block to
# every tool description; drop it so the startup log stays scannable.
if "### Responses" in desc:
desc = desc.split("### Responses", 1)[0].rstrip()
# Collapse internal newlines / extra spaces into one line.
desc = " ".join(desc.split())
logger.info(f" {i:>2}. {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}")
@@ -43,6 +77,11 @@ async def lifespan(app: "FastAPI"):
app = FastAPI(title="Main App", lifespan=lifespan)
@app.get("/health")
def health_check():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn