从零手写一个MCP Agent服务:stdio与SSE两种连接模式的踩坑实录
上周我在给公司的内部知识库做一个Agent入口,需要把几个自研工具(文档检索、SQL查询、发通知)暴露给Claude Desktop和自建的Agent框架同时使用。本来想直接抄官方示例了事,结果发现一旦涉及"同一个MCP服务要同时被本地客户端和远程Agent调用"这个需求,stdio和SSE的选型就没那么简单了。这篇记录一下我踩过的具体坑。
一、先搞清楚MCP到底在传什么
MCP(Model Context Protocol)本质是JSON-RPC 2.0的一层约定。客户端和服务端之间交换三类消息:请求(带id)、响应(带对应id)、通知(无id)。工具注册走的是tools/list,调用走tools/call。
理解这一点很重要,因为後面stdio和SSE的差异,本质上只是"这层JSON-RPC消息怎么在传输层流动"的差异。
二、stdio模式:本地跑起来最省事,但有个致命细节
stdio模式下,MCP服务是一个被客户端作为子进程启动 的可执行程序,双方通过标准输入输出通信。协议规定:一行一条JSON-RPC消息。
我第一版用的是Python的mcp库,按文档写了大概这样:
python
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
app = Server("kb-server")
@app.list_tools()
async def list_tools():
return [
types.Tool(
name="search_docs",
description="搜索内部知识库文档",
inputSchema={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
)
]
@app.call_tool()
async def call_tool(name, arguments):
if name == "search_docs":
result = await do_search(arguments["query"])
return [types.TextContent(type="text", text=result)]
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
看起来没问题,但我踩的第一个坑是日志 。我在工具函数里随手写了个print(f"query={query}"),结果客户端直接报JSON解析错误。原因是stdout是协议通道,任何非JSON输出都会污染它。所有调试输出必须走stderr ,也就是print(..., file=sys.stderr)或者用logging配置到stderr。这个坑我查了半小时才反应过来。
第二个坑是进程生命周期 。stdio模式下服务由客户端拉起,客户端退出服务就没了。我一开始想在里面维护一个长连接的向量库客户端,结果每次重启都要重新建连,冷启动能到两三秒。后来改成在on_initialized回调里做预热,才勉强压到800ms左右。
三、SSE模式:能远程,但会话隔离要自己管
因为还有一部分Agent跑在服务器上,不可能让它去启动本地进程,所以我又加了一个SSE入口。
SSE模式的结构是:客户端先GET /sse建立一个长连接,服务端通过这条连接推送消息,同时返回一个endpoint事件告诉客户端往哪个URL发POST请求。之后的JSON-RPC请求都用POST发过去。
用mcp库大致这样:
python
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount
sse = SseServerTransport("/messages/")
async def handle_sse(request):
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await app.run(streams[0], streams[1], app.create_initialization_options())
return Response()
starlette_app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse.handle_post_message),
]
)
这里我踩了最惨的一个坑 :一开始我把app(Server实例)定义成全局单例,想着所有连接共享。结果多个客户端同时连进来时,会话状态互相串了------A客户端的初始化参数会被B客户端看到,tools/call的返回偶尔发错连接。
翻源码才明白:每个SSE连接必须对应一个独立的Server会话 。正确做法是在handle_sse里为每个连接创建一个新的会话实例,或者至少保证create_initialization_options()返回的状态是per-connection的。我改了之后并发测试5个客户端才稳定。
另一个坑是代理超时 。我们前面挂了nginx,默认proxy_read_timeout是60秒。SSE连接空闲超过60秒就被nginx掐断,客户端重连时又拿不到原来的session id,工具调用直接失败。解决办法是在nginx配置里针对/sse路径设proxy_read_timeout 3600s并关闭proxy_buffering,同时在服务端每30秒发一个心跳注释行:\n\n保活。
四、两种模式到底怎么选
结合这次的实际使用,我的判断标准是这样的:
选stdio:客户端和服务在同一台机器、服务是本地工具(文件操作、本地数据库)、不关心多客户端并发。优点是零网络配置、天然进程隔离、安全性好(不暴露端口)。
选SSE:服务要远程部署、要被多个Agent或用户共享、需要集中做鉴权和限流。代价是必须自己处理会话隔离、心跳保活、反向代理配置这些事。
我最后的方案是同一份工具逻辑,两个入口 :tools/目录放纯业务函数,stdio_server.py和sse_server.py分别只做传输层封装。这样改工具逻辑只改一处,两个入口都不会漏。
五、几个容易忽略的点
- 错误返回要用
isError: true,而不是抛异常。抛异常客户端收到的是协议层错误,Agent无法把它当成工具执行结果来处理,会直接中断对话。 tools/list返回的inputSchema要写全 ,尤其是required。我第一次漏了,Claude调用时经常传空参数进来。- SSE的session id不要复用。断线重连要当成新会话,旧会话的资源(比如数据库连接)要显式清理,否则连接池会被耗光。
- stdio下不要用
asyncio.run嵌套 。mcp库自己管事件循环,你在工具函数里再起一个loop会直接报RuntimeError: This event loop is already running。
这次做完最大的感受是:MCP的协议本身不复杂,真正花时间的是传输层的那些"工程细节"------日志通道、会话隔离、超时保活。这些在示例代码里都不会写,但线上跑起来一个都躲不掉。