MCP 协议与工具接入

MCP(Model Context Protocol,模型上下文协议)是一套开放协议,用于规范 LLM 应用与外部工具之间的连接方式:应用侧实现 MCP 客户端(Client),工具侧实现 MCP 服务端(Server)。

它的直接收益是:”N 个应用配 M 个工具”所需的 N×M 段适配代码,收敛为 N+M 段。

具体来说:你想让 Agent 能查 GitHub、查数据库、发 Slack,得写三段适配代码(各一套认证、参数格式、错误处理);再写一个跑在 IDE 里的 Agent,又是三段,内容几乎一样;哪天工具那边换个实现,每个 Agent 里都得跟着改一遍——它们明明在做同一件事。

MCP 把这两个方向都解开了:换 Agent,工具侧不用动;换工具实现,应用侧不用动。两边甚至可以是不同的人、用不同的语言写的。

这篇讲这层协议长什么样:分了哪两层、有哪两种传输、一次连接要走哪三步,以及工具接进来之后怎么进到 Agent 的工具表里。

1. 三个角色:Host、Client、Server

MCP 里只有三个角色,但它们不是一个进程里的三个东西:

角色 是什么
Host 跑着 Agent 的那个程序。管连接,也决定把哪些工具交给模型
Client Host 内部的一个对象,负责跟一个 Server 通信
Server 真正提供能力的一方。一个 Server 就是一组工具/资源的提供者

最容易搞混的是 Client:它不是”客户端程序”,而是 Host 里的一个连接对象,而且跟 Server 是一对一的。

所以”Host 里有一个 Client”这个说法是错的。正确的是:Host 里有一张 Client 的表——接了 5 个 Server,就有 5 个 Client 对象,各自管一条连接。

为什么要把 Client 单独拆出来,而不是让 Host 直接跟 Server 说话?因为这样”一个 Server 出问题”的影响范围就被限制在一个 Client 里了——它的连接断了、超时了、协议对不上,另外四个 Client 不受影响。这个设计在 §7 讲启动流程时还会用到。

2. 两层:消息是一层,怎么送是另一层

MCP 不是一个协议,是两个叠在一起的协议:

  • 数据层:JSON-RPC 2.0 消息。规定请求、响应、错误各长什么样——说什么
  • 传输层:stdio 或 Streamable HTTP。规定这些消息怎么从这边到那边——怎么送

打个比方:内容是信,传输是邮路。信的内容不变,运输方式可以换。

这个分层不是纸面上的——它是可以真的换的。同一个工具实现,本机跑的时候用 stdio,部署到服务器上换成 HTTP,JSON-RPC 那一层一个字都不用改。

两边各能提供什么

协议是双向的:握手时两边都要声明自己的能力。

方向 能力 是什么
Server → Client Tools 模型可以调用的函数(会被 LLM 当成工具列表)
Resources 只读数据(文件、数据库记录),由应用决定读哪个
Prompts 预置的提示词模板,由用户显式调用
Client → Server Roots 告诉 Server——我关心的范围是这些目录
Sampling Server 反过来请 Host 去调一次模型
Elicitation Server 反过来找用户要一个输入

前三个词最容易被当成”三种类型的工具”,其实它们的区别在谁发起:

  • Tools 是模型主动的 —— 模型决定调哪个
  • Resources 是应用主动的 —— 代码决定往上下文里塞什么
  • Prompts 是用户主动的 —— 比如斜杠命令,用户点了才用

记住”发起者不同”这一条,这三个词就分得开了。

3. 只有两种标准传输

截至 2025-11-25 版规范,标准传输只有两种:stdio 和 Streamable HTTP。没有 WebSocket,没有 gRPC,也没有”自定义 TCP”。

差别只有一句话:Host 和 Server 之间怎么通信。

先说一件容易被忽略的事:传输是 Host 和 Server 之间的事,跟模型完全无关。 模型看不到这一层——它只知道有个工具叫 mcp_github_search_issues,这个工具背后是管道还是 HTTP,它既不知道也不需要知道。

stdio Streamable HTTP
通信方式 父子进程的标准输入输出(管道) HTTP 请求
Server 怎么来 Host 自己拉起子进程 Server 已经跑在某个 URL 上了
认证 不需要——同一个进程树,密钥靠环境变量传 需要——HTTP 头里带 token
服务范围 本机、这一个 Host 可以服务多个 Host、多台机器

共同点两条:都跑 JSON-RPC 2.0,Client 那边的流程一模一样(下一节那三步对两种传输都成立)。

stdio:连网络都不需要

stdio 的 Server 就是一个普通的命令行程序。Host 启动时 spawn 它,然后往它的 stdin 写请求、从它的 stdout 读响应。

没有端口要占,没有防火墙要开,没有服务发现要做。 进程起来了工具就能用,Host 退出子进程跟着结束。

跟传统的”起个服务、开个端口、Client 连上去”比,省掉的全是运维成本:

启动即用,退出即清理。 代价是这个 Server 只能被本机这一个 Host 用。

stdio 有三条 I/O 通道,职责不能混:

通道 干什么 谁看
stdin Host → Server 的 JSON-RPC 请求 Server
stdout Server → Host 的 JSON-RPC 响应 Client 解析
stderr 日志、调试信息 人

stdout 上只能有 JSON-RPC,一个字符都不能多。

在 Server 里随手写一句 print("starting..."),那行字就会跟 JSON 混在一起,Client 解析直接失败。这是写 MCP Server 最容易踩的第一个坑——要打日志,走 stderr。

Streamable HTTP:一次 POST,两种响应

Server 部署在某个 URL 上,Client 往里 POST JSON-RPC 消息。响应有两种形态:

  • application/json —— 一问一答,普通请求走这条
  • text/event-stream —— SSE,Server 要分多次往前推(进度通知、长任务)时走这条

所以请求头里的 Accept 必须两种都声明:

1
Accept: application/json, text/event-stream

只写一种,Server 想用另一种回你的时候就没得选了。

4. 一次连接的三步

两种传输之上,逻辑层的流程是同一套:

initialize → notifications/initialized → tools/list → tools/call × N

前三步整个连接只做一次,第四步重复无数次。

第一步:initialize 握手

Client 先发请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": { "name": "my-agent", "version": "1.0.0" }
}
}

Server 回:

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true },
"resources": {}
},
"serverInfo": { "name": "github-mcp-server", "version": "0.6.2" }
}
}

两边交换的是三样东西:

  • protocolVersion —— Client 说”我想用 2025-11-25”。如果 Server 不支持,它回一个自己能支持的版本,以 Server 的为准
  • capabilities —— Server 说”我提供 tools 和 resources,不提供 prompts”;Client 说”我支持 roots 和 sampling”。这是能力协商:之后双方只会调用对方声明过的东西
  • clientInfo / serverInfo —— 名字和版本,出错的时候拿来对账

握手响应回来后,Client 还要再发一条通知:

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

它没有 id。在 JSON-RPC 里,没有 id 的消息就是通知——对方不需要回。

为什么要多这一条?因为 initialize 的响应发出去时,Server 并不知道 Client 收好了没有。这条通知是一个明确的”我这边准备好了,可以开始干活”。

第二步:tools/list——把工具清单拿回来

1
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "search_issues",
"description": "在仓库里搜索 issue",
"inputSchema": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}
}
]
}
}

inputSchema 就是一份 JSON Schema——跟 LLM API 里那份是同一个东西。叫法是各家的:Claude 是 input_schema,OpenAI 是 parameters,MCP 是 inputSchema。

这里只列了 tools。如果 Server 还提供 resources 和 prompts,另有 resources/list 和 prompts/list,形状一样。

第三步:tools/call——真正调用

1
2
3
4
5
6
7
8
9
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "search_issues",
"arguments": { "query": "memory leak" }
}
}
1
2
3
4
5
6
7
8
9
10
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "找到 3 个相关 issue:..." }
],
"isError": false
}
}

两个细节值得停一下:

一、参数是 arguments,一个对象。 模型给的参数直接放进去,不用自己 json.loads。

二、结果不是字符串,是一个 content 数组。 文本、图片、音频都能放——因为工具的输出本来就不一定是纯文本。截图、图表、文件引用都要能回。这跟 tool_result 的 content 是同一个思路。

还有 isError 这个字段:

工具执行失败时,Server 回的是 "isError": true 加一段错误说明,而不是一个 JSON-RPC 错误。 道理和上一篇 §4 一样——JSON-RPC 错误是给程序看的(协议层出问题了,比如方法名写错),isError 是给模型看的(工具跑了,但没跑成)。后者模型有机会自己纠正,所以必须让它看到。

把顺序串起来,一次完整会话就是:

1
2
3
4
5
6
7
8
9
initialize                  ← 握手:版本 + 能力 + 身份
↓
notifications/initialized ← 我准备好了
↓
tools/list ← 拿工具清单
↓
tools/call ← 调工具
tools/call
tools/call ...

一个版本提示

上面这套握手,在 2026-07-28 那版规范里被整个去掉了。

那一版取消了 initialize / notifications/initialized,改成按需调用 server/discover 查能力;协议版本、客户端身份这些信息改成跟着每一个请求走(放在 _meta 里)——也就是连接不再有”状态”。同时 Roots、Sampling、Logging 三个能力被标记为废弃。

这篇按 2025-11-25 讲,原因是现在在跑的 Server 绝大多数还是这一版,而规范给废弃留了至少 12 个月的窗口。看别人的代码时如果只看到 server/discover 没看到 initialize,那就是新版的写法。

5. 请求和响应怎么配对

上面写的是”一问一答”的顺序版。真实实现里,响应是可能乱序回来的——尤其用 HTTP 并发调三个工具的时候。

Client 的做法是一张待处理表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pending = {}   # id → 等待这个响应的队列

async def request(method, params):
msg_id = next_id()
chan = asyncio.Queue()
pending[msg_id] = chan # 先把坑占上,再发
await transport.send({
"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params
})
return await chan.get() # 等这个 id 的响应

async def read_loop():
async for msg in transport.receive():
if "id" in msg: # 是响应
chan = pending.pop(msg["id"], None)
if chan:
chan.put_nowait(msg) # 按 id 派发
else: # 是通知
handle_notification(msg)

三个要点:

  • 请求发出前先把 id → 队列 记下来。 不能等响应回来才建——响应比登记快的时候,消息就丢了
  • 派发靠 id,不靠到达顺序。 谁先回来不影响谁是谁
  • 没有 id 的消息是通知,走另一条路。它可能是 Server 主动推的进度更新,也可能是它在用 Sampling 反过来请求 Host 调模型

6. 工具接进来之后:注册到同一张工具表

tools/list 拿回来的东西,接下来要做一件事:包装成本地工具,注册进 Agent 自己的工具表。

转换很轻——inputSchema 和 Claude 的 input_schema 基本一一对应,改个字段名就行。注册完之后,工具表里会同时躺着两类工具:

来源 名字
内置 ReadFile / Bash / Grep
MCP(github Server) mcp_github_search_issues
MCP(db Server) mcp_db_query
MCP(slack Server) mcp_slack_send

加前缀是为了防重名。 两个 Server 各提供一个叫 search 的工具,前缀一加就分得开了——顺便也告诉模型”这个工具来自哪个 Server”。

对 Agent 来说,这两类工具没有区别。 Agent Loop 那一层完全不知道 mcp_github_search_issues 背后是一根管道、一个子进程,还是一个大洋彼岸的 HTTP 服务——它只是拿到一个名字和一份 schema,执行,拿结果。

这正是 MCP 想要的效果:把”工具从哪来”这件事,挡在工具表以下。

工具表对外只需要一个取 schema 的方法:

1
tools = self.registry.get_all_schemas(self.protocol)

protocol 是”这份 schema 给谁用”——Claude 一套形状,别的供应商另一套。同一个工具表,为不同供应商翻译成不同格式,这个转换只在这一个地方做。

这个方法内部除了遍历和转换,还有两个跳过条件:disabled 的(被用户关掉的),以及 deferred 且还没被 discovered 的。第二个是延迟加载用的,§8 展开。

7. 配置与启动

配置差不多长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
mcp_servers:
# stdio:有 command 字段 → 启动子进程,走管道
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: "${GITHUB_TOKEN}"

# Streamable HTTP:有 url 字段 → 发 HTTP 请求
remote-tool:
url: "https://api.example.com/mcp"
headers:
Authorization: "Bearer ${API_TOKEN}"

用哪种传输,判据就一条:有 command 是 stdio,有 url 是 HTTP。 不需要另写一个 type 字段——这两个字段天然互斥,写不出”两个都有”的配置。

配置分两级,项目级覆盖用户级:

级别 位置 放什么
用户级 家目录下的全局配置 你个人常用的 Server、个人 token
项目级 项目根目录下的配置文件 只有这个项目需要的 Server

合并规则是按 Server 名字逐项覆盖:项目级里同名的那一项整个替掉用户级的,其余的用户级项保留。

这样团队可以把”这个项目要连哪个数据库”写进仓库,而每个人自己的 token 留在本地。${GITHUB_TOKEN} 这种写法是关键:配置文件进仓库,密钥不进仓库。

启动时按顺序发生的事

  1. 读两级配置,合并出最终的 Server 列表
  2. 每个 Server 看它是 command 还是 url,决定用哪种 transport
  3. 后台建立连接(不阻塞主流程)
  4. initialize 握手
  5. tools/list 拉工具清单
  6. 包装成本地工具,注册进工具表
  7. Agent Loop 正常跑——取工具表时,MCP 工具已经在里面了
  8. 模型要调某个 MCP 工具 → 转成 tools/call 发过去
  9. 结果转回 tool_result 塞回 messages

其中两处工程上的选择值得单独说。

一、连接是后台做的,启动不等它。 某个 Server 起不来(网络不通、npx 装不上、命令不存在),只打一条警告,不阻止启动——其他工具照常可用。一个 Server 挂掉,不该让整个 Agent 用不了。 这也是 §1 把 Client 拆成一对象一连接的好处:坏的只坏那一个。

二、连接是复用的。 握手和 tools/list 一个进程里只做一次,之后一直用同一条连接。不是每次调工具都重连——那样每个调用都要多两个往返。

顺带一提,这个”启动时后台连上所有 Server”的做法,主流 Coding Agent 基本都是这么干的——因为它对用户来说延迟最低:等你打完第一句话,工具早就在了。

8. 工具太多:延迟加载

接 10 个 Server 就是几百个工具,而工具定义本身就要吃掉上下文:

工具定义占的 token
80 个工具全量塞进 tools 约 15,000
只塞核心工具 + 一个检索工具 约 1,000

麻烦的地方在于,tools 是每一轮请求都要整份发出去的(上一篇 §5:API 无状态,messages 只增不减、每轮整个发一遍,tools 也在里面)。15,000 token 的固定开销乘上几十轮——活还没开始干,成本已经花掉一大截。

比钱更麻烦的是效果:几百个工具一次摆给模型,选错工具的概率明显上升。

做法是把它们延迟加载,分四步:

  1. 注册时就标记成”延迟”的——MCP 工具默认都算这一类。因为几百个工具里,一次任务真正会用到的一般就那么几个
  2. 生成工具列表时跳过它们的完整 schema,只在 system-reminder 里列个名字。模型知道”有这么个东西存在”,但不用付完整定义的 token
  3. 模型需要的时候,调一个叫 ToolSearch 的工具把完整定义拉出来
  4. Registry 返回 schema,同时把它标记为「已发现」——从下一轮起,它就跟普通工具一样出现在 tools 里了

两轮下来的样子:

  • 第 1 轮:tools 里只有 7 个核心工具 + ToolSearch,system-reminder 里挂着 74 个工具名。模型看到里面有 Prometheus 相关的名字,调 ToolSearch("prometheus");本地把它标记为已发现
  • 第 2 轮:tools 里多了一个 query_prometheus,模型这一轮就能直接调它了

代价是最坏情况多花一轮。 模型得先”搜”再”用”,多一次往返。

所以核心工具(读文件、跑命令、搜索)不该延迟——它们每轮都在用,延迟了等于每轮都多一轮,那就本末倒置了。

9. 小结

一、MCP 解决的是 N×M 的接线问题。 三个角色:Host 是程序,Client 是 Host 里的连接对象(跟 Server 一对一),Server 是能力提供方。

二、它分两层:JSON-RPC 2.0 管”说什么”,stdio / Streamable HTTP 管”怎么送”。 换传输,上层逻辑不变。

三、stdio 不需要端口和认证,代价是本机专用。 三条 I/O 通道职责不能混——stdout 上只能有 JSON-RPC。Streamable HTTP 的 Accept 要同时声明 application/json 和 text/event-stream。

四、一次连接三步:initialize → notifications/initialized → tools/list,之后 tools/call 重复 N 次。 前三步只做一次。握手协商的是版本、能力、身份三样东西。

五、MCP 工具和内置工具注册进同一张工具表,加 Server 名前缀防重名,对 Agent Loop 完全透明。连接后台建立、失败只警告——一个 Server 挂掉不该拖垮整个 Agent。

六、工具多的时候要延迟加载:默认只给名字,用 ToolSearch 按需拉完整定义。省 token,也提高选对工具的概率;代价是最坏情况多一轮。

参考

作者

wangyawei

发布于

2026-09-15

更新于

2026-09-15

许可协议