MCP 协议
第 23 课:MCP 协议
紧接第 22 课《工具调用》:上一课我们讲透了「模型怎么调用一个函数」,也埋了一个伏笔——每个宿主都要为每个工具各写一套适配,接入量是 M×N。这一课把那个伏笔展开:MCP(Model Context Protocol)如何用一套协议把 M×N 压成 M+N,Host / Client / Server 三个角色怎么分工,Resources / Prompts / Tools 三类原语怎么选,怎么把一个内网系统包成 Server,传输与鉴权怎么配,以及 2026-07-28 版为什么把协议改成无状态、改完扩容简单在哪。
② 说清 Host / Client / Server 三个角色的分工,以及一条 JSON-RPC 2.0 请求的关键字段(必填 jsonrpc / method,请求带 id;params 视方法可有;result 属响应)
③ 解释 2026-07-28 无状态化改了什么,并算出按 ttlMs 缓存 tools/list 把 1,000 次探测压到 1 次(省 999 次)
上一课我们把工具调用拆成了「定义 → 意图 → 执行 → 回填」四步。这一张先算最实际的一笔账:为什么每接一个新工具,就要为每个宿主各写一遍适配代码。
因为工具调用依赖宿主自己的私有约定——A 应用写的工具,B 应用没法直接拿来用。就像每家门店都要为每个品牌的会员卡各装一台刷卡机:5 家店 × 20 个品牌 = 100 台,谁也统一不了,每加一个品牌每店都要再加一台。所以下面把 M×N 这笔乘法,与它被压成 M+N 之后的差距算出来。
更直接的做法是「谁要用谁自己接」——每个宿主单独为每个工具写一份适配。只有 1 个宿主时它也够用,但成本随规模平方涨:从 5×20 加到 10×50,两两对接的份数从 100 涨到 500(约 5 倍——宿主翻倍、工具数又增至 2.5 倍,乘法叠加;而协议化后份数只从 25 涨到 60,约 2.4 倍);每新增一个工具,还要在全部宿主里各改一次。
| 符号 | 含义 | 例子 |
|---|---|---|
| M | 宿主(host)数量 | 5 个(IDE、聊天、工单系统…) |
| N | 工具(tool)数量 | 20 个(数据库、工单、日历…) |
| 方案 | 要写多少份 | 计算 |
|---|---|---|
| 各自对接 | 100 | M×N = 5 × 20 |
| 统一协议 | 25 | M+N = 5 + 20 |
| 规模 | M×N | M+N | 倍数 |
|---|---|---|---|
| 5 × 20 | 100 | 25 | 4.0× |
| 10 × 50 | 500 | 60 | ≈ 8.3× |
| 20 × 100 | 2,000 | 120 | ≈ 16.7× |
MCP 解决的不是模型能力问题,而是一个生态乘法:把 M×N 份点对点适配,换成 M+N 份对协议的实现。它借鉴 LSP 的成功经验——协议只规定「怎么交换 context」,不规定「交换什么业务」,所以既能接各类工具,也不会绑死在某一家。
上一张卡片算清了 M×N 这笔乘法。这一张回答:MCP 到底是什么,三个角色怎么分工。
因为一个宿主往往要同时连多个服务器,如果让应用自己记住每条连接的细节,它会越写越重,所以要把「连接」从应用里拆出来,交给一个专门的角色 Client。就像一家公司要对接多个供应商,会给每个供应商配一个专门的对接人,而不是让老板自己记所有细节。所以一次 tools/call 的请求,走的是「宿主 → 客户端 → 服务器」这条固定的路。
| 角色 | 是什么 | 数量 |
|---|---|---|
| Host | 发起连接、负责编排的 AI 应用 | 1 个宿主 |
| Client | 宿主内为每个服务器建的连接器 | = 服务器数 |
| Server | 提供 context 与能力的程序 | 2 个(如工单 + 日历) |
| 字段 | 取值 | 含义 |
|---|---|---|
| jsonrpc | "2.0" | 版本必须是 2.0 |
| method | tools/call | 要调的方法 |
| params | {name, arguments} | 传什么参数 |
| id | 1 | 请求与响应的配对号 |
| 动作 | 消息数 | 说明 |
|---|---|---|
| 请求 | 1 | 客户端 → 服务器 |
| 响应 | 1 | 服务器 → 客户端 |
| 合计 | 2 | 一次调用 2 条消息 |
| 连调 3 个工具 | 6 | 2 × 3 |
MCP 是一个「宿主—客户端—服务器」三层协议:宿主管编排、客户端管连接、服务器管能力。消息走 JSON-RPC 2.0(请求必填 jsonrpc / method,且带 id;params 视方法可有),每个响应用 id 配对——理解这条固定的路,后面所有工程问题(原语、鉴权、扩容)都会落到某一个角色身上。
上一张卡片看到 Server 通过「原语」对外提供能力。这一张把三类原语拆开,回答各自解决什么、什么时候用哪个。
因为三类原语的控制方不同,混用会让模型做出越权的事,所以要先按「谁控制」把边界划清。就像一个抽屉柜:Resources 是别人直接放进你桌面的资料(应用决定给什么)、Prompts 是你自己挑的模板卡(用户决定用哪张)、Tools 是给助手的一把钥匙(模型决定什么时候用)。所以下面用一笔 token 账,看把太多东西当 Tools 会付什么代价。
更省事的做法是「把服务器能提供的东西全写成 Tool」,反正模型都能调。但 Tool 的 schema 每次都常驻上下文:12 个能力全做成 Tool 就是 12 × 200 = 2,400 token,其中不少是只读资料,本该由应用按需注入。分出 Resources / Prompts 后,常驻的只剩 3 个 Tool = 600 token,省 1,800 token(每次请求都省)。
| 原语 | 谁控制 | 典型方法 | 场景 |
|---|---|---|---|
| Resources | 宿主应用 | resources/list、resources/read | 把资料注入 context |
| Prompts | 用户 | prompts/list、prompts/get | 斜杠命令 |
| Tools | 模型 | tools/list、tools/call | 执行函数 |
| 做法 | 暴露给模型的 tool 数 | 其余怎么给 |
|---|---|---|
| 全部当 Tool | 12 | 全部常驻 context |
| 分好类 | 3 | 其余 9 个按需注入 |
| 项 | 数值 |
|---|---|
| 每个 tool 定义(示意) | ≈ 200 token |
| 12 个 Tool | 12 × 200 = 2,400 token |
| 3 个 Tool | 3 × 200 = 600 token |
| 省下 | 1,800 token |
三类原语的分水岭是「谁控制」:Resources 应用控制、Prompts 用户控制、Tools 模型控制。选错控制方,付出的代价是 token 与安全边界,而不是功能缺失——这也是为什么「只把该给模型的做成 Tool」既是设计原则,也是成本纪律。
上一张卡片把三类原语分清了。这一张动手,把一个公司内网系统(工单查询)包成 Server。
因为服务器只需要把能力声明出来、把编排交给宿主,所以一个 server 常常只有几十行。就像给新同事做一张「工作说明卡」:写清他能做什么、要什么输入、返回什么,剩下该调谁、什么时候调,由主管来定。所以下面用四个工具把工单系统的 Server 走一遍,并算一下工作量。
| 工具名 | 输入 | 作用 |
|---|---|---|
| get_ticket | id | 查一张工单 |
| list_tickets | status、limit | 按状态列工单 |
| create_ticket | title、desc | 建工单 |
| update_status | id、status | 改状态 |
| 做法 | 工作量 |
|---|---|
| 包 1 个 Server:4 个工具 × ≈ 30 行 | ≈ 120 行 |
| 为 5 个宿主各写一套:4 × 5 = 20 个适配函数 | 20 × 30 = 600 行 |
| 项 | 数值 |
|---|---|
| 每个 tool 定义(示意) | ≈ 200 token |
| 4 个工具 | 4 × 200 = 800 token |
| 发送时机 | 每次请求都带进 context |
包一个 Server 的套路只有三步:选好对外暴露的 tool、给每个 tool 写清描述与 schema、把真实系统的调用接进去。宿主那边一行都不用改,这就是 M+N 的好处——接入成本从「每个宿主一套」变成「一个 Server 一份」。
上一张卡片把工单 Server 跑起来了。这一张处理「谁能调什么」,也就是传输与鉴权的配套选择。
因为本地与远程的信任边界不同,所以传输方式与鉴权强度要一起选,不能只看方便。就像同一栋楼的门禁:本楼员工刷工牌就进(本地 stdio 直接继承宿主进程的权限),外来访客要登记换证(远程 HTTP 走 OAuth token)。所以下面用一笔账,比较两种传输的成本。
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 服务器位置 | 本机子进程 | 远程服务 |
| 信任边界 | 继承宿主权限 | 需独立鉴权 |
| 常见场景 | 文件、Git | 日历、SaaS |
| 传输 | 每次调用的鉴权次数 | 1,000 次调用 |
|---|---|---|
| stdio | 0(继承进程权限) | 0 |
| Streamable HTTP | 1(校验 token) | 1,000 |
| 项 | 数值 |
|---|---|
| stdio 的网络往返 | 0 次 |
| HTTP 每请求往返 | 1 次 |
| 1,000 次调用多出 | 1,000 次 |
| 权限最小化 | 只授予该工具必需的范围 |
传输与鉴权是一对:本地 stdio 靠进程权限、几乎零额外成本;远程 Streamable HTTP 必须配 OAuth token、最小权限与用户同意。工具即任意代码执行,这一层不能省——描述不可全信,调用前必须取得用户同意。
上一张卡片解决了「谁能调什么」。这一张看协议自己的演进:为什么要无状态化。
因为有状态要求请求必须回到原来那台实例、状态还要外置到共享存储,所以实例一多,扩容就卡在这两件事上。就像一家店要求「必须找上次接待你的那个店员」:人一多,排班和交接全乱了;改成「谁都能接」,才排得开班。所以下面用一笔账对比两种部署方式,并看缓存能省多少探测。
更直接的做法是「保留会话、用粘性路由把请求钉回原实例」。单实例时没问题,但实例从 1 台加到 10 台,就要求负载均衡记住每个会话落在哪台、并把会话状态外置到共享存储——扩容从「加机器」变成「迁会话」。无状态化把这两条约束都去掉,所以加实例不再需要迁移。
| 约束 | 含义 | 后果 |
|---|---|---|
| 粘性路由 | 请求必须回到原实例 | 负载不均、扩容要迁会话 |
| 共享会话存储 | 状态外置到统一存储 | 多一层组件与故障点 |
| 项 | 有状态 | 无状态(2026-07-28) |
|---|---|---|
| 路由依据 | 会话 ID(粘性) | Mcp-Method / Mcp-Name 头 |
| 负载均衡 | 需粘性 | 普通轮询即可 |
| tools/list | 每次会话拉取 | 可按 ttlMs 缓存(cache) |
| 项 | 每次调用都拉 | 按 ttlMs 缓存(cache) |
|---|---|---|
| 1,000 次工具调用 | 1,000 次额外 tools/list | 1 次 |
| 额外往返占比 | 每次多 1 次 | 几乎为 0 |
无状态化不是功能缩水,而是把可扩展性从应用层挪回协议层:协议无状态、应用仍可有状态。配套三件事同向——请求自带路由头(Mcp-Method / Mcp-Name)、列表结果可缓存(ttlMs)、正式弃用政策。注意:roots / sampling / logging 在 2026-07-28 版被弃用,其中 Sampling(客户端的「服务器反过来请客户端调模型」能力)官方建议迁移到直接对接 LLM provider API;原先「服务端发起请求」这类机制则改由 Multi Round-Trip Requests(MRTR)承载。