本文介绍了远程MCP使用的SSE协议,通过wireshark抓包的方式了解MCP客户端和服务端之间通过SSE协议交互涉及到的请求与响应。
1. 什么是SSE协议?
MCP的远程服务是通过SSE(Server-Sent Events)启动的,SSE是一个基于HTTP的长连接协议。SSE在逻辑上是一个由客户端发起、由服务器同意而建立的从服务器向客户端发消息的单向管道。这个管道建立之后,客户端给服务器发消息时用传统方式发,服务器给客户端发消息时用这个管道发,双方就可以灵活地进行通信了。
MCP SSE客户端会发起多个请求,第一个请求是/sse
路径,这是建立SSE长连接的第一步。服务端会使用chunked方式来回传数据,每次不告诉客户端数据量有多少,让客户端保持连接始终联通,即维护了一个长连接。后续每一次服务端与客户端的通讯,都会采用事件id、事件名称event、data三个字段来通信(服务端发送给客户端)。
2. 实际测试:运行MCP服务端和QwenAgent
因为使用sse远程方式启动mcp服务端时是在本地回环地址启动的, 所以可以通过wireshark工具监听到我们本地客户端与服务端之间传输的请求与响应,通过这种方式来进一步了解mcp每一步都请求了什么,响应了什么。
首先是用sse模式启动我们的mcp服务端demo,也就是官方python sdk中的mcp-python-sdk/examples/servers/simple-tool
,设置端口为8000。注意修改命令中--directory
之后的路径为你电脑上simple-tool
的正确路径。
然后再在wireshark里面监听本地回环地址,使用过滤器tcp.port==8000
筛选出所有和8000端口有关的请求。
使用如下代码,运行一次QwenAgent,调用mcp工具。
MCP服务端服务端的日志中会出现下面五条请求记录
3. 分析wireshark抓包结果
3.1. 第一条请求:/SSE
首先在wireshark中找到第一条sse请求,在wireshark中能清晰的看到客户端从49652端口向8000端口发起TCP三次握手的记录。
客户端发起的/sse
接口的请求报文如下,没有什么特别的
服务端的响应如下,这一串响应是在两个tcp报文中发出的,下图中用紫色荧光笔标注len不为0的就是服务端发出的两个报文。
注意:这里的HTTP响应报文是一个chunked类型的,也就是这一条HTTP响应报文后续还一直会有其他内容(服务端和客户端之间的管道),直到客户端和服务端的交互结束了,这一条HTTP响应报文才算完整结束!
这两个报文的内容拼接起来如下,为了更直观的展示HTTP报文格式,这里将HTTP协议的\r\n
换行符也人工标识出来。
这里便是服务端发出的第一个SSE协议事件数据了。其中,事件id是51(这个51是固定的事件编号,每次请求/sse
接口返回的事件编号都是这个),事件名称是endpoint(告诉客户端后续需要请求的接口路径是啥),事件内容就是endpoint的具体值了。在data之后还额外出现了两个\r\n
,这便是单个事件的结束标志。
这个响应就是告诉客户端,后续的请求全都要使用/messages/?session_id=b53301ca408f4da4a12562ce2fde23de
这个路径来发起,这个路径中包含本次会话的session id,客户端使用这个路径,服务端就能够知道要在哪一个管道里面向客户端发回结果。
在QwenAgent的debug日志中(底层mcp交互用的是httpx库)也能观察到这个事件,客户端收到了服务端提供的endpoint URL。
3.2. 第二条请求:初始化
第二条客户端的请求如下,这里已经开始使用服务端刚刚返回的endpoint了。请求体部分是json格式的内容,initialize代表是初始化MCP客户端,告诉服务端当前客户端使用的协议版本protocolVersion、支持的能力capabilities、jsonrpc版本等等信息
针对这次请求,服务端发回的响应就比较简单了,一个Accepted告诉客户端他的请求已经被接受了,并没有返回实际性的内容。
这正是前文提到过的SSE协议的特性,服务端传回的数据不会使用HTTP响应直接传回,而是会在第一次/sse请求后建立的长连接管道里面传回!上述响应只是针对客户端的POST请求,依照HTTP协议的要求发出的而已(HTTP要求每一个req都需要有一个res)
如下图所示,在服务端返回Accepted响应之后,就能观察到一个服务端向客户端发出的len不为0的TCP报文,这个报文中就包含了服务端针对客户端这次发起的初始化请求的实际事件响应。
这个报文的内容如下,e9是初始化事件响应的id,event事件名称是一个message,data中就包含了服务端对这次初始化请求的响应,返回了服务端的jsonrpc版本、支持的协议版本protocolVersion、支持的能力capabilities、服务端的信息serverInfo。
同样的,这里也是额外出现了两个\r\n
作为事件结束标志。
3.3. 第三条请求:初始化成功告知
第三条请求就是客户端告诉服务端自己已经准备好了,初始化成功initialized。同样会有一对POST和Accepted的HTTP请求,这里不再赘述
从抓包结果中可以看到,这一条请求到下一条请求之间没有服务端向客户端发出len不为0的TCP报文,因为这一次请求只是客户端告知服务端自己已经准备好了,服务端没必要额外返回任何信息。
3.4. 第四条请求:请求工具列表
第四条请求就是客户端向服务端请求服务端提供的工具列表了
服务端照常进行了Accepted响应
随后在管道里面发出的TCP报文中,就包含了服务端当前支持的工具,以及工具的参数和参数的类型与释义。
从日志中看,QwenAgent会把这部分内容转换为prompt发送给AI,让AI来调用这个工具。
其中上下文信息如下,可以看到这里并没有使用function call的请求格式,而是直接在system的prompt里把工具相关信息以XML格式发送给AI了。
在输出的bot response中,能看到AI针对这个tools生成了请求参数,url参数的值也是正确的,和我们提出的问题保持了一致。
3.5. 第五条请求:调用工具
在日志中能观察到,在AI生成了包含function_call的响应之后,QwenAgent的SDK就开始准备调用远程MCP工具了。
此时发起的请求如下,请求体中包含了需要请求的工具名称fetch,以及传输过来的参数arguments
服务端还是会返回一个accpet响应
随后,MCP服务端会根据这个请求,调用实际的工具,并最终返回结果。由于这个请求结果的content是慕雪个人博客首页的html源码,所以内容非常之大,这里就不贴出来完整的事件data了。
可以看到,服务端通过三次TCP报文才把整个首页的html完整传输给客户端。
到这里,针对/sse
接口的HTTP响应就完整结束了,MCP服务端以tools调用结果返回为标志来结束HTTP响应。
在wireshark拼接出来的完整HTTP响应中可以观察到,tools调用结果的json完整结束了,这个HTTP响应就是结束了,随后便出现了TCP四次挥手的报文。其中调用工具的响应json末尾会包含一个字段"isErr"
,应该是用于标识本次mcp工具调用是否成功的,为false代表调用成功。
3.6. 工具调用结果交付AI处理
在收到工具调用结果之后,日志中就能够观察到QwenAgent将这个工具调用结果拼接在prompt里面发送给AI了。这里我把html文档的内容全部删掉了,改成了“首页HTML内容”,保留了其他字段。
首先这里能看到完整的MCP服务端工具调用结果的响应,包含jsonrpc字段、id字段、result字段、isError字段。其中工具调用结果是在result/content里面返回的。
QwenAgent的SDK依旧是在消息上下文里面将MCP工具的响应结果通过<tool_response>\n首页HTML内容\n</tool_response>
的拼接了起来,以user身份发送给了AI。
最终,AI理解并处理“首页HTML内容”,输出了回答
不过,这里日志中出现了一个奇怪的地方,那就是代码里面打印的bot response上下文中的工具调用格式又变成了function_call
,这里应该是QwenAgent SDK针对mcp工具在对外输出的response里面做的额外解析处理,并没有把内部通过prompt让AI调用MCP工具的格式输出出来,在最终输出的时候还是会使用function_call
的格式来标识AI和MCP工具的调用,方便用户解析。
从前文的日志分析中我们已经能够确定QwenAgent在调用工具的时候是直接通过prompt的方式让AI识别mcp工具的。在之前的博客中也提到过,这是MCP工具集成在Agent中的两种方式之一(另外一个方式就是直接使用AI的function call功能来调用),两种方式并没有好坏之分,只是将MCP集成到Agent中的不同的实现方式而已。
我顺带测试了一下QwenAgent的自定义工具是否也是用prompt方式的,果不其然,通过QwenAgent提供的@register_tool
注册的自定义工具也是通过prompt方式让AI来调用的。
以下是运行Qwen-Agent/examples/assistant_add_custom_tool.py
时DEBUG日志中prompt内容,这里也是通过prompt让AI了解了自定义工具my_image_gen
的调用方式。
4. The end
今天心血来潮,通过抓包看了一下MCP客户端和服务端到底是怎么交互的,也算是学到了不少新知识,SSE协议也是第一次听说。如果对MCP或SSE有任何问题,欢迎评论讨论。
这次测试也借机了解了QwenAgent SDK底层是如何让Qwen大模型去处理tools的,采用的是prompt方案。所以QwenAgent在对接其他非Qwen大模型的时候基本不可用,比如我尝试了硅基流动的THUDM/GLM-4-9B-0414
和deepseek-ai/DeepSeek-V3
,都没办法正常处理QwenAgent提供的prompt,其中GLM-4-9B-0414尝试输出工具调用参数,但并不符合QwenAgent的需要,没有办法被SDK识别,程序直接停止运行了。
DeepSeek-V3尝试了多次,也是会出现无法正常输出工具调用参数的问题,勉强能成功一两次。这里给出DeepSeek成功处理的上下文,如下:
总而言之,QwenAgent的这个prompt只有在使用Qwen自家模型的时候识别度才好,使用其他家模型的时候非常容易出现tools无法正常识别的情况。不过这也是意料之中了,毕竟人家都叫QwenAgent SDK了,本来就不是面向所有大模型的通用SDK。