Skip to content

第 5 章 MCP 协议 ​

函数调用的机制稳定了,接入却全靠手工:工具定义、执行器、参数校验和错误回填,每接一个系统就要重做一遍,今天接门店系统,明天接认证方式不同的快递接口,后天接另一套 HR 系统;一家公司内部是这样,放到整个行业,应用的种类乘以工具源的种类,集成代码的总量就是一个乘积。模型上下文协议(Model Context Protocol,MCP)就是为消掉这个乘积提出的:2024 年 11 月 25 日,Anthropic 把它作为开放协议开源,每个应用只需实现一次协议客户端,每个工具源只需实现一次协议服务器,接入的工作量从 N 乘 M 份点对点代码减少为 N 加 M 份标准实现;此后两年它逐渐成为行业默认的接入接口,OpenAI 和 Google 先后宣布采纳,仅官方注册表收录的服务器就接近两千个(见 5.1),Claude Desktop、Cursor、VS Code 等宿主应用原生支持,本书后面把工具、数据、智能体连接起来的章节,底层用的都是它。5.1 算清 N 乘 M 的集成成本有多大,说明标准接入为什么划算;5.2 介绍协议的整体结构,包括三种能力原语和三层角色;5.3 动手实践,用官方 Python SDK 分别写服务器和客户端,把 stdio 和 streamable HTTP 两种传输都实际跑通,再把第 4 章的回路接到 MCP 服务器上,可以看到客户端代码几乎不用改;5.4 讲协议的进阶能力、授权边界,以及几个刚被废弃的特性,说明一份快速演进的协议是怎么取舍的。

5.1 MCP 解决什么问题,N 乘 M 到 N 加 M ​

接一个门店系统需要准备的东西,4.2 已经全部出现过:一份 get_daily_revenue 的工具定义,一个 TOOLBOX 字典,一段执行与回填的逻辑,还有 4.4 的整套参数校验。

这套代码同时依赖两个东西:一是 OpenAI 的消息协议,二是门店系统自己的接口约定。数据源一换,后者就整个作废;换一个应用来接门店系统,前者也得重新写一遍。接入代码既不能跟着工具复用,也不能跟着应用复用。

放到整个行业来看,问题更明显。假设市面上有 N 个想做智能体的应用,例如 IDE 插件、客服工作台、数据分析工具和桌面助手;还有 M 个值得接入的工具和数据源,例如数据库、代码仓库、工单系统、支付系统和日历。点对点集成需要写 N 乘 M 份连接代码,N 和 M 只要各自增加一点,乘积就会快速增大。

接入过的团队都知道,麻烦不只是数量多。这 N 乘 M 份代码几乎没有两份相同:各家的认证体系不同,错误码格式各自定义,分页约定互不一致,每接一家,集成方都要把同样的问题重新处理一遍。工具源一旦升级接口版本,所有接入它的应用都要修改代码,这份维护工作量同样是乘积。

有人可能会想,能不能由最有影响力的应用厂商定一套接口标准,其他各家厂商跟着接入?这条路在历史上就没走通过。工具源没有理由把分发渠道交给某一家应用,尤其当这家应用本身就是竞争对手;应用厂商之间也很难达成一致,标准掌握在谁手里,谁就能影响整个生态的方向。一套标准要被各方接受,通常需要一个中立的技术协议,只规定消息格式和交互规则,不偏向任何一方。网站之间交换内容靠 HTTP,数据库查询靠 SQL,这两个标准就是这样被各方接受的,MCP 想在智能体接入领域起同样的作用。

在微软提出语言服务器协议(Language Server Protocol,LSP)之前,编程语言和编辑器之间也存在同样的乘积问题:每种语言都要给每款编辑器各写一个插件。LSP 把语言支持放进一个独立进程,用一套消息协议与编辑器通信。这样,语言服务只需实现一次,就可以被所有编辑器共用。

MCP 官方规范在开篇说明中承认,设计思路借鉴了 LSP(原文措辞是 takes some inspiration)。两者的区别在于标准化的对象:LSP 标准化的是语言能力,MCP 标准化的是工具和数据源如何接入。

了解 MCP 目前的状况,看两个方面就够了。一是出身:2024 年 11 月 25 日随 Anthropic 的公告开源,规范、SDK 和参考服务器同时发布。二是采纳范围:OpenAI 在 2025 年 3 月宣布采纳,Google DeepMind 在 4 月跟进,此后主流宿主应用陆续原生支持。官方注册表在 2025 年 9 月上线,几个月内收录的服务器接近两千个,两套官方 SDK 的月下载量接近一亿次。

相互竞争的多方能同时接受同一份协议,说明 N 加 M 对各方都有利:工具源多了一条零成本的分发渠道,应用方直接获得现成的工具生态。谁先完成标准化,谁的生态就先发展起来。

四个应用对接四个工具源,点对点需要十六条连线,加一层协议后只需要八条。

图 5-1 点对点与标准接入:N×M 份连线变 N+M 份

标准接入的第一项好处是少写代码:N 加 M 取代 N 乘 M,这个换算前面已经算过。工具源只需写一次服务器,Claude Desktop、Cursor、VS Code 和自研应用都能使用,工具作者不必为每个宿主重写插件。4.1 节那种手写工具定义的工作,从每个应用各做一遍,变成工具源只做一次,写好的接入第一次能在不同应用之间复用。

工具发现机制也内置在协议里:客户端发一个 tools/list 请求,就能取回服务器全部能力的名称、描述和参数 schema,不需要查文档,也不需要手工配置。

远程接入的安全问题,协议用一套 OAuth 授权流程来管,权限边界怎么划、凭据怎么管,协议层都有对应的规定,这部分在 5.4 展开。部署上的耦合也随之解开:服务器可以部署在数据源旁边,既可以是本机的子进程,也可以是数据源所在机房的远程服务。应用只需要知道它的地址,不用把对方的 SDK 引入自己的部署单元。

但信任问题,MCP 无法省掉。它标准化的只是技术接入,N 乘 M 份实现代码确实不用再写。但每个应用对每个服务器,仍然要单独判断一次授权和权限范围是否可信。这些决定依然要做 N 乘 M 次,只是内容从写代码变成了做决定,5.4 的授权与同意流程就是为此设计的。

工具的维护权也回到最了解业务口径的数据团队手上。点对点时代,门店数据团队最清楚营业额口径、缓存策略和错误码的含义,但这些知识只能通过接口文档传递给各个接入方,接入方各自理解一遍,理解的过程中容易出现偏差。工具收进 MCP 服务器后,数据团队直接维护服务器,口径和策略都封装在实现里,应用消费的只是接口,知识随代码一起传递,不再靠文档传递。工具清单升级后,客户端重新拉取一次 tools/list 就能得到最新版本,协议里还有专门的清单变更通知机制。

还有一项收益不太明显:与模型厂商的耦合也松开了。第 4 章讲过各家模型接口之间的差异。工具定义直接写在应用里时,模型换了厂商、接口换了写法,定义和执行代码都得重新适配。工具定义放进 MCP 服务器后,定义的格式由协议规定,任何客户端取到的都是同一套 JSON Schema。工具源和模型厂商这两个各自会变的因素,就此互不牵连,任何一边的变化都不会影响另一边。

还要说明边界,避免期望过高:接入 MCP 不等于模型能自动发现所有接口。客户端只能使用自己连接的那几台服务器,模型能说出某个 REST 端点,靠的是训练记忆,不是自动扫描的结果。能力边界始终是:连接了哪些服务器,就有哪些能力。反过来,也不是所有工具都值得做成 MCP 服务器:单个应用自用的私有工具,用第 4 章的函数调用就够了;需要跨应用、跨宿主分发,让同一份定义被多个客户端复用时,才值得做成服务器。

当前的 2026-07-28 版本是一次大改:协议级会话、initialize 握手、ping 等一批旧机制从规范中移除,sampling、roots 等特性被标为 Deprecated(已废弃),网上大量照着 2025 年旧版规范写的教程已经和现在的规范对不上。这些名词此刻不必记:协议级会话和 initialize 握手是什么、为什么被移除,5.2 会说明;sampling、roots 是什么、为什么被废弃,5.4 的废弃清单里再讲。

本章涉及的每处协议细节,一律以 2026-07-28 版为依据,时效标注截至 2026-08,需要最新口径,请查官方规范网站。

5.2 三原语与三层架构 ​

协议的整体结构就是两条线:参与方这条线把角色分成宿主、客户端和服务器三层,说清各自负责什么;能力这条线把服务器提供的内容归为工具、资源和提示三种原语,说清每种长什么样。两条线理清之后,5.3 里的每一行代码都能在协议中找到对应的位置。

图 5-2 MCP 三层角色与隔离边界:宿主掌管模型与完整对话历史,客户端严格一对一,服务器只见切片、彼此不可见

协议把参与方分成三层:宿主(Host)、客户端(Client)和服务器(Server)。宿主是面向用户的应用,Claude Desktop、Cursor 这类成品是宿主,自己开发的智能体应用同样是宿主。规范给宿主列了一系列职责,归纳起来是四件事:创建并管理多个客户端实例,决定连接哪些服务器;管理连接权限和生命周期;落实安全策略和用户同意流程,数据能不能给、工具能不能调,最终由宿主决定;统筹模型和上下文,让多个服务器的能力进入同一次对话。

完整的对话历史只保存在宿主这一层。这份历史会随每个请求进入上下文,按 token 计费,多个服务器的工具定义也随请求一起发送,第 1 章算过的那项成本在这里同样成立:接入的服务器越多,每一轮的固定开销就越大。第 7 章讲上下文裁剪,要解决的就是这类膨胀。

客户端是宿主内部的连接器,协议规定一个客户端只对应一个服务器,严格一对一。宿主要同时连接三个服务器,就要创建三个客户端实例,彼此隔离。客户端的工作基本在协议层:双向转发消息,在每个请求上附带协议版本和能力声明,维护与服务器之间的安全边界。应用开发者平时几乎感觉不到客户端的存在,SDK 已经封装好了,但排查问题时,需要知道这一层在哪里。

服务器是能力的提供方,用协议规定的三种原语提供工具和数据。部署方式很灵活:可以是宿主启动的本地子进程,也可以是互联网上的远程服务。在设计原则上,规范对服务器的要求很低。官方的原话是,服务器应当极其容易构建、高度可组合,每个服务器只管一类能力,所有复杂的编排都交给宿主。

规范的设计原则明确规定:服务器不得读取完整对话,也不得接触其他服务器的信息。全部对话历史都保留在宿主中,每次调用给服务器多少上下文由宿主决定,跨服务器的信息传递必须经过宿主。这条隔离原则限定了数据暴露的上限:一个服务器能接触到的,只有宿主主动提供给它的那部分,多个服务器组合使用时的泄露范围因此缩小。

在三层架构之外,2026-07-28 版对协议的运行方式做了根本性的改变:从有状态改为完全无状态。这一点恰好与网上大量旧教程的说法相反。旧版本(2025-11-25 及更早)里,通过一次 initialize 握手建立连接,客户端和服务器互相报告身份、版本和能力,服务器发出一个会话 ID,之后所有请求都挂在这个会话上,断线后还要靠会话恢复机制。

新版把这套机制全部删除。每个请求自己携带协议版本和客户端能力,放在请求体的 _meta 字段里。服务器不再保存会话状态,前后两个请求完全可以由负载均衡器后面的不同机器处理。客户端想提前了解服务器支持哪些协议版本和能力,可以调用可选的 server/discover;也可以什么都不做直接发请求,在每个请求里现场完成版本协商。

这份随请求携带的版本和能力声明具体是什么样子?以一条 tools/call 请求为例,看一下它的正文(只列出相关字段,规范原文见参考资料):

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_daily_revenue",
    "arguments": { "store_id": "ST001", "date": "2026-08-30" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

_meta 里那两个以 io.modelcontextprotocol/ 开头的键是保留键。protocolVersion 声明这条请求使用哪个版本的协议,clientCapabilities 声明客户端具备哪些能力;两者如果是空对象,表示这条请求不需要任何特殊能力。这两个键每条请求都必须带上,缺少任何一个,服务器都不会去猜测,而是直接报参数无效(错误码 -32602),拒绝这条请求。elicitation(服务器向用户请求输入的特性,5.4 展开)这类特性的声明也放在这个 _meta 里,键名沿用同一前缀。

新旧服务器混合存在的环境里,server/discover 还可以用作兼容性探测:如果对面是 2025-11-25 时代的旧版服务器,它不认识这个方法,一调用就能发现版本差异。新版本还把版本不匹配定义成明确的错误 UnsupportedProtocolVersionError,客户端不需要再靠猜测判断。

从需要维持会话,到完全无状态,部署负担减轻了很多:远程服务器可以放心地水平扩展,不再需要处理会话粘连。

三种原语中的第一种是工具(Tools),即可以由模型决定何时调用的函数。实际执行仍然在工程侧,这就是第 4 章讲的函数调用,get_daily_revenue 就是一个标准的 MCP 工具。涉及两个方法:tools/list 获取工具清单,每项包含名称、描述和 inputSchema,这份 schema 与第 4 章见过的是同一种 JSON Schema;tools/call 带参数执行并返回结果。是否调用、何时调用、参数怎么填,都由模型决定,因此规范把它归为模型控制的原语。

第二种原语是资源(Resources),即供上层读取的数据,每项都对应一个 URI,例如 stores://list、file:///reports/2026-08.csv。读取使用 resources/read;带模板的资源,例如 stores://{store_id}/profile,可以按参数展开成具体的 URI。

资源的内容会进入应用的上下文,什么时候读由应用决定,因此它是应用控制的原语。工具和资源的界线要分清:工具是动作,执行后要么产生副作用,要么产出计算结果;资源是只读数据,读取不会产生副作用,读一百遍得到的仍然是同一份。

常见的划分思路是:查询类的只读接口归资源,操作类的接口归工具。资源还支持订阅变更:客户端打开一个 subscriptions/listen 长连接,数据一有变化,服务器就主动推送通知。这是 2026-07-28 版用来取代旧版资源订阅机制的新方式。

提示(Prompts)是模板化的消息。服务器把常见任务的提示词封装成带参数的模板,用户在宿主界面里通过斜杠命令等方式选用。交互使用 prompts/list 和 prompts/get 两个方法:用户选定后,宿主收集参数,取回模板并填入参数,就得到一段现成的消息。提示由用户主动选用,属于用户控制的原语。

三种原语的区别不在形式,而在控制权归谁:工具归模型,资源归应用,提示归用户。同一项能力该用哪种原语提供,先问一句:谁有资格触发它。三种原语都由服务器提供;elicitation 不同,它的机制在客户端:服务器执行到一半需要用户补充信息时,只能通过这套机制向用户请求。这套机制涉及 2026-07-28 版一个重要的新模式,这里先不展开,5.4 连同报文样例一起讲。

能力协商,就是让两端事先说清各自支持什么。服务器在能力声明中说明自己提供 tools、resources 和 prompts 中的哪几项,客户端通过每个请求的 _meta 说明自己支持 elicitation 等特性;双方只发送对方声明支持的内容。把能力声明放进每个请求,与无状态化的设计是一致的,这正是 2026-07-28 版的做法。

规范还有一条建议:服务器在 tools/list 里按稳定的顺序返回工具,客户端据此可以放心缓存清单,前缀缓存的命中率也随之提高,节省的是实际的 token 费用。

5.3 手写 MCP 服务器与客户端,stdio 与 streamable HTTP ​

动手写协议代码之前,先准备环境。本章使用官方 Python SDK,包名是 mcp,安装命令如下,要求 Python 3.10 或更高版本。

bash
pip install "mcp[cli]"

2.x 版 SDK 是为 2026-07-28 版规范重写的,pip install mcp 默认安装的就是它;截至 2026-08,PyPI 上的最新版本是 2.1.1。1.x 老版本已经转入维护分支,只接收关键修复;老项目需要锁定版本时,可以给版本范围设一个上界,写成 mcp>=1.28,<2。下面的代码全部在 2.1.0 上实际验证过,2.x 内的补丁版本升级不影响本章内容。

案例沿用第 4 章的那家连锁品牌,把门店服务做成一个 MCP 服务器:一个工具、一个资源、一个提示,三种原语各写一遍。

python
from mcp.server import MCPServer

mcp = MCPServer("连锁门店服务")

@mcp.tool()
def get_daily_revenue(store_id: str, date: str) -> dict:
    """查询指定门店指定日期的营业额,单位为元。仅支持查询单个门店单天数据。"""
    fake = {"ST001": 12850.5, "ST002": 9320.0, "ST003": 15704.2}
    if store_id not in fake:
        raise ValueError(f"门店 {store_id} 不存在")
    return {"store_id": store_id, "date": date, "revenue_yuan": fake[store_id]}

@mcp.resource("stores://list")
def list_stores() -> str:
    """全部门店的编号清单。"""
    return "ST001,ST002,ST003"

@mcp.prompt()
def compare_revenue(date: str) -> str:
    """生成对比三家门店某日营业额的分析请求。"""
    return f"请对比三家门店 {date} 的营业额并指出最高的一家。"

if __name__ == "__main__":
    mcp.run()

把这段代码和 4.1 节对照,可以看出手写工作大幅减少。4.1 里把函数签名转成 JSON Schema 的转换器要手写,完整的工具定义要手工拼装;到这里,一个 @mcp.tool() 装饰器就全部完成,类型注解直接作为 inputSchema,docstring 直接作为 description,转换由 SDK 完成。描述和 schema 依然随 tools/list 原样送到客户端,第 4 章讲的描述写法和参数设计原则一条都没有过时,只是不再需要手工转写。

资源用 @mcp.resource 绑定 URI,提示用 @mcp.prompt 注册,协议定义同样自动生成。mcp.run() 不传参数时,默认使用 stdio 传输。

两种标准传输里,第一种是 stdio,做法是把服务器作为客户端的子进程启动。JSON-RPC(一种用 JSON 编码方法调用的远程调用协议)消息按每行一条的规则写入子进程的标准输入,响应再从标准输出读回。这个设计带来两点后果。第一,凭证从环境变量获取:客户端把 env 放进启动参数,服务器进程读取 os.environ,本地场景既够用又简单。第二,日志只能输出到标准错误流:协议消息占用整个标准输出,混进一行日志就会导致对端解析失败,这也是 2026-07-28 版废弃日志特性后官方给出的迁移方向之一。

客户端的代码也很短。

python
import asyncio

from mcp import Client, StdioServerParameters

# 先把上一段的服务器代码保存为 server.py,与这份客户端放在同一目录
server = StdioServerParameters(command="python3", args=["server.py"])

async def main() -> None:
    async with Client(server) as client:
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        result = await client.call_tool(
            "get_daily_revenue", {"store_id": "ST003", "date": "2026-08-24"})
        for block in result.content:
            print("text:", getattr(block, "text", None))
        resources = await client.list_resources()
        print("resources:", [str(r.uri) for r in resources.resources])
        content = await client.read_resource("stores://list")
        print("read:", [getattr(c, "text", None) for c in content.contents])
        prompts = await client.list_prompts()
        print("prompts:", [p.name for p in prompts.prompts])

asyncio.run(main())

StdioServerParameters 描述怎么启动子进程,包括命令、参数和环境变量三项;Client 接收这份描述,在进入 async with 时完成连接。代码里的五次调用分别对应协议的五个方法,一次全部执行了一遍。实测输出如下。

text
tools: ['get_daily_revenue']
text: {
  "store_id": "ST003",
  "date": "2026-08-24",
  "revenue_yuan": 15704.2
}
resources: ['stores://list']
read: ['ST001,ST002,ST003']
prompts: ['compare_revenue']

把门店号换成不存在的 ST999,协议层依然正常:工具抛出的异常送到服务器端 SDK,SDK 捕获后,照常返回一个结果对象。客户端这边,默认配置(Client 的 raise_exceptions 默认关闭)也不会把它当作异常抛出,只是 is_error 为真,返回文本是 Error executing tool get_daily_revenue。

这里有一个容易忽略的细节:中文消息没有传过来。刚才服务器代码里抛出的 ValueError,属于下面要讲的崩溃这一档。SDK 把工具异常分成两档:抛出 ToolError(从 mcp.server.mcpserver.tools.base 导入)时,消息会原样传给客户端;抛出普通异常就按崩溃处理,异常文本和堆栈只保留在服务器日志里,客户端拿到的只是那句笼统的报错。

要让模型读到“门店 ST999 不存在”,然后自己换参数重试,服务器里就要抛出 ToolError。示例代码特意保留了 ValueError,是为了让读者先亲眼看到这个问题;把它换成 ToolError 重新运行,中文消息就能完整传给模型。

参数类型传错则走另一条路径,schema 校验失败的消息会原样交给客户端,模型据此修改即可。上述行为都在官方 SDK 2.1.0 上实测过,也对照源码注释核实过。这套处理对应的正是 4.4 的原则:把错误包装成结构化结果,再回填给模型。第 4 章手写的 try/except,现在由 SDK 在协议边界处代为完成。

第二种传输叫 streamable HTTP。服务器端只需修改 run 那一行,客户端则把连接对象从启动参数换成一个 URL。

python
# 服务器:mcp.run(transport="streamable-http", port=8807)
# 客户端:
import asyncio

from mcp import Client

async def main() -> None:
    async with Client("http://localhost:8807/mcp") as client:
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        result = await client.call_tool(
            "get_daily_revenue", {"store_id": "ST001", "date": "2026-08-24"})
        for block in result.content:
            print("text:", getattr(block, "text", None))

asyncio.run(main())

分两个终端运行,一个启动服务器、一个运行客户端,同样能拿到工具清单和查询结果,服务器日志里能看到一行行 POST /mcp 请求。streamable HTTP 的工作方式是:服务器提供一个 MCP 端点(路径习惯上叫 /mcp),客户端把每条 JSON-RPC 消息打包成一个 HTTP POST 请求发送过去。响应有两种:普通的 JSON 对象,或者只属于这次请求的 SSE 流(Server-Sent Events,服务器单向推送的 HTTP 流)。使用 SSE 时,流上可以先推送一些进度通知,再给出最终响应。

2026-07-28 版对这个传输做了大幅修改:GET 流端点、会话头、SSE 断流重放都从规范中移除,每个请求各自独立、自成一体。协议版本和能力放在请求体的 _meta 里,一部分元数据同时复制到 MCP-Protocol-Version 等请求头,网关和负载均衡器不需要解析正文就能做路由。

用网关日志或抓包工具看一遍这些流量,结构就清楚了:每个 POST 的正文就是一条 JSON-RPC 消息,方法名例如 tools/call,参数放在 params 里。看懂这个结构,以后排查“工具没被列出来”“调用没到服务器”这类问题就有了顺序:先确认请求有没有到、方法对不对,再看服务器返回了什么,最后才怀疑 SDK。

图 5-3 两种传输的样貌:stdio 把服务器当子进程拉起,streamable HTTP 每条消息一条 POST

两种传输怎么选,取决于部署方式和信任边界的划分。个人在本机使用的工具,比如查本地文件、连接内网系统,用 stdio 最方便,不需要开端口、不需要配证书,进程边界就是信任边界,凭证通过环境变量传递。如果需要跨机器共享、让多个客户端连接同一台服务器、集中管理授权和审计,就用 streamable HTTP,并配合 5.4 的 OAuth 流程。

本地 HTTP 还有一个要注意的问题。为了省事把服务器绑定在 127.0.0.1,认为只有本机能连就是安全的,我起初也这样想,但这挡不住浏览器里的恶意网页:DNS 重绑定可以让网页脚本借一个域名解析到本机端口,官方安全最佳实践把这条列为本地服务器面临的现实威胁。比较稳妥的做法是,本地场景尽量选 stdio;确实必须用 HTTP 时,就给端点配置认证,或者改用 Unix 域套接字这类本机通道。真正把服务器暴露到网络上时,服务端自己也要校验 Host 和 Origin 头,把来源不明的请求在进入业务逻辑之前拦截。

本地 stdio 服务器还有一层天生的权限问题。它以用户本人的身份运行,用户能访问什么,它就能访问什么:SSH 私钥、内网服务和环境变量里的全部凭证,一个都不少。这层环境性权限,正是本地服务器容易被恶意工具盯上的原因:攻陷一台本地服务器,基本等于获得了用户本人的权限。防御思路有两条:一是把服务器放进容器或受限账号里降权运行,把“能访问什么”从用户的全部权限收窄到工具必需的范围;二是回到第 13 章讲的最小权限原则,工具清单里只放权限范围窄的工具。

传输选型还有一个问题,就是 HTTP+SSE。网上不少教程讲的正是这套老传输:带 /sse 端点,握手时返回 endpoint 事件。但它在 2025-03-26 版规范里就被标为 Deprecated,到 2026-07-28 版正式进入废弃清单。新实现不要再使用它,旧服务器的迁移目标是 streamable HTTP。

让 MCP 服务器接入第 4 章的回路,靠两点。第一,tools/list 返回的 inputSchema 本身就是 JSON Schema,与 OpenAI tools 数组要求的 parameters 结构一致;第二,call_tool 返回的结果文本,可以直接放进 role 为 tool 的消息。转换代码如下,这一侧已在 MCP 环境中实测过。

python
import asyncio
import json

from mcp import Client, StdioServerParameters

async def openai_tools(client) -> list:
    """把 MCP 工具清单翻译成 OpenAI Chat Completions 的 tools 数组。"""
    result = await client.list_tools()
    return [{"type": "function",
             "function": {"name": t.name,
                          "description": t.description or "",
                          "parameters": t.input_schema}}
            for t in result.tools]

async def call_mcp_tool(client, name: str, arguments: dict) -> str:
    """执行 MCP 工具,返回可直接回填的 tool 消息内容。"""
    result = await client.call_tool(name, arguments)
    return next((b.text for b in result.content if hasattr(b, "text")),
                f"工具 {name} 未返回文本内容")

接回第 4 章的回路时,把 openai_tools 的返回值直接替换 4.2 代码里的 tools 列表,这一半的衔接是直接可用的。

日常调试和搭建原型,还有两个现成的辅助工具。官方的 Inspector 提供一个交互界面,连上服务器后可以逐个试用工具、查看原始的消息往返,排查协议问题比翻日志更直观;Python 侧还有 FastMCP 这样的轻量封装,几个装饰器就能搭出服务器骨架,省去大量样板代码。这些工具用起来方便,但底层机制还是这一节手写的这套,Inspector 的输出看不懂、FastMCP 出现异常行为时,答案都要回到这里找。写异步服务器时还有一条要求:同步实现的工具函数要放进线程池执行再等待结果,不要让一次阻塞调用卡住整个事件循环。

再说衔接的另一半,执行侧要改一处代码。call_mcp_tool 是异步函数,签名里比 TOOLBOX 里的本地函数多了 client 和工具名两个参数,所以 4.2 里的 result = json.dumps(fn(**args), ensure_ascii=False) 不再适用:在同步代码里这样调用异步函数,返回的不是结果,而是协程对象,json.dumps 会立即报 TypeError,client 也传不进去。

做法分两步:先把 run_agent 声明为 async def,再把分发那一行改为 await call_mcp_tool(client, tool_call.function.name, json.loads(tool_call.function.arguments))。因为返回值本来就是文本,json.dumps 可以直接省掉,同时还可以用 Client 的 async with 把整段调用包起来。除这一句之外,循环、历史管理、终止条件都不用改,回路就运行在 MCP 服务器上了。

第 4 章提到的中文厂商兼容端点在这里同样适用,换上密钥、base_url 和模型名就能运行。工具源从此移出应用代码,门店数据团队维护自己的 MCP 服务器,运营助手只需要知道一个地址。

接入第二个服务器时,会遇到同名工具的问题。文件系统服务器和云存储服务器都可能提供一个 read_file,两边的清单合并后,模型就分不清该调用哪个。通行的做法是给工具名加上 mcp_{server_id}_ 前缀再提供给模型:来自 filesystem 服务器的那个变成 mcp_filesystem_read_file。重名因此消除,原始名字也保留着,查看调用日志时仍然能看出调用的是哪个服务器。

执行时,客户端去掉前缀,按 server_id 找到对应的客户端连接,再用原名发起调用。第 4 章总结过:两份描述都匹配同一个请求时,模型的选择接近随机,命名空间在这里提前解决了这个问题。到这一步,接入工具的手工工作全部省去:回路只需修改一处分发,重名靠前缀解决。

加前缀、去前缀这两步写成函数只有几行,server_id 取不含下划线的简名,免得和分隔符混淆。

python
async def dispatch_mcp_tool(prefixed_name: str, arguments: dict,
                            connections: dict) -> str:
    """去掉 mcp_{server_id}_ 前缀,找到对应连接,用原名发起调用。"""
    _, server_id, original = prefixed_name.split("_", 2)
    client = connections[server_id]      # 如 {"filesystem": fs_client}
    result = await client.call_tool(original, arguments)
    return next((b.text for b in result.content if hasattr(b, "text")),
                f"工具 {original} 未返回文本内容")

清单合并时给每个名字拼上前缀,分发那一行换成这个函数,回路其余照旧。

5.4 MCP 的进阶与安全,Tasks、Skills、Apps、elicitation 与 OAuth 2.1 ​

多数项目用到的 MCP 其实只有三原语加两种传输。但有三类实际需求不在这个基本框架内:报销审批工具执行到一半发现金额超过阈值,需要用户确认;批量的记录核对一次运行十几分钟,连接一断就前功尽弃;远程服务器暴露在公网上,认证一步也不能省。这一节讲协议为这些场景提供的方案,另外还有界面和操作指南两类扩展,以及几个刚被废弃的特性。各部分内容彼此独立,遇到具体场景时再回来细读对应的部分也来得及。协议演进很快,每一条都以 2026-07-28 版为准,时效标注到 2026-08。

先看一种场景:服务器执行到一半,需要回头向用户请求信息。协议把这类场景称为 elicitation,2026-07-28 版为它换了一套新的实现,叫多轮请求(Multi Round-Trip Requests,MRTR)。旧版规范的做法是让服务器反向发出一条 JSON-RPC 请求,客户端收到 elicitation/create 就弹窗收集用户输入,再把结果交回去。这种双向请求要求协议允许服务器主动发起调用,服务器还要记住有哪些交互尚未完成,实现复杂,负载均衡也难做。

新版的做法是把方向反过来。服务器不再向客户端发起任何请求,而是把需要用户回答的问题放进响应里返回,这份响应原本是用来装结果的;客户端拿到答案后,把原来的请求再发一遍,服务器读到答案后接着执行。整个过程中,服务器从不主动发出请求,一问一答都在客户端发起的请求链上完成。正是这样的设计,让服务器可以做到完全无状态。落到字段上:等待用户输入的响应是一个 InputRequiredResult,resultType 标记为 input_required,inputRequests 里装着要问的问题;客户端把答案填进 inputResponses 再重试;正常结果返回时,resultType 是 complete。

报销审批工具执行到一半需要用户确认,此时 tools/call 的响应里不再放结果,而是放一个输入请求(只列出相关字段,规范原文见参考资料):

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "confirm_amount": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "报销金额超出阈值,请确认是否继续提交",
          "requestedSchema": {
            "type": "object",
            "properties": { "approve": { "type": "boolean" } },
            "required": ["approve"]
          }
        }
      }
    },
    "requestState": "(服务器加密的不透明字符串)"
  }
}

inputRequests 的写法有一个细节:confirm_amount 这个键名是服务器自己起的,值也没有另设格式,就是把 elicitation/create 这个请求对象原样放进来。客户端按 schema 渲染表单、收集答案,重试原来那次 tools/call 时,用同一个键把答案放进 inputResponses,requestState 也原样带回。前后两次请求的 JSON-RPC id 不同,它们是两个完全独立的请求。

无状态的承诺,具体体现在 requestState 上。需要跨请求保留的信息,例如执行到一半的上下文,服务器会编码进一段不透明的 requestState 字符串,随结果一起返回;客户端重试时原样带上,服务器解码后继续计算。规范提醒,服务器要把 requestState 当作可能被攻击者篡改的输入,只要它参与授权判断,就必须加上完整性保护,通常用 HMAC 或 AEAD 这类签名或加密方式,防止恶意客户端篡改状态来绕过授权。

elicitation 有两种模式。一种是 form:服务器附上一份受限的 JSON Schema,客户端按它渲染表单。这份 schema 只允许扁平对象里的基本类型和枚举,复杂结构不支持,目的是让客户端的界面实现简单可靠。敏感信息如密码、密钥和支付凭证,规范禁止通过 form 模式采集,必须改用 url 模式:服务器给出一个 URL,用户直接在浏览器里与目标站点交互,信息不经过客户端,自然也不会进入模型上下文。

用户的回应有三种动作:accept 表示提交,decline 表示拒绝,cancel 表示关闭,三种动作服务器都必须处理。

url 模式把敏感交互交还给浏览器,代价是链接本身扩大了攻击面:仿冒官网的钓鱼链接、开放重定向和缺少 state 参数导致的会话劫持,这些手法都在这条通道上出现过。URL 展示给用户之前,客户端要先做检查:链接域名是否就是预期的官方域名,有没有人注册了高仿的域名,用的是不是 HTTPS。凡是连着 OAuth 授权的链接,必须携带与用户绑定、密码学上安全的 state 参数,用来防范会话劫持和身份混淆。这一检查如果不做,一条钓鱼链接就可以通过 elicitation 通道直接送到用户面前,危险程度与钓鱼邮件里的链接相当。

再往下是长任务问题。跨店的记录核对一次要运行十几分钟,代码仓库全量索引一次要几个小时,tools/call 如果同步等待结果,既占用连接,又怕断线。官方扩展 Tasks 就是为这类场景设计的,扩展标识是 io.modelcontextprotocol/tasks。

工作方式大致是这样:服务器判断某个请求耗时较长时,不返回最终结果,而是返回一个任务句柄,resultType 记为 task,上面带有 taskId、初始状态、存活时间以及建议的轮询间隔。客户端按这个间隔调用 tasks/get 查询进度,状态在 working、input_required、completed、failed、cancelled 之间变化。执行到一半需要用户介入时,任务转入 input_required 并附上输入请求,客户端通过 tasks/update 提交答案。

即使客户端崩溃后重启也没有关系,taskId 是持久的句柄,重新获取后继续轮询即可。记录核对的场景里,宿主应用关闭、第二天再回来看结果,依靠的就是它。这个扩展要求双方都显式声明支持才会生效,服务器不会把任务句柄强行发给没有声明能力的客户端。

三原语覆盖不了的还有交互界面。图表、表单和地图这些内容,纯文本的结果无法表达,官方扩展 MCP Apps 允许服务器在工具结果之外附带一个 ui:// 资源,里面是一份自包含的 HTML 应用。宿主在对话流里用沙箱 iframe 渲染它,应用与宿主之间通过 postMessage 通信。应用还可以回头调用这台服务器上的其他工具,获取新数据来刷新界面。

第三方代码运行在沙箱里,无法访问宿主页面,也无法访问用户的 Cookie。截至 2026-08,Claude、VS Code 的 Copilot、Microsoft 365 Copilot 等宿主已经支持这套扩展。

还有一类是操作指南。Skills over MCP 要做到的是:服务器分发的内容不限于工具,还包括结构化的操作指南(skill),供宿主按需加载。第 4 章 4.5 和第 21 章讲的是使用侧,即技能装进宿主之后如何触发;这里讲的是分发侧,即技能如何通过协议送到宿主。它补充的正是提示原语覆盖不到的部分:prompts 是用户自己选用的现成模板,skill 面向任务执行,把“这类工作按什么步骤做”的操作知识连同工具一起分发,宿主在任务真正需要时再放进上下文。目前它仍处于工作组阶段,扩展提案正在审议,尚未定稿。工程上可以先这样理解:技能文档用资源原语来承载即可。

讲完进阶能力,安全是另一个必须讲的部分。5.1 提出的信任问题在这里落实:每接入一台远程服务器,都要做一次“该给多大权限”的决定。OAuth 2.1 提供的是执行这类决定的流程,但“给多大权限”这个判断本身,协议无法替人做出。

服务器部署到公网后,协议给出的方案是 OAuth 2.1。角色分工很明确:MCP 服务器担任 OAuth 2.1 的资源服务器,访问令牌的受众锁定为它自己;授权服务器既可以由 MCP 服务器兼任,也可以单独部署。

整个流程是这样的。客户端不带令牌发出请求,服务器返回 401,并在 WWW-Authenticate 头里给出受保护资源元数据的地址(RFC 9728)。客户端按地址找到授权服务器并注册自己,目前推荐的做法是 Client ID Metadata Documents:用一份托管的元数据文档作为 client ID。早期的动态注册(RFC 7591,客户端现场向授权服务器注册、当场获得一个 client ID)已经被废弃,但为了兼容仍然保留。

OAuth 里最常用的是授权码流程:用户在授权服务器页面点击同意,客户端换回一个一次性的短码,再用短码换取访问令牌。PKCE 为这个流程加上保护,客户端申请时先提交一个随机数的变换值,换令牌时再提交原值,两者对得上才发放令牌。截获短码的人无法同时提供这两个,也就换不到令牌。完整流程就是带 PKCE 的授权码流程:请求按 RFC 8707 附上 resource 参数,指明令牌用于哪台服务器;获得的访问令牌以 Bearer 的形式随每个请求发送。

resource 参数看起来不起眼,作用是绑定令牌的受众,避免为 A 服务器申请的令牌被拿到 B 服务器上使用。stdio 传输不涉及这一整套流程,规范写明 stdio 实现应当从环境中获取凭证,OAuth 只用于 HTTP 传输。

授权边界上还有一条硬性规定:协议明确禁止令牌直通(token passthrough)。客户端不能把给 A 服务器的令牌转交给 B 服务器,服务器也不能接收并转发来路不明的令牌;服务器要调用第三方 API,就自己向第三方申请凭据。这条禁令防范的是典型的代理混淆攻击:恶意服务器拿着客户端交来的令牌,去做客户端从未授权过的事。

第三方 API 的凭据保存在服务器进程里,从头到尾不进入模型上下文。模型只需要知道某件事能做,不需要知道用的是哪把钥匙。但权限范围不能马虎:模型每调用一次,使用的都是那把钥匙的全部权限。如果工具背后挂着一把权限过宽的 API key,智能体就成了高权限的内部威胁。权限应当只开到刚好够用,而且应该在接入一台服务器的时候就确定下来。

工具投毒是另一类要防范的安全问题,第 13 章会细讲。工具描述虽然是写给模型读的,攻击者同样能读到:MCP 的能力协商和授权流程约束的是行为边界,约束不了描述文本里夹带的指令。接入不信任的服务器之前,要先了解这个风险。

最后是废弃清单,sampling、roots 和 logging 三项在 2026-07-28 版里同时被标记为 Deprecated。按照协议的政策,已废弃的特性至少保留十二个月,最早的移除时间是 2027-07-28,新的实现不要再接入它们。

sampling 允许服务器反向借用客户端的模型来做补全,客户端临时充当模型网关,初衷是让轻量的服务器也能用上模型。废弃后的迁移建议是让服务器直接连接模型供应商的 API,模型调用走自己的通道,协议只管工具和数据,不再顺带管模型调用。roots 原本让客户端把工作目录这类文件系统位置告诉服务器,服务器据此限制自己的文件访问。废弃后,改由工具参数或资源配置显式传递路径,边界由调用方每次给出,而不是开始时一次性约定。

sampling 被废弃,还有一层安全考虑:这条反向通道不仅使用的人少,而且从结构上就不应该开放。服务器反向发送提示词,模型会把服务器的话当作可信指令执行。安全研究者做过演示:恶意服务器谎称系统进入维护模式,诱使模型提取 AWS 凭据并编码成 base64,绕过数据防泄漏(DLP)过滤器,凭据就此泄露。信任边界被整个颠倒过来,每条反向请求都要客户端亲自把关,漏掉一条,后果就是上面演示的那样。

logging 这项机制负责从服务器向客户端推送日志消息,整体在废弃名单里,其中设置级别的 logging/setLevel 方法已随会话机制一起移除,级别改由每个请求的 _meta 字段指定。替代方案很明确:本地 stdio 场景把日志写到标准错误流,远程场景交给 OpenTelemetry。

三项废弃放在一起,传递出一个明确的信号:协议正在收缩范围,把会话、反向请求和日志这些分布式复杂度高的机制逐个移除,换成无状态、单向请求、职责更单一的结构。给接入方的启示是:把阅读 changelog 变成日常习惯。从 2024 年 11 月的初版到 2026-07-28,规范多次改版,照着旧博客写代码会遇到哪些问题,本章开头已经提醒过。

本章小结 ​

  • 5.1 接入成本:点对点集成是 N 乘 M 份代码,MCP 换成 N 加 M 份标准实现;省掉的是代码,省不掉的是信任,每个应用对每个服务器仍要做一次授权判断,这部分在 5.4 的授权流程落实。
  • 5.2 结构:三层角色是宿主、客户端和服务器,客户端与服务器严格一对一;三种原语按控制权归类,工具归模型、资源归应用、提示归用户;2026-07-28 版完全无状态,协议版本和能力随每个请求的 _meta 携带。
  • 5.3 两种传输:stdio 把服务器当子进程拉起,凭证走环境变量、日志只能走标准错误流;streamable HTTP 每条 JSON-RPC 消息一个 POST;接入第 4 章回路只改一处分发,同名工具加 mcp_{server_id}_ 前缀解决。
  • 5.4 进阶与安全:Tasks 用任务句柄轮询长任务,elicitation 走 MRTR 把用户输入放进响应,MCP Apps 附带 ui:// 界面,Skills over MCP 还在工作组阶段;远程接入用带 PKCE 的 OAuth 2.1,令牌直通被明令禁止;sampling、roots 和 logging 已标废弃,新实现不要再接入。

第 4 章的回路加上本章的标准接入,智能体已经能稳妥地使用分散在各处的工具和数据。至于协议之间如何互通、智能体之间如何跨组织协作,是更大的生态话题。

参考资料 ​

  • 《Breaking the Model Context Protocol》(Satheesh kumar & Sekar,Apress 2026)第 3 章(环境性权限:本地服务器以用户本人身份运行的天然暴露面)、第 7 章(HTTP 传输的服务端 Host/Origin 校验防御),见 txt-ref-zh/breaking-the-mcp/

  • 《Building and Distributing Agentic AI Solutions》(Julian Soh、John Tidwell、Priyanshi Singh,Apress 2026)第 4 章(“客户端不会自动扫 API 端点”的防误解澄清、单应用私有工具不必上 MCP 的边界),见 txt-ref-zh/building-distributing-agentic-ai/

  • 《Build an AI Agent (From Scratch)》(Manning MEAP,2026)第 6 章(MCP Inspector 调试界面与 FastMCP 薄封装快路),见 txt-ref-zh/build-ai-agent-from-scratch/

  • 《Building Agentic Applications with CrewAI and MCP》(Max Gfeller,Manning MEAP)第 4 章(异步服务器中同步工具丢线程池隔离的纪律),见 txt/crewai-mcp.txt(英文原文)

  • MCP 官方规范 2026-07-28 版总页(当前版本、JSON-RPC 2.0、LSP 灵感自述、三层架构定义、三原语与客户端特性、扩展概览)https://modelcontextprotocol.io/specification/ 核实于 2026-08

  • MCP 规范 2026-07-28 changelog(移除会话与 initialize 握手、新增 server/discover 与 subscriptions/listen、MRTR 模式、resultType 字段、移除 ping 与 logging/setLevel、移除 SSE 重放、Tasks 移入官方扩展)https://modelcontextprotocol.io/specification/2026-07-28/changelog 核实于 2026-08

  • MCP 规范 Architecture 页(host、client、server 职责,1:1 连接,服务器读不到完整对话与彼此隔离的设计原则,无状态与每请求能力协商)https://modelcontextprotocol.io/specification/2026-07-28/architecture 核实于 2026-08

  • MCP 规范 Transports 总览与 Streamable HTTP 页(stdio 与 streamable HTTP 绑定、单一 MCP 端点 POST、JSON 或请求级 SSE 响应、MCP-Protocol-Version 与 Mcp-Method、Mcp-Name 头、HTTP+SSE 废弃提示)https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http 核实于 2026-08

  • MCP 规范 Security Best Practices 页(本地服务器威胁清单里的 DNS 重绑定、本地优先 stdio、HTTP 端点配认证或改 Unix 域套接字的收敛做法)https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices 核实于 2026-08-28

  • MCP 规范 MRTR 页(InputRequiredResult、inputRequests 与 inputResponses、requestState 完整性要求、仅 prompts/get、resources/read、tools/call 支持输入请求)https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr 核实于 2026-08

  • MCP 规范 Elicitation 页(form 与 url 两模式、requestedSchema 受限子集、敏感信息禁走 form、accept 与 decline 与 cancel 三动作)https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation 核实于 2026-08

  • MCP 规范 Authorization 页(OAuth 2.1 资源服务器定位、RFC 9728 与 RFC 8707 与 RFC 9207、PKCE、Client ID Metadata Documents、Bearer 令牌每请求携带、令牌直通禁令、stdio 从环境取凭证)https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization 核实于 2026-08

  • MCP 规范 Deprecated Features 登记页(roots、sampling、logging、动态客户端注册、HTTP+SSE 的迁移路径与最早移除时间 2027-07-28)https://modelcontextprotocol.io/specification/2026-07-28/deprecated 核实于 2026-08

  • MCP Tasks 官方扩展概览(任务句柄、tasks/get 轮询、tasks/update 中途输入、状态机、双方 opt-in)https://modelcontextprotocol.io/extensions/tasks/overview 核实于 2026-08

  • MCP Apps 官方扩展概览(ui:// 资源、沙箱 iframe、postMessage、已支持宿主列表)https://modelcontextprotocol.io/extensions/apps/overview 核实于 2026-08

  • Skills Over MCP 工作组章程(工作组阶段、SEP-2640 提案在审、与 agentskills.io 规范协调)https://modelcontextprotocol.io/community/working-groups/skills-over-mcp 核实于 2026-08

  • Anthropic 公告 Introducing the Model Context Protocol(2024-11-25 发布并开源)https://www.anthropic.com/news/model-context-protocol 核实于 2026-08

  • MCP Python SDK 仓库与文档(pip install mcp[cli]、v2 支持 2026-07-28 规范、MCPServer 与 Client API、Python 3.10+、v1.x 维护分支)https://github.com/modelcontextprotocol/python-sdk 核实于 2026-08,版本 2.1.0 另核于 PyPI

  • OpenAI 与 Google 采纳 MCP、官方注册表服务器近两千(2025-09 上线后数月内)、SDK 月下载近亿,转述自 WorkOS 博客 MCP in 2026(二手汇总数据,引用需注明转述)https://workos.com/blog/everything-your-team-needs-to-know-about-mcp-in-2026 核实于 2026-08,2026-09-03 复核原文口径

  • 《Designing Multi-Agent Systems》第 12 章 Protocols for Distributed Agents(N 乘 M 困局与标准化价值、三层架构、OAuth 安全背景、多服务器同名工具的 mcp_{server_id}_ 命名空间前缀;书中会话与 sampling 等按旧版规范写的内容已按 2026-07-28 版修正,未照搬),见 txt-ref-zh/designing-multi-agent-systems/

  • 《Breaking the Model Context Protocol》(Satheesh kumar & Sekar,Apress 2026)第 1 章(ASI03 身份与权限滥用:智能体继承 MCP 工具所持的全部凭据、过宽 API key 使其成为高权限内部威胁)、第 6 章(采样滥用:服务器反向下发提示词、模型把服务器系统提示当可信指令照办、谎称维护模式提取 AWS 凭据并编成 base64 绕过 DLP 过滤器)、第 7 章(诱导 URL 攻击:钓鱼高仿链接、缺失 OAuth state 参数的会话劫持、开放重定向滥用;URL 到达用户前做验证、state 参数须密码学安全并绑定用户),见 txt-ref-zh/breaking-the-mcp/(全书译文,数字保真核对通过)。