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 @Author :tao.chen
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import FastAPI from fastapi import FastAPI
@@ -12,30 +13,63 @@ from common.logging import logger
from spark_executor import app as spark_executor_app from spark_executor import app as spark_executor_app
@asynccontextmanager # --- MCP service registry ---
async def lifespan(app: "FastAPI"): #
logger.info(f"Startup {app.title}") # Each McpService entry becomes one mount under the root app. The lifespan
spark_executor_mcp = init_mcp_server(spark_executor_app) # iterates this list at startup, so adding a new MCP service is a one-line
spark_executor_mcp.mount_http(app, mount_path="/spark-executor-mcp") # append here — no edits to the lifespan function needed.
logger.info( #
f"MCP server mounted at /spark-executor-mcp " # Example (future):
f"(source: {spark_executor_app.title} v{spark_executor_app.version})" # 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). @dataclass(frozen=True)
tools = spark_executor_mcp.tools class McpService:
logger.info(f"MCP tools registered ({len(tools)}):") """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): for i, tool in enumerate(tools, 1):
desc = (tool.description or "").strip() 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: if "### Responses" in desc:
desc = desc.split("### Responses", 1)[0].rstrip() desc = desc.split("### Responses", 1)[0].rstrip()
# Collapse internal newlines / extra spaces into one line.
desc = " ".join(desc.split()) desc = " ".join(desc.split())
logger.info(f" {i:>2}. {tool.name} - {desc}") 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 yield
logger.info(f"Shutdown {app.title}") logger.info(f"Shutdown {app.title}")
@@ -43,6 +77,11 @@ async def lifespan(app: "FastAPI"):
app = FastAPI(title="Main App", lifespan=lifespan) app = FastAPI(title="Main App", lifespan=lifespan)
@app.get("/health")
def health_check():
return {"status": "ok"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn