【MCP】了解远程MCP调用背后使用的SSE协议
本文介绍了远程MCP使用的SSE协议,通过wireshark抓包的方式了解MCP客户端和服务端之间通过SSE协议交互涉及到的请求与响应。
1. 什么是SSE协议?
参考:https://zhuanlan.zhihu.com/p/1894024642395619635和https://blog.csdn.net/aerror/article/details/146208818
MCP的远程服务是通过SSE(Server-Sent Events)启动的,SSE是一个基于HTTP的长连接协议。SSE在逻辑上是一个由客户端发起、由服务器同意而建立的从服务器向客户端发消息的单向管道。这个管道建立之后,客户端给服务器发消息时用传统方式发,服务器给客户端发消息时用这个管道发,双方就可以灵活地进行通信了。
MCP SSE客户端会发起多个请求,第一个请求是/sse
路径,这是建立SSE长连接的第一步。服务端会使用chunked方式来回传数据,每次不告诉客户端数据量有多少,让客户端保持连接始终联通,即维护了一个长连接。后续每一次服务端与客户端的通讯,都会采用事件id、事件名称event、data三个字段来通信(服务端发送给客户端)。
1 | 事件id |
2. 实际测试-运行服务端和QwenAgent
因为使用sse远程方式启动mcp服务端时是在本地回环地址启动的, 所以可以通过wireshark工具监听到我们本地客户端与服务端之间传输的请求与响应,通过这种方式来进一步了解mcp每一步都请求了什么,响应了什么。
首先是用sse模式启动我们的mcp服务端demo,也就是官方python sdk中的mcp-python-sdk/examples/servers/simple-tool
,设置端口为8000。注意修改命令中--directory
之后的路径为你电脑上simple-tool
的正确路径。
1 | uv run \ |
然后再在wireshark里面监听本地回环地址,使用过滤器tcp.port==8000
筛选出所有和8000端口有关的请求。
使用如下代码,运行一次QwenAgent,调用mcp工具。
1 | from qwen_agent.agents import Assistant |
MCP服务端服务端的日志中会出现下面五条请求记录
1 | ❯ uv run --directory /Users/mothra/data/code/python/openai/mcp-python-sdk/examples/servers/simple-tool mcp-simple-tool --transport sse --port 8000 |
3. 分析wireshark抓包结果
3.1. 第一条请求:/SSE
首先在wireshark中找到第一条sse请求,在wireshark中能清晰的看到客户端从49652端口向8000端口发起TCP三次握手的记录。
客户端发起的/sse
接口的请求报文如下,没有什么特别的
1 | GET /sse |
服务端的响应如下,这一串响应是在两个tcp报文中发出的,下图中用紫色荧光笔标注len不为0的就是服务端发出的两个报文。
注意:这里的HTTP响应报文是一个chunked类型的,也就是这一条HTTP响应报文后续还一直会有其他内容(服务端和客户端之间的管道),直到客户端和服务端的交互结束了,这一条HTTP响应报文才算完整结束!
这两个报文的内容拼接起来如下,为了更直观的展示HTTP报文格式,这里将HTTP协议的\r\n
换行符也人工标识出来。
1 | 200 OK\r\n |
这里便是服务端发出的第一个SSE协议事件数据了。其中,事件id是51(这个51是固定的事件编号,每次请求/sse
接口返回的事件编号都是这个),事件名称是endpoint(告诉客户端后续需要请求的接口路径是啥),事件内容就是endpoint的具体值了。在data之后还额外出现了两个\r\n
,这便是单个事件的结束标志。
1 | 51 |
这个响应就是告诉客户端,后续的请求全都要使用/messages/?session_id=b53301ca408f4da4a12562ce2fde23de
这个路径来发起,这个路径中包含本次会话的session id,客户端使用这个路径,服务端就能够知道要在哪一个管道里面向客户端发回结果。
在QwenAgent的debug日志中(底层mcp交互用的是httpx库)也能观察到这个事件,客户端收到了服务端提供的endpoint URL。
1 | 2025-04-20 14:32:01,570 - INFO - HTTP Request: GET http://127.0.0.1:8000/sse "HTTP/1.1 200 OK" |
3.2. 第二条请求:初始化
第二条客户端的请求如下,这里已经开始使用服务端刚刚返回的endpoint了。请求体部分是json格式的内容,initialize代表是初始化MCP客户端,告诉服务端当前客户端使用的协议版本protocolVersion、支持的能力capabilities、jsonrpc版本等等信息
1 | POST /messages/?session_id=b53301ca408f4da4a12562ce2fde23de |
针对这次请求,服务端发回的响应就比较简单了,一个Accepted告诉客户端他的请求已经被接受了,并没有返回实际性的内容。
1 | 202 Accepted |
这正是前文提到过的SSE协议的特性,服务端传回的数据不会使用HTTP响应直接传回,而是会在第一次/sse请求后建立的长连接管道里面传回!上述响应只是针对客户端的POST请求,依照HTTP协议的要求发出的而已(HTTP要求每一个req都需要有一个res)
如下图所示,在服务端返回Accepted响应之后,就能观察到一个服务端向客户端发出的len不为0的TCP报文,这个报文中就包含了服务端针对客户端这次发起的初始化请求的实际事件响应。
这个报文的内容如下,e9是初始化事件响应的id,event事件名称是一个message,data中就包含了服务端对这次初始化请求的响应,返回了服务端的jsonrpc版本、支持的协议版本protocolVersion、支持的能力capabilities、服务端的信息serverInfo。
同样的,这里也是额外出现了两个\r\n
作为事件结束标志。
1 | e9\r\n |
3.3. 第三条请求:初始化成功告知
第三条请求就是客户端告诉服务端自己已经准备好了,初始化成功initialized。同样会有一对POST和Accepted的HTTP请求,这里不再赘述
1 | POST /messages/?session_id=b53301ca408f4da4a12562ce2fde23de |
1 | 202 Accepted |
从抓包结果中可以看到,这一条请求到下一条请求之间没有服务端向客户端发出len不为0的TCP报文,因为这一次请求只是客户端告知服务端自己已经准备好了,服务端没必要额外返回任何信息。
3.4. 第四条请求:请求工具列表
第四条请求就是客户端向服务端请求服务端提供的工具列表了
1 | POST /messages/?session_id=b53301ca408f4da4a12562ce2fde23de |
服务端照常进行了Accepted响应
1 | 202 Accepted |
随后在管道里面发出的TCP报文中,就包含了服务端当前支持的工具,以及工具的参数和参数的类型与释义。
1 | 109\r\n |
从日志中看,QwenAgent会把这部分内容转换为prompt发送给AI,让AI来调用这个工具。
1 | 2025-04-20 14:32:01,637 - DEBUG - Request options: {'method': 'post', 'url': '/chat/completions', 'files': None, 'json_data': {'messages': [{'role': 'system', 'content': '你是一个强大的助手,可以帮用户处理问题。\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{"type": "function", "function": {"name": "exmaple-server-fetch", "description": "Fetches a website and returns its content", "parameters": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to fetch"}}, "required": ["url"]}}}\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{"name": <function-name>, "arguments": <args-json-object>}\n</tool_call>'}, {'role': 'user', 'content': '这个网站是什么?https://blog.musnow.top/'}], 'model': 'Qwen/Qwen2.5-32B-Instruct', 'seed': 44742000, 'stream': True}} |
其中上下文信息如下,可以看到这里并没有使用function call的请求格式,而是直接在system的prompt里把工具相关信息以XML格式发送给AI了。
1 | 'messages': [{ |
在输出的bot response中,能看到AI针对这个tools生成了请求参数,url参数的值也是正确的,和我们提出的问题保持了一致。
1 | t = [{ |
3.5. 第五条请求:调用工具
在日志中能观察到,在AI生成了包含function_call的响应之后,QwenAgent的SDK就开始准备调用远程MCP工具了。
1 | 2025-04-20 15:32:49,370 - INFO - bot response: [{'role': 'assistant', 'content': '', 'reasoning_content': '', 'name': '网页查看助手', 'function_call': {'name': 'exmaple-server-fetch', 'arguments': '{"url": "https://blog.musnow.top/"}'}}] |
此时发起的请求如下,请求体中包含了需要请求的工具名称fetch,以及传输过来的参数arguments
1 | POST /messages/?session_id=b53301ca408f4da4a12562ce2fde23de |
服务端还是会返回一个accpet响应
1 | HTTP/1.1 202 Accepted |
随后,MCP服务端会根据这个请求,调用实际的工具,并最终返回结果。由于这个请求结果的content是慕雪个人博客首页的html源码,所以内容非常之大,这里就不贴出来完整的事件data了。
1 | f0bd |
可以看到,服务端通过三次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。
1 | 2025-04-20 14:33:18,289 - DEBUG - Received SSE event: message |
最终,AI理解并处理“首页HTML内容”,输出了回答
1 | {'role': 'assistant', 'content': '该网站名为"慕雪的寒舍 - 雪下了一夜",由作者"慕雪年华"创建。该网站看起来是一个个人博客,包含了各种类型的文章,例如编程学习、博客建站等。网站中的文章包含了不同的主题,如使用Python管理虚拟环境、MCP协议的理解和使用等。此外,网站顶部和底部列出了作者的一些社交链接和其他网站。', 'reasoning_content': '', 'name': '网页查看助手'} |
不过,这里日志中出现了一个奇怪的地方,那就是代码里面打印的bot response上下文中的工具调用格式又变成了function_call
,这里应该是QwenAgent SDK针对mcp工具在对外输出的response里面做的额外解析处理,并没有把内部通过prompt让AI调用MCP工具的格式输出出来,在最终输出的时候还是会使用function_call
的格式来标识AI和MCP工具的调用,方便用户解析。
1 | 2025-04-20 15:32:57,549 - INFO - bot response: [{'role': 'assistant', 'content': '', 'reasoning_content': '', 'name': '网页查看助手', 'function_call': {'name': 'exmaple-server-fetch', 'arguments': '{"url": "https://blog.musnow.top/"}'}}, {'role': 'function', 'content': '首页HTML内容', 'reasoning_content': '', 'name': 'exmaple-server-fetch'}, {'role': 'assistant', 'content': '该网站名为"慕雪的寒舍 - 雪下了一夜",由作者"慕雪年华"创建。该网站看起来是一个个人博客,包含了各种类型的文章,例如编程学习、博客建站等。网站中的文章包含了不同的主题,如使用Python管理虚拟环境、MCP协议的理解和使用等。此外,网站顶部和底部列出了作者的一些社交链接和其他网站。', 'reasoning_content': '', 'name': '网页查看助手'}] |
从前文的日志分析中我们已经能够确定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
的调用方式。
1 | 2025-04-20 16:17:52,865 - DEBUG - Request options: {'method': 'post', 'url': '/chat/completions', 'files': None, 'json_data': {'messages': [{'role': 'system', 'content': 'According to the user\'s request, you first draw a picture and then automatically run code to download the picture and select an image operation from the given document to process the image\n\n# 知识库\n\n## 来自 [文件](doc.pdf) 的内容:\n\n```\n# Python Image Processing Tutorial: Downloading Images and Performing Flip Operations \n\nIn this tutorial, we will learn how to download images using Python and perform basic image \noperations such as flipping and rotating using the Pillow library. \n ## Prerequisites \n Before we begin, make sure you have the following libraries installed in your Python environment: \n\n- `requests`: for downloading images \n- `Pillow`: for image processing \n If you haven\'t installed these libraries yet, you can install them using pip: \n\n```bash \npip install requests Pillow \n``` \n ## Step 1: Downloading an Image \n First, we need to download an image. We will use the `requests` library to accomplish this task. \n\n``` \nimport requests \n\ndef download_image(url, filename): \n\tresponse = requests.get(url) \n\tif response.status_code == 200: \n\twith open(filename, \'wb\') as file: \n\tfile.write(response.content) \n\telse: \n\tprint(f"Error: Failed to download image from {url}") \n\n# Example usage \nimage_url = "https://example.com/image.jpg" # Replace with the URL of the image you want to \ndownload \nfilename = "downloaded_image.jpg" \ndownload_image(image_url, filename) \n``` \n ## Step 2: Opening and Displaying the Image \n Next, we will use the `Pillow` library to open and display the image we just downloaded. \n\n``` \nfrom PIL import Image \n\ndef open_and_show_image(filename): \n\timage = Image.open(filename) \n\timage.show() \n\n# Example usage \nopen_and_show_image(filename) \n``` \n ## Step 3: Flipping and Rotating the Image \n\nNow we can perform flip and rotate operations on the image. The `Pillow` library provides several \nmethods for image manipulation. \n\n``` \ndef flip_image(filename, mode=\'horizontal\'): \n\timage = Image.open(filename) \n\tif mode == \'horizontal\': \n\tflipped_image = image.transpose(Image.FLIP_LEFT_RIGHT) \n\telif mode == \'vertical\': \n\tflipped_image = image.transpose(Image.FLIP_TOP_BOTTOM) \n\telse: \n\tprint("Error: Mode should be \'horizontal\' or \'vertical\'") \n\treturn \n\tflipped_image.show() \n\treturn flipped_image \n\ndef rotate_image(filename, degrees): \n\timage = Image.open(filename) \n\trotated_image = image.rotate(degrees) \n\trotated_image.show() \n\treturn rotated_image \n\n# Example usage \nflipped_image = flip_image(filename, mode=\'horizontal\') # Horizontally flip \nflipped_image.save("flipped_horizontal.jpg") # Save the horizontally flipped image \n\nflipped_image = flip_image(filename, mode=\'vertical\') # Vertically flip \nflipped_image.save("flipped_vertical.jpg") # Save the vertically flipped image \n\nrotated_image = rotate_image(filename, 90) # Rotate by 90 degrees \nrotated_image.save("rotated_90.jpg") # Save the rotated image \n\n``` \n ## Step 4: Saving the Modified Image \n\nIn the examples above, we have seen how to save flipped and rotated images. You can use the \n`save` method to save any modified image. \n\n``` \n# Save the image \ndef save_image(image, filename): \n\timage.save(filename) \n\n# Example usage \nsave_image(flipped_image, "flipped_image.jpg") \nsave_image(rotated_image, "rotated_image.jpg") \n``` \n\nBy now, you have learned how to download images using Python and perform basic image \noperations using the Pillow library. You can extend these basics to implement more complex image \nprocessing functions as needed. \n\n```\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{"type": "function", "function": {"name": "my_image_gen", "description": "AI painting (image generation) service, input text description, and return the image URL drawn based on text information.", "parameters": [{"name": "prompt", "type": "string", "description": "Detailed description of the desired image content, in English", "required": true}]}}\n{"type": "function", "function": {"name": "code_interpreter", "description": "Python code sandbox, which can be used to execute Python code.", "parameters": [{"name": "code", "type": "string", "description": "The python code.", "required": true}]}}\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{"name": <function-name>, "arguments": <args-json-object>}\n</tool_call>'}, {'role': 'user', 'content': '画一只猫的图片'}], 'model': 'Qwen/Qwen2.5-32B-Instruct', 'seed': 652077296, 'stream': True}} |
4. The end
心血来潮通过抓包看了一下MCP客户端和服务端到底是怎么交互的,也算是学到了不少新知识,SSE协议也是第一次听说。目前Agent对整个编程行业的影响都十分巨大,咱们还是得拥抱AI,学习点Agent相关的知识,看看能不能和日常学习工作结合起来提高效率。
主要是,俺可不想被AI淘汰呐……
Retry