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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user