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.1和Connection ''是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安全早报 + 实战攻防案例
浙公网安备 33010602011771号