从抓包看 MCP:AI 工具调用背后的通信机制

从抓包看 MCP:AI 工具调用背后的通信机制

TL;DR

通过抓包分析,我们清晰地了解了 MCP 通信的全过程:从建立 SSE 连接、三步初始化、工具调用操作到最终的连接终止。可以看出,MCP 基于简单的 SSE 协议搭建了一个功能强大的工具调用框架,使 AI 代理能够便捷地调用外部工具完成复杂任务。

相比传统的接口调用方式,MCP 更加灵活,能够自动适应不同的工具集,让 AI 代理 " 即插即用 " 地使用各种服务能力,这也是其设计的精妙之处。

当然,MCP 也并不是完美的,作为一个新兴的协议,它仍然在不断发展中。未来可能会有更多的功能和特性被添加进来,以满足更复杂的需求。

背景

MCP 支持两种标准的传输实现:标准输入/输出(stdio)和 Server-Sent Event(下称 SSE)。stdio 基于命令行工具,多用于本地集成,通过进程通信来实现;SSE 基于客户端和服务器的网络通信,用于跨设备网络的通信场景。

既然是用抓包来分析,我们就要选择使用 SSE 传输 MCP server,然后通过工具进行网络抓包分析。在抓包分析之前,我们必要对 SSE 协议进行简单的了解。

SSE 协议

SSE 协议 是一种服务器推送技术,使客户端能够通过 HTTP 连接从服务器自动接受更新,通常用于服务器向客户端发送消息更新或者连续的数据流(流信息 streaming)。

本质上,HTTP 协议是无法实现主动推送消息的,除非服务端“通知”客户端接下来发送的是流信息。因此客户端便不会断开该连接,并持续从该连接上接收数据流。

看到这里你是否想到了 WebSocket 协议,二者看起来都是客户端与服务端建立连接,然后服务端向客户端推送数据。看似相同,实际差别还挺大:

  • SSE 是基于 HTTP 的轻量级协议;WebSocket 是独立的协议。
  • SSE 是基于 HTTP 请求 Accept: text/event-stream;WebSocket 借助 HTTP 升级协议 Upgrade: websocket,之后使用独立协议。
  • SSE 是伪双工,只支持服务端到客户端的单向通信,客户端到服务端的通信还需要另外发送 HTTP 请求进行;WebSocket 是全双工的双向通信。
  • SSE 简单、轻量,适合单向低频推送;WebSocket 复杂度高、实时性强,适合双向高频交互。

从上面的对比不难看出 MCP 选择 SSE 作为网络传输协议的原因了。

了解了 SSE 协议之后,我们就可以开始了。

环境

  • 抓包工具:Proxyman ,并安装 CA 证书,方便处理 HTTPS 的请求。
  • AI 应用:VSCode Insiders,安装 Github Copilot 插件并开启 Agent 模式。
  • MCP Server:使用 上一篇文章 中的 Spring REST API 示例

配置 MCP Server

settings.json 中添加 MCP Server 配置,为了能够使用 Proxyman 的 HTTP Proxy 在 /etc/hosts 中添加 127.0.0.1 nio.local

{
  "mcp": {
    "servers": {
      "spring-ai-mcp-sample": {
        "type": "sse",
        "url": "http://nio.local:8080/sse"
      }
    }
  }
}

添加好之后就可以启动 MCP Client 连接 Server 了。

MCP 通信

下面我们将通过抓包分析,详细了解 MCP 通信的完整生命周期,包括建立连接、初始化、操作和终止四个阶段。"

当我们的 VSCode 成功连接到 MCP Server,此时从 Proxyman 已经可以看到多条通信了。

建立连接

由于不确定 Server 支持哪种方法,MCP Client 会同时发送 GETPOST 请求到我们配置的 Server 地址,尝试建立连接。请求中的 Accepttext/event-stream,说明是与 Server 尝试进行 SSE 通信。

这里配置的 Server 仅支持通过 GET 方式建立 SSE 通信,POST 请求收到 404 响应。而 GET请求的响应中,Server 端回传了如下信息:

  • 会话 id:3e19fbcd-51f4-4784-9f63-538c9a203859
  • 事件 eventendpoint 表示数据的内容,也就是后续客户端 Client 与 Server 单向通信的端点。
  • 数据 data/mcp/messages?sessionId=3e19fbcd-51f4-4784-9f63-538c9a203859,其中 /mcp/messages 是由服务侧配置的 spring.ai.mcp.server.sse-message-endpoint: /mcp/messages
id:3e19fbcd-51f4-4784-9f63-538c9a203859
event:endpoint
data:/mcp/messages?sessionId=3e19fbcd-51f4-4784-9f63-538c9a203859

这个 HTTP 连接作为后续 Server 向 Client 推送流信息的通道,所以在截图中我们看到了其他的流信息。此时 MCP Client 与 Server 连接的声明周期就开始了:

  • 初始化
  • 操作
  • 终止

初始化

初始化阶段必须是客户端与服务器之间的首次交互,这个过程有点类似 TCP 的三次握手

Client 发起初始化请求

从 Server 接收到后续的通信端点后,Client 会发送 initialize 请求进行初始化,上报信息和功能协商。

  • protocolVersion 协议的版本
  • capabilities 功能支持:listChanged 表示支持列表变更通知
  • clientInfo 客户端信息
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "Visual Studio Code - Insiders",
      "version": "1.100.0-insider"
    }
  }
}

Server 响应初始化请求

同样 Server 也回传了流信息

  • 相同的会话 id
  • 事件类型 message
  • 事件数据
id:3e19fbcd-51f4-4784-9f63-538c9a203859
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"webmvc-mcp-server","version":"1.0.0"}}}

在事件的数据部分,Server 也提供了与请求类似的内容(在下文中将直接展示流信息中的数据部分):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "logging": {},
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "webmvc-mcp-server",
      "version": "1.0.0"
    }
  }
}

初始化完成

在完成与 Server 端的信息交换,并协商(如版本兼容、功能支持)成功后,Client 发送请求完成初始化。

{
  "method": "notifications/initialized",
  "jsonrpc": "2.0"
}

这一次 Server 并不会有任何响应,像是 TCP 握手时客户端发送了 ACK 后,服务端不会进行任何处理一样。

操作

获取 tool 列表

完成初始化后,Client 发送请求获取 Server 支持的 tool 列表。

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": {}
}

服务端通过 SSE 连接回传 tool 列表,我们使用的示例 Server 中包含了 4 个 tool。在响应内容包含了如 tool 名字、输入 schema 参数说明等信息。客户端收到这个响应后,会在本地缓存 tool 列表避免频繁的请求。只有当 Server 端更新了列表并通知 Client 后才会更新缓存内容。

篇幅原因,没有全部展示列表内容。

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "addUser",
        "description": "Add a new user",
        "inputSchema": {
          "type": "object",
          "properties": {
            "arg0": {
              "type": "object",
              "properties": {
                "email": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                }
              },
              "required": [
                "email",
                "name"
              ],
              "description": "user to add"
            }
          },
          "required": [
            "arg0"
          ],
          "additionalProperties": false
        }
      },
     //...
    ]
  }
}

有了 tool 列表之后,我们便可以尝试让 Copilot 为了执行任务了。在 Copilot Agent 模式下输入和上次一样的任务:

First, help me check the user list to see if there is a user named Carson. If not, add a new user: Carson carson@gmail.com; then check the list again to see if the new user was added successfully. Finally, say hello to Carson.

先来看执行结果。

在我发出任务请求后,VSCode 经过一通分析决定一次执行几个 tool 来完成任务。这里我使用的是 GPT-4o 的模型,没有任何推理过程的展示。如果不是展开了工具的执行结果,最终能看到的只有最后的一句话。

如果切换到 Claude 3.7 Sonnet 模型,执行时会加入推理,整个流程会清晰很多。

执行

回到 Proxyman 查看抓取的请求。

  1. VScode 先请求 Copilot Server 时传输的请求内容比较长。以 GPT-4o 模型为例,请求大小为 49.7 KB,响应 1.34 KB

    请求中包含了:

    • 一段非常长的系统 Prompt,有兴趣的可以参考开发者整理的 GitHub Copilot Agent 官方 Prompt
    • 可用的 tool 列表,包括 VSCode 官方提供的系统 tool 以及配置的 MCP Server 提供的 tool

    在响应中包含了经过分析任务后决定要调用的 tool:

    {
      "choices": [
        {
          "index": 0,
          "delta": {
            "content": null,
            "role": "assistant",
            "tool_calls": [
              {
                "function": {
                  "arguments": "",
                  "name": "bb7_getUsers"
                },
                "id": "call_nL7ToTNvrfLwUPYoqtUH8Yx3",
                "index": 0,
                "type": "function"
              }
            ]
          }
        }
      ],
      "created": 1745649196,
      "id": "chatcmpl-BQTO863fJsOBHD4tU1LN3AEk5Uuo2",
      "model": "gpt-4o-2024-11-20",
      "system_fingerprint": "fp_ee1d74bde0"
    }
    
  2. VSCode 根据响应的内容,调用 MCP Tool。

    //http://nio.local:8080/mcp/messages?sessionId=3e19fbcd-51f4-4784-9f63-538c9a203859
    {
      "jsonrpc": "2.0",
      "id": 3,
      "method": "tools/call",
      "params": {
        "name": "getUsers",
        "arguments": {}
      }
    }
    

    MCP Server 在 SSE 连接中回传 tool 的调用结果。

    {
      "jsonrpc": "2.0",
      "id": 3,
      "result": {
        "content": [
          {
            "type": "text",
            "text": "[{\"name\":\"John\",\"email\":\"john@example.com\"},{\"name\":\"Jane\",\"email\":\"jane@example.com\"}]"
          }
        ],
        "isError": false
      }
    }
    

    紧接着 VSCode 将调用结果发送给 Copilot Server 进行处理,然后又得到一个要调用的 tool,以及需要提供的参数。

  3. 如此往复,直到最终完成任务的执行。在最右一个发送给 Copilot Server 的请求中,可以看到这个任务执行过程中所有调用的 tool 请求和响应的列表。也就是说,每次调用模型时,都会带上此前调用的所有 tool 请求和响应,因此请求的 size 也是逐渐变大的。

终止

终止操作就简单了,对于 SSE 传输类型的 MCP 交互来说,就是断开相关的 HTTP 连接。

(转载本站文章请注明作者和出处乱世浮生,请勿用于任何商业用途)

comments powered by Disqus