Starlette+Uvicorn 部署 MCP 时,如何捕获无日志的服务端异常?
结论
在当前结构下,最直接的办法是在注册到 MCP 的业务处理函数或其包装函数中,调用 logger.exception() 记录完整 traceback,然后重新抛出异常。
raise_exceptions 是 MCP 底层 Server.run() 的参数。mcp 1.27.1 的 StreamableHTTPSessionManager 没有同名构造参数,也不会自动从 Starlette 或 Uvicorn 获取这个设置。如果需要开启,可以在开发环境中继承 MCP 的 Server,覆盖 run()。但异常向外传播并不保证整个 Uvicorn 进程退出。
为什么异常发生了,却没有 traceback?
在 mcp 1.27.1 中,list_tools() 抛出的普通异常会进入底层 _handle_request() 的异常处理分支。默认设置为 raise_exceptions=False,该分支会将异常转换为:
types.ErrorData(code=0, message=str(err), data=None)
随后将它发给客户端,不在这里记录 traceback。因此,Inspector 会显示:
MCP error 0: this won't crash the server
这里的 0 是 SDK 在这个分支中填入的协议错误码,与 HTTP 状态码、进程退出码无关,也不表示成功。对应版本源码
Starlette 的 debug=True 只影响到达其错误中间件的异常。如果 MCP 已经把异常转换为响应,Uvicorn 的日志也无法补出异常堆栈。Starlette 错误中间件、Uvicorn ASGI 异常处理
异常后面的日志语句没有输出,是因为函数在抛出异常时已经中断,后续语句没有执行。
推荐做法:在 MCP 处理函数边界记录异常
可以给现有的两个处理函数加一层包装,Starlette、CORS 和会话管理逻辑都不需要改动:
import logging
import sys
from functools import wraps
from mcp.server import Server
logging.basicConfig(
level=logging.DEBUG,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
def log_handler_exceptions(handler):
@wraps(handler)
async def wrapped(*args, **kwargs):
try:
return await handler(*args, **kwargs)
except Exception:
logger.exception(
"MCP handler failed: %s",
handler.__qualname__,
)
raise
return wrapped
class MyMCPServer:
def __init__(self, tools_exe_service):
self.server = Server("my-mcp-server")
self.server.list_tools()(
log_handler_exceptions(tools_exe_service.list_tools)
)
self.server.call_tool()(
log_handler_exceptions(tools_exe_service.call_tool)
)
# 原有 main() 保持不变
在 except 内调用 logger.exception(),会以 ERROR 级别记录当前异常及 traceback。后面的裸 raise 让异常继续传播,由 MCP 按自身规则生成错误响应。@wraps 用于保留被包装函数的签名等信息。
这层包装能捕获业务处理函数抛出的异常,但无法捕获 SDK 在调用业务函数之前发生的校验错误。
如果这里的日志仍然没有输出,需要检查日志配置。根 logger 已有 handler 时,logging.basicConfig() 默认不会重新配置。在独立的本地调试脚本中,可以加入 force=True,强制替换根 logger 的现有 handler;嵌入其他应用时,应先确认这些 handler 是否需要保留。Python logging 文档
raise_exceptions=True 应该传到哪里?
如果直接管理 MCP 的读写流,应将参数传给 mcp_server.run():
await mcp_server.run(
read_stream,
write_stream,
initialization_options,
raise_exceptions=True,
)
当前代码通过 StreamableHTTPSessionManager 在内部调用 run()。使用 mcp 1.27.1 时,可以定义一个用于开发调试的子类:
from mcp.server import Server
class DebugMCPServer(Server):
async def run(
self,
read_stream,
write_stream,
initialization_options,
raise_exceptions=False,
stateless=False,
):
return await super().run(
read_stream,
write_stream,
initialization_options,
raise_exceptions=True,
stateless=stateless,
)
然后将原来的实例化语句改为:
self.server = DebugMCPServer("my-mcp-server")
会话管理器仍然接收这个实例:
self.session_manager = StreamableHTTPSessionManager(
app=mcp_server_obj,
json_response=True,
)
会话管理器调用 app.run() 时,就会执行覆盖后的方法。这个写法依赖该版本的 Server.run() 签名,升级 SDK 后需要重新核对。Server.run() 源码
使用时有两个限制:
- 会话管理器还会捕获从
app.run()向外抛出的异常,并调用logger.exception()记录会话崩溃,因此不能保证 Uvicorn 进程退出。会话管理器源码 - 该版本的
call_tool()注册器另有一层异常处理,会把普通工具异常转换成isError=True的CallToolResult。这类异常可能在到达_handle_request()之前就已被处理,因此raise_exceptions=True无法覆盖所有工具异常。call_tool() 源码
日常开发中,即使开启了 raise_exceptions=True,也应保留业务函数边界上的异常日志。
需要现场调试时,让调试器在抛出位置暂停
要查看变量和调用栈,可以在 IDE 中启用“抛出异常时暂停”,并包含已捕获异常。如果只配置“未捕获异常时暂停”,可能会错过 SDK 内部处理的异常。
使用 PDB 时,也可以临时在业务函数的 except 分支中进入调试器,前提是服务进程连接着可交互终端:
except Exception:
logger.exception("tools/list failed")
breakpoint()
raise
获取 traceback 不需要让服务器崩溃。即使异常到达 Uvicorn 的 HTTP 请求处理边界,Uvicorn 通常也会记录异常,并通过错误响应或关闭连接结束当前请求,进程仍可继续运行。Uvicorn 请求异常处理源码
生产环境中应保留服务端 traceback,关闭 debug=True,并按业务需要控制返回客户端的错误内容,避免将内部路径、配置或敏感数据带入错误消息。
备注:内容仅供参考。
