从零手写一个MCP Agent服务:stdio与SSE两种连接模式的踩坑实录

从零手写一个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分别只做传输层封装。这样改工具逻辑只改一处,两个入口都不会漏。

五、几个容易忽略的点

  1. 错误返回要用isError: true,而不是抛异常。抛异常客户端收到的是协议层错误,Agent无法把它当成工具执行结果来处理,会直接中断对话。
  2. tools/list返回的inputSchema要写全 ,尤其是required。我第一次漏了,Claude调用时经常传空参数进来。
  3. SSE的session id不要复用。断线重连要当成新会话,旧会话的资源(比如数据库连接)要显式清理,否则连接池会被耗光。
  4. stdio下不要用asyncio.run嵌套 。mcp库自己管事件循环,你在工具函数里再起一个loop会直接报RuntimeError: This event loop is already running。

这次做完最大的感受是:MCP的协议本身不复杂,真正花时间的是传输层的那些"工程细节"------日志通道、会话隔离、超时保活。这些在示例代码里都不会写,但线上跑起来一个都躲不掉。

相关推荐
熊猫钓鱼>_>11 小时前
越顺,越空:当 AI 把学习 “优化“ 到消失
人工智能·学习·ai·llm·agent·ai编程·metaai
每天都要写算法(努力版)12 小时前
【行业前沿报告】给智能体写工具:从接口能调用,到任务能完成
llm·agent
网络毒刘12 小时前
GPT-6.1 Sol 定位速读:成本效率型编码模型与「何时该换本地 Agent」
人工智能·gpt·openai·agent·cursor
后端小肥肠12 小时前
Claude Opus 5.5 做视频:从口播稿到成片,全流程跑通
人工智能·aigc·agent
网络毒刘12 小时前
Manual 模式精修补丁:在 Agent 提案后用最小编辑完成高风险改动
安全·agent·ai编程·cursor
大连好光景12 小时前
如何将已有应用转成MCP服务?
mcp
A1Book13 小时前
# 从 0 部署 Qwen3.8-27B:量化档位怎么选,终端配置怎么配
llm·测试
Together_CZ14 小时前
LLM-as-a-Verifier: A General-Purpose Verification Framework——一种通用验证框架
llm·framework·agent·verification·verifier·一种通用验证框架·llm-as-a-
桃西西呀16 小时前
LangChain 之八:流式与透传
人工智能·langchain·llm
桃西西呀16 小时前
LangChain 之九:一个能检索又会调工具的流式问答助手
人工智能·langchain·llm