FastAPI 挂载 MCP 后首个请求 500,漏掉的是宿主 lifespan
FastAPI 挂载 MCP 子应用时,宿主 lifespan 需要管理 session manager。合并原有生命周期,并通过 initialize、list_tools 和工具调用验证,健康检查 200 不够。
FastAPI 挂载 MCP 后首个请求 500,漏掉的是宿主 lifespan
MCP 独立运行正常,挂进 FastAPI 之后,健康检查也是 200,第一次访问 MCP 却返回 500。堆栈里出现:
RuntimeError: Task group is not initialized. Make sure to use run().
这里先检查应用启动生命周期。路由挂载成功只说明请求能被分发到子应用;MCP 的 session manager 还需要在宿主应用启动时进入 run 上下文。挂载子应用不会替宿主自动执行这一步。
下面使用 mcp==1.29.0 的 FastMCP 接口。新版本 main 文档的 MCPServer 构造方式不同,不过宿主管理会话生命周期这一点仍适用。诊断时同时记录 mcp、fastapi、starlette 的安装版本,避免拿另一代 SDK 的关键字修当前环境。
最小可启动的宿主
保存为 host_app.py。先构造 MCP 子应用,再在宿主 lifespan 中读取 session_manager;构造顺序很重要。
from contextlib import asynccontextmanager
from fastapi import FastAPI
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("mounted-demo", json_response=True)
@mcp.tool()
def ping() -> str:
return "pong"
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with mcp.session_manager.run():
yield
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ok"}
app.mount("/", mcp_app)
用 uvicorn host_app:app --host 127.0.0.1 --port 8000 启动,MCP 地址是 http://127.0.0.1:8000/mcp。这里在根路径挂载,子应用仍保留自己的 /mcp。如果改成 app.mount("/tools", mcp_app),地址会变成 /tools/mcp。
根路径挂载要放在自有路由后面,否则它可能先匹配请求,让后面的业务路由到不了。不要为了处理 404 把相同子应用挂载两遍,那会让路径与生命周期更加难查。
验收要真正握一次手
健康检查没有触碰 session manager,不能证明修复完成。在宿主启动后,用 MCP 客户端完成 initialize 和 list_tools,确认返回列表中有 ping;再调用 ping,结果应是 pong。最后正常停止服务,观察关闭过程有没有未释放任务或资源错误。
测试代码若使用 Starlette/FastAPI TestClient,应使用 with TestClient(app) as client: 进入生命周期。只构造 TestClient 对象然后发请求,可能在测试里再次制造同样的问题。测试默认 Host 常是 testserver,若收到 421,应为测试环境单独配置正确的 Host;不要因此误判 lifespan 仍然失效。
多个 MCP 服务挂到同一个宿主时,每个 server 有各自的 manager。可以在同一个 lifespan 中用 AsyncExitStack 依次进入各自的 run,上下文退出时反向清理。不要在每个 HTTP 请求里启动 manager,也不要对同一个 manager 重复进入。
如果应用已经有数据库连接池的 lifespan,把 MCP 的上下文合并进原有函数;重新写一个 FastAPI 实例替代旧实例,可能把数据库初始化或原来的路由一起丢掉。


