MCP 是怎样工作的?把模型上下文协议拆开看一遍

关于 MCP,流传最广的一句话是「AI 应用的 USB-C」。这个比喻好记,但它什么也没解释——USB-C 是个物理接口,MCP 是一套跑在 JSON-RPC 之上的应用层协议,两者唯一的共同点是「都想做统一接口」。比喻能让你记住它想干嘛,却挡住了你去看它到底怎么干的。

我平时用 Claude Code,顺手接过几个 MCP 服务器。接的过程中我发现,与其去记那些营销话术,不如把它当成一个普通网络协议来拆——就像我之前拆 Redis 的 RESP 一样:它在线上传的是什么字节、连接怎么建立、谁跟谁说话。拆完你会发现它出奇地朴素。

MCP(Model Context Protocol,模型上下文协议)是 Anthropic 在 2024 年 11 月开源的一套开放协议,规定 LLM 应用如何跟外部的工具和数据打交道。它不绑 Anthropic 自家模型,规范公开,任何人都能照着写客户端或服务器。

一、解决「M × N」问题

假设你有 M 个想接入大模型的应用(Claude、某个 IDE 插件、自己写的聊天机器人),和 N 个想被接进去的能力(GitHub、数据库、文件系统)。在没有统一约定的年代,每个应用要用上每个能力,都得单独写一套对接代码——M 个应用乘 N 个能力,就是 M × N 套各不相同的胶水代码。再加一个新工具,就得为现有的每个应用都补一遍。

左边是没有统一协议时 3 个应用各自连 3 个工具、连出 9 条交叉线的混乱图;右边是接入 MCP 后,应用和工具都只连到中间的 MCP 协议层,3+3=6 条线的清爽图

MCP 做的事,就是在中间立一个标准。应用方实现一次「MCP 客户端」,工具方实现一次「MCP 服务器」,两边都只认这套协议,于是 M × N 塌缩成 M + N。新增一个工具,只要它说 MCP,所有支持 MCP 的应用就都能用上它,谁都不用再为谁单独改代码。

这就是它最实在的价值,也是「USB-C」那个比喻唯一想表达的点——只是它没告诉你中间那根「线」里跑的是什么。

二、三个角色:Host、Client、Server

MCP 里有三个角色,名字容易混,得先掰开:

  • Host(宿主):你真正在用的那个应用,比如 Claude Code、Claude 桌面端、某个 IDE。它内置大模型,也负责管理下面的多个 Client。
  • Client(客户端):活在 Host 内部的连接器。一个 Client 只跟一个 Server 一一对应,是 1:1 的关系。Host 想连三个服务器,就在内部开三个 Client。
  • Server(服务器):一个独立的程序,对外暴露具体能力——读文件、查数据库、调 GitHub API。它可以是你机器上的一个子进程,也可以是一个远程服务。

三层架构图:左边 Host 框里装着 Client A/B/C,各自通过 JSON-RPC 与中间的三个 Server 一一对应,Server 再分别连到右边的项目目录、数据库、GitHub API 等真实数据源;图例标注实线是 JSON-RPC 消息、虚线是 Server 访问真实数据源

这里有个容易绕的点:模型本身不直接跟 Server 说话。是 Host 里的 Client 跟 Server 通信,把结果整理好再喂给模型。Server 也从不直接碰模型,它只管暴露能力、响应请求。把这层关系理顺,后面的消息流向就不会乱。

「一个 Client 对一个 Server」这条规矩也值得记一下——它意味着每条连接都是独立的、隔离的。一个服务器崩了或者行为异常,影响的是它那条连接,不会污染其它的。

三、JSON-RPC 2.0

把架构掀开,MCP 在线上传的每一条消息,都是一个 JSON-RPC 2.0 报文。没有自定义的二进制格式,就是普通的 JSON。所以它一共只有三种消息:

请求(Request)——带 id,期待对方回一条对应的响应:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": { "name": "create_issue", "arguments": { "...": "..." } }
}

响应(Response)——id 跟请求对得上,要么有 result,要么有 error,二选一:

{
  "jsonrpc": "2.0",
  "id": 3,
  "error": { "code": -32602, "message": "缺少必填参数 repo" }
}

通知(Notification)——没有 id,发完就完,对方不回:

{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }

就这三种。id 用来把请求和响应配对——因为可以同时有好几个请求在飞,靠 id 才知道哪条响应对应哪条请求。method 是方法名,MCP 把它按 分类/动作 来命名(tools/listresources/readprompts/get),看名字就知道在操作哪类东西。

通知专门用来传那种「不需要确认的事件」。最典型的就是上面那条 notifications/tools/list_changed——服务器的工具列表变了,发个通知告诉客户端「你手上的清单过期了,重新拉一遍」,仅此而已,不需要回话。

理解到这一层,MCP 就没什么神秘的了:它就是一套约定好了方法名和参数结构的 JSON-RPC。剩下要搞清的,无非是「有哪些方法」和「连接怎么建立」。

四、连接是怎么建立的:握手与能力协商

连接建立的第一件事,是一次初始化握手。这步很关键,因为客户端和服务器谁支持什么、版本对不对得上,全在这里谈妥。

Client 与 Server 的时序图:初始化阶段,Client 先发 initialize 带上协议版本和自己的能力,Server 响应自己的版本和支持的能力,Client 再发 notifications/initialized 通知;握手完成后进入正常运行阶段,走 tools/call 请求与响应;底部标注协议版本是日期串、对不上就连不上

流程是三步:

  1. 客户端发一条 initialize 请求,亮出自己支持的协议版本能力(capabilities),外加自己的名字版本号。
  2. 服务器回一条响应,同样亮出它的协议版本和它支持的能力。
  3. 客户端再发一条 notifications/initialized 通知,握手完成,进入正常运行。

initialize 请求长这样:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": { "name": "claude-code", "version": "1.0.0" }
  }
}

服务器的响应:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": {}
    },
    "serverInfo": { "name": "my-server", "version": "0.3.1" }
  }
}

这里有两个点值得留意。

一个是协议版本是个日期串,像 2025-06-18,不是 1.0 这种语义版本号。MCP 用发布日期当版本号(先后出过 2024-11-052025-03-262025-06-18 几版)。两边在握手时确认能不能在同一版本上对话,对不上就在这一步谈不拢,连接根本不会建立——这点比很多协议「先连上再说,出错再处理」要干脆。

另一个是能力协商(capability negotiation)。双方在握手时各自声明「我支持哪些功能」,之后就只在交集里活动。服务器说自己有 tools,客户端才会去调工具相关的方法;服务器没声明 prompts,客户端就不会去问提示词。那些 listChangedsubscribe 是更细的开关——比如 resources.subscribe: true 表示「我这边的资源支持订阅变更」。能力是协商出来的,不是假定出来的,这让不同实现之间能优雅地降级,而不是直接报错崩掉。

五、Server 暴露的三类原语:Tools、Resources、Prompts

握完手,正题来了:服务器到底能暴露什么?规范定义了三类原语(primitives)。很多教程会把它们并列成「三个功能」,但我觉得那样讲会漏掉最要紧的一点——它们真正的区别,是「由谁来决定用它」

三栏卡片图:Tools 工具(靛蓝,标签「模型决定调用」,方法 tools/list、tools/call,例子建 GitHub issue、跑 SQL);Resources 资源(青绿,标签「应用决定注入」,方法 resources/list、resources/read,例子文件、记录、日志,可订阅变更);Prompts 提示词(琥珀,标签「用户主动触发」,方法 prompts/list、prompts/get,例子斜杠命令 /review、/commit)

Tools(工具)——由模型决定调用。 这是让模型去「做事」的函数:建一个 issue、跑一条查询、发一封邮件。服务器用 tools/list 把工具清单连同每个工具的描述和参数 schema 交出去,模型读懂描述后,自己决定什么时候、带什么参数去调,调用走 tools/call。主动权在模型手上。

工具列表大致是这样(核心是那个 inputSchema,它用 JSON Schema 描述参数,模型靠它知道该怎么填):

{
  "name": "create_issue",
  "description": "在指定仓库创建一个 issue",
  "inputSchema": {
    "type": "object",
    "properties": {
      "repo":  { "type": "string" },
      "title": { "type": "string" },
      "body":  { "type": "string" }
    },
    "required": ["repo", "title"]
  }
}

模型决定调用时,发 tools/call

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "create_issue",
    "arguments": {
      "repo": "meiki/blog",
      "title": "图片懒加载没生效",
      "body": "首屏以下的图没加 loading=lazy"
    }
  }
}

服务器执行完,把结果放在 content 里回来(content 是个数组,因为结果可以是文本、图片等多种块):

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "已创建 issue #42" }
    ],
    "isError": false
  }
}

Resources(资源)——由应用决定注入。 这是供模型参考的只读数据:一份文件、一条数据库记录、一段日志。每个资源用一个 URI 标识,resources/list 列出来,resources/read 读内容。关键区别在于:不是模型自己去抓,而是宿主应用决定把哪些资源塞进上下文。打个比方,工具是「模型能按的按钮」,资源是「应用端摆出来给模型看的资料」。如果服务器声明了 subscribe,客户端还能订阅某个资源,等它变了收到通知。

Prompts(提示词)——由用户主动触发。 这是服务器预先准备好的模板或工作流,由用户显式选用。落到产品上,最常见的形态就是斜杠命令——你在 Claude Code 里敲 / 弹出来的那些,很多就是某个 MCP 服务器通过 prompts/list 提供、用 prompts/get 取出来的。主动权在用户。

把这三者的控制方串起来记最清楚:

Tools 模型说了算,Resources 应用说了算,Prompts 用户说了算。

这条线我觉得是理解 MCP 设计的钥匙。它不是把所有外部能力一股脑塞给模型任意调用,而是按「该由谁做主」做了切分——该模型自主的归 Tools,该应用控场的归 Resources,该用户拍板的归 Prompts。这种克制,比能力本身更能说明它想成为一个长期标准的意图。

顺带一提,原语不只服务器有,客户端也能反过来给服务器提供能力,常见三个:sampling(服务器反过来请客户端这边的模型生成一段内容,这样服务器不用自带 API key)、roots(客户端告诉服务器它能碰的文件系统边界在哪)、以及较新版本加入的 elicitation(服务器中途向用户要一条补充信息)。这几个用得没前三类多,但它解释了为什么握手时客户端也要声明自己的 capabilities——通信是双向的。

六、两种传输:stdio 与 Streamable HTTP

最后一块拼图是传输层。同样一套 JSON-RPC 消息,可以走两种通道,取决于服务器在哪儿。

左右两栏对比图:左边 stdio 跑在本地,Host 拉起 Server 子进程,走 stdin/stdout,每条消息一行 JSON、零网络零鉴权、适合本地文件和命令行工具;右边 Streamable HTTP 跑在远程,Client 用 POST 上行、Server 用 SSE 流下行,同一个 /mcp 端点收发、可跨网络、鉴权基于 OAuth 2.1

stdio——跑在本地。 服务器是 Host 直接拉起的一个子进程,双方就通过标准输入输出(stdin / stdout)通信,每条 JSON-RPC 消息占一行、用换行分隔。没有网络、没有鉴权,启动即用。本地文件、本地数据库、命令行工具这类「能力就在你自己机器上」的场景,几乎都走 stdio。我接的几个本地服务器全是这种,配起来基本就是在配置里写一条「用什么命令把它拉起来」。

Streamable HTTP——跑在远程。 服务器是个远程 HTTP 服务,客户端用 POST 把请求发到一个端点(比如 /mcp),需要流式返回时服务器用 SSE(Server-Sent Events)持续往回吐数据。这适合那种由服务方托管、多人共用的远程能力。

这里有个历史包袱值得说一句:早期规范用的是「HTTP + SSE」的双端点方案,从 2025-03-26 那一版起换成了现在的单端点 Streamable HTTP。如果你翻到比较老的教程,看到的可能是旧方案,对不上是正常的——这也侧面说明这协议还在快速变。

七、碎碎念

它确实朴素,但「朴素」不等于「成熟」。 协议本身干净,可生态还很新。我接服务器时踩到的麻烦,基本不在协议层,而在「这个第三方服务器质量怎么样、文档全不全、版本跟没跟上」。规范定得好,不保证每个实现都靠谱。

鉴权是最该当心的地方。 stdio 在本地、问题不大;但远程 HTTP 服务器意味着你在授权一个外部服务去碰你的数据和工具。基于 OAuth 2.1 的鉴权框架是后来的版本才补齐的,属于这套协议里相对年轻、也最需要你自己看清楚再点同意的部分。接一个来路不明的远程 MCP 服务器,跟随便给一个第三方应用授权没有本质区别——别因为它叫「MCP」就放松。

工具描述的质量,直接决定模型用不用得好。 因为 Tools 是模型自主调用的,那段 descriptioninputSchema 写得清不清楚,几乎等同于工具好不好用。我见过描述含糊的工具,模型要么不调、要么调错参数。这部分是服务器作者的功夫,不是协议能替你兜的。

工具一多,是有成本的。 每个连上的服务器都会把自己的工具清单注入模型的上下文。接十个服务器、几十个工具,光这份清单就占掉可观的 token,还可能让模型在「该选哪个工具」上犯迷糊。我的做法是只接当下真用得上的,不图全。

它还在快速演进。 前面那几个日期版本号、换掉的传输方案、后补的鉴权和 elicitation,都说明规范远没定型。现在写的对接代码,过段时间可能就得跟着改。把它当一个「方向明确但仍在路上」的标准看,比当成稳定地基靠谱。

小结

MCP 没有它的名号听上去那么玄。把它拆开,就是一套跑在 JSON-RPC 2.0 之上、约定好了方法名和握手流程的应用层协议:用 M + N 替掉 M × N 的对接,靠 Host / Client / Server 三层分工,靠初始化握手协商能力,再用 Tools / Resources / Prompts 三类原语按「该由谁做主」切分外部能力——模型、应用、用户各管一摊。

想看一手规范、所有方法的完整定义和最新版本,去官方文档:Model Context Protocol 规范。等我接的服务器再多些、踩过更多真实的坑,再回来更新这篇。