MCP Server上线那天,我连踩5个坑

MCP Server上线那天,我连踩5个坑

上周五下午4点,我把自研的MCP Server推上生产环境。

原计划半小时搞定,结果搞到晚上11点。中间踩了5个坑,每一个都不致命,但每一个都能让你卡半小时以上。

记录下来,省得你再踩。


坑1:stdio模式跑得好好的,换SSE就炸

本地开发一直用stdio模式,调试顺滑,工具调用正常。切到SSE模式部署到服务器上,客户端连上来直接报错:

Error: MCP server connection closed unexpectedly

查了40分钟,最后发现是uvicorn默认的worker模型把stdio管道关了。stdio模式下进程是单线程的,SSE模式需要HTTP长连接,两个的生命周期管理完全不同。

解决:SSE模式下不能用stdio的启动方式。正确做法是单独写一个SSE入口:

from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount

transport = SseServerTransport("/messages/")

async def handle_sse(request):
    async with transport.connect_sse(
        request.scope, request.receive, request._send
    ) as streams:
        await server.run(streams[0], streams[1], server.create_initialization_options())

app = Starlette(routes=[
    Route("/sse", endpoint=handle_sse),
    Mount("/messages/", app=transport.handle_post_message),
])

然后用uvicorn启动,指定--host 0.0.0.0 --port 8080。关键是SSE和stdio是两套东西,别想着一套代码无缝切换。


坑2:工具注册了但客户端看不到

SSE连上了,客户端能握手,但tool list返回空。

我明明在代码里写了@server.tool()装饰器,本地测试也能调用,为什么线上就不行?

原因是启动顺序。我的工具注册代码在一个独立模块里,用的是延迟导入。SSE模式下请求到达时,模块还没被import,装饰器根本没执行。

本地测试之所以没问题,是因为我习惯性地在主文件顶部import了所有模块。线上用gunicorn多worker加载,启动逻辑不一样。

解决:确保所有工具在server启动前完成注册。最笨但最靠谱的办法——在主入口文件顶部显式import:

# main.py
import tools.file_ops    # 注册文件操作工具
import tools.web_search  # 注册搜索工具
import tools.code_exec   # 注册代码执行工具

# 以上import会触发 @server.tool() 装饰器执行
# 放在server.run()之前就对了

别用importlib动态加载,至少在MCP Server场景下,显式import更可控。


坑3:长时间运行的工具被反向代理杀掉

我有一个代码执行工具,跑复杂脚本可能要2-3分钟。本地测试没问题,线上跑到1分钟左右就被断开。

Nginx。默认proxy_read_timeout 60s

60秒一到,Nginx主动断连,客户端收到一个不完整的响应。MCP协议层没有任何重试机制——工具执行到一半被杀,上下文全丢。

解决:Nginx配置加上:

location /sse {
    proxy_pass http://127.0.0.1:8080;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    chunked_transfer_encoding off;
}

注意proxy_http_version 1.1Connection ''是SSE长连接的标配,不加的话Nginx会用HTTP/1.0,连接很快就断。

如果你用的是云厂商的ALB/SLB,还要单独去改负载均衡器的超时配置,Nginx改了也没用。我就是被这个坑了——前面还有一层阿里云SLB,默认60秒超时。


坑4:并发请求把数据库连接池打满

上线第一天,3个用户同时用,第4个连上来就报错:

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached

我的MCP Server里有个工具调数据库,连接池大小写的5。本地开发就我一个人用,从没触发过。3个用户同时查,加上SSE长连接占着不释放,连接池瞬间打满。

解决:两步。

第一步,调大连接池:

engine = create_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=30,
    pool_recycle=3600,  # 1小时回收一次,防止连接泄漏
    pool_pre_ping=True  # 每次取连接前先ping,剔除死连接
)

第二步,更关键——每个工具调用完要立即释放连接,别让连接跟着SSE长连接一直占着。用session.close()包裹在finally里:

@server.tool()
async def query_db(sql: str):
    session = SessionLocal()
    try:
        result = session.execute(text(sql))
        return result.fetchall()
    finally:
        session.close()

不加finally,一次异常就泄漏一个连接,半小时后连接池就满了。


坑5:日志全打到stdout,排查问题全靠猜

出了上面那些问题的时候,我发现一件很尴尬的事——我几乎没有可用的日志。

本地开发靠print,线上print在Docker容器的stdout里,要看日志得docker logs,翻半天找不到关键信息。

解决:上结构化日志。用structlog,一行代码搞定:

import structlog

logger = structlog.get_logger()

@server.tool()
async def some_tool(params):
    logger.info("tool_called", tool="some_tool", params=params, request_id=get_request_id())
    try:
        result = do_something(params)
        logger.info("tool_success", tool="some_tool", duration_ms=elapsed)
        return result
    except Exception as e:
        logger.error("tool_failed", tool="some_tool", error=str(e), params=params)
        raise

关键字段:

  • request_id:贯穿一次MCP会话的所有日志,方便按会话串联
  • tool:哪个工具被调用
  • duration_ms:耗时,精确到毫秒
  • error:异常信息
  • 别用print(json.dumps(...)),直接用structlog,输出是JSON格式,ELK/Loki直接能采集。


    总结一下这5个坑

    坑表面现象根因修一行还是改架构 stdio→SSE连接直接断两套模式,启动逻辑不同改启动方式 工具不可见tool list为空延迟import导致装饰器未执行显式import 超时断连1分钟后中断Nginx/SLB默认60s超时改代理配置 连接池满第4个用户报错连接池太小+未及时释放改代码+调配置 没日志出问题全靠猜print代替了结构化日志加logging

    说白了,MCP Server本身不复杂,但生产环境的基础设施(反向代理、数据库连接池、日志系统)会给你制造大量意外。本地跑得顺,不代表线上能跑。

    每一层架构都不是设计出来的,是被流量逼出来的。先让它跑起来,再让它跑得稳。



    关注「安全值班室」公众号

    每天一篇AI安全早报 + 实战攻防案例

    关注安全值班室

    posted on 2026-05-26 09:01  明.Sir  阅读(2)  评论(0)    收藏  举报

    导航