PAYATHON 2026

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,并按业务需要控制返回客户端的错误内容,避免将内部路径、配置或敏感数据带入错误消息。

备注:内容仅供参考。