为什么需要一个协议:host / client / server 三角、JSON-RPC 消息与三种原语
先搞清楚 MCP 到底解决了哪个具体的重复劳动,再把宿主、客户端、服务端三个角色和工具、资源、提示模板三种原语对上号,最后逐字段读懂一条真实的 JSON-RPC 报文。
今日目标
- 能用一句话说清 MCP 解决的是 M 乘 N 的重复接线问题,并举出一个不该用 MCP 的场景
- 能画出宿主、客户端、服务端三者的关系,并说明为什么一个客户端只连一个服务端
- 能逐字段读懂一条 tools/call 请求与响应,指出协议版本和客户端能力写在哪里
这门课默认你已经手写过一轮 Agent 循环,知道模型返回一个工具调用、程序执行、结果回填是怎么回事。没做过也不要紧,30 天课的第 2 天有完整的手写过程,可以先去补一下:手写 Agent Loop。今天我们不写循环,只回答一个问题:那些工具凭什么能被别人的程序也用上。读完回来把上面三条勾掉。
小白版讲解
万能插座的故事:为什么每接一个工具都要重写一遍胶水代码
你家里有台灯、有电脑、有电饭煲。它们的功能完全不同,但插头是同一种。这件事今天看起来天经地义,可如果没有插头标准,每买一台电器你都得请电工来接线一次——电器厂商也得为每一种墙上的接线方式各出一个版本。
写 Agent 的工具就是没有插头标准的年代。你给自己的 Agent 接了查订单、发邮件、跑 SQL 三个工具,代码里是三段各自为政的函数:参数怎么定义、错误怎么回传、超时怎么处理,全凭当时的心情。然后同事的 Agent 也要查订单,他不能直接用你的函数——你那份是写死在你的循环里的,他得照着你的逻辑重抄一遍。
把规模放大就成了一道乘法题。假设团队里有 M 个 Agent 应用(客服机器人、代码助手、数据分析助手),要接 N 个数据源与工具(订单库、日历、代码仓库、监控系统)。没有协议时,你要写的适配器数量是 M 乘 N:每一个应用都要为每一个工具单独写一遍接线代码。M 和 N 各自到 5,就是 25 份需要维护的胶水;订单库改一个字段,25 份里有 5 份要跟着改,而且没人记得清是哪 5 份。
有了协议之后,这道乘法变成加法:M 加 N。每个工具实现一次协议、每个应用实现一次协议,中间靠协议对上。这就是 MCP(Model Context Protocol,模型上下文协议)在做的唯一一件事——它是插头标准,不是电器,也不是发电厂。
先看没有协议时的样子。下面这段是你在自己 Agent 里接一个天气工具的典型写法:
// 没有协议时:工具定义、执行、错误处理全焊死在这一个循环里
const tools = [
{
name: 'get_weather',
description: '查询某个城市的天气',
parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
},
]
async function runTool(name, args) {
if (name === 'get_weather') {
const res = await fetch(`https://example.com/weather?city=${args.city}`)
if (!res.ok) throw new Error(`天气服务返回 ${res.status}`)
return await res.text()
}
throw new Error(`未知工具 ${name}`)
}
// 同事想用这个工具,只能把上面两段整体复制一遍,然后自己维护一个副本。# 没有协议时:工具定义、执行、错误处理全焊死在这一个循环里
tools = [
{
"name": "get_weather",
"description": "查询某个城市的天气",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
]
async def run_tool(name: str, args: dict) -> str:
if name == "get_weather":
res = await client.get("https://example.com/weather", params={"city": args["city"]})
res.raise_for_status()
return res.text
raise ValueError(f"未知工具 {name}")
# 同事想用这个工具,只能把上面两段整体复制一遍,然后自己维护一个副本。这段代码没有一行是错的。问题在于它不能被第二个程序使用:工具的定义、执行、错误约定三样东西被绑在了一个进程里。MCP 做的事情就是把这三样从进程里拆出来,放进一条任何程序都能说的通用语言里。
工程代价也要先说清楚:多了一层协议就多了一层进程、一层序列化、一层需要排查的地方。 一个只有你自己用、永远不会给第二个程序调用的工具,写成 MCP 服务端是纯亏——这一节的最后我们会专门讲这条边界。
宿主、客户端、服务端:三个角色各自负责什么
协议定了三个角色,名字有点绕,但对应关系其实很直白。
宿主(host) 是你面前那个应用本身:一个聊天客户端、一个 IDE、一个自动化平台。它管着模型、管着对话历史、管着用户的授权决定。服务端(server) 是被接进来的那个能力:一个能读文件的进程、一个能查数据库的服务、一个包着第三方 API 的网关。客户端(client) 夹在中间,是宿主为每一个服务端各开的一条连接。
用插座类比:宿主是你家,服务端是电器,客户端是墙上的那个插孔。你家有几台电器,墙上就得有几个插孔——规范明确规定一个客户端只和一个服务端通信,是严格的一对一。
Mermaid 源码
graph LR
subgraph Host[宿主应用进程]
H[宿主]
C1[客户端 1]
C2[客户端 2]
H --> C1
H --> C2
end
C1 --> S1[服务端 A 本地文件]
C2 --> S2[服务端 B 远程数据库]一对一看着像是浪费,其实是这套架构里最关键的一条安全设计。规范里有一条原则写得很直接:服务端不应该能读到整段对话,也不应该能看见别的服务端。 完整的对话历史留在宿主手里,每个服务端只拿到它这次真正需要的那点参数。
这条设计换来的好处,要出事的时候才体会得到。假设你接了一个第三方的天气服务端,它的作者心怀不轨。在一对一隔离下,它能看到的只有你传给它的城市名;如果没有这层隔离、所有服务端共享一条通道,它就能读到你和公司内部数据库服务端之间的往来——那是一次数据泄露。
工程代价是连接数:你接十个服务端,宿主进程里就有十条连接、十个待管理的生命周期。这也是为什么客户端实现的复杂度往往超出预期——第 5 天我们会自己写一个,你会看到真正的活儿都在连接管理和聚合上。
三种服务端原语:谁来决定用不用
服务端能对外暴露三样东西,规范把它们叫做原语(primitive)。三者的区别不在于"能做什么",而在于谁来决定这一次用不用它。这是最容易背混的一点,记住控制方就不会错。
| 原语 | 谁来决定 | 一句话 |
|---|---|---|
| 工具(tool) | 模型 | 模型看着描述自己挑,挑完就执行,会产生副作用 |
| 资源(resource) | 宿主应用 | 一份可以被读进上下文的只读数据,由应用或用户挑 |
| 提示模板(prompt) | 用户 | 用户显式选中的一段预设问法,常做成斜杠命令 |
举个例子,同一个代码仓库可以三种形态都出一份。做成工具是 search_code:模型判断需要搜代码时自己调。做成资源是 file:///project/README.md:应用把它塞进上下文,或者让用户在一个文件选择器里挑。做成提示模板是"给这段代码做 review":用户敲一个斜杠命令把它调出来。
这个划分不是为了好听。决定权在谁手里,决定了这件事出错时该找谁负责。 模型选错了工具是工具描述写得不好,用户选错了提示模板是命名不清楚,应用塞错了资源是产品设计问题。第 2 天和第 3 天我们会分别把工具和资源写一遍,你会看到三者的代码形态差得也很远。
一条报文的解剖:JSON-RPC 加上 MCP 自己的那部分
MCP 的传输层不神秘:所有消息都是 JSON-RPC 2.0。请求带 id 和 method,响应带同一个 id 和 result 或 error,通知(notification)不带 id 也不需要回复。这部分是现成的标准,学一遍到处能用。
MCP 自己加的东西集中在一个叫 _meta 的字段里。在 2026-07-28 这一版规范里,每一条客户端请求都必须自带协议版本和客户端能力,写成 io.modelcontextprotocol/protocolVersion 和 io.modelcontextprotocol/clientCapabilities 两个键。下面是一条真实的调用请求:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}三个键各有分工。protocolVersion 是必填的,服务端不认这个版本就要回 -32022 错误并附上自己支持的版本列表。clientCapabilities 也是必填的,它告诉服务端"我这边能干什么"——比如能不能弹一个表单让用户填字段。服务端不得依赖客户端没有声明的能力,需要而对方没声明时要回 -32021 并列出缺了哪些。clientInfo 是选填的自报家门,规范特别提醒它没有经过任何验证,只能用来显示和记日志,不能拿来做安全判断。
响应这边有一个这一版新加的必填字段 resultType:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "纽约当前 22 度,多云" }],
"isError": false,
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "ExampleServer", "version": "1.0.0" } }
}
}resultType 只有两个核心取值。complete 表示这次算完了;input_required 表示服务端还缺东西,需要客户端补齐再重试一次——第 4 天会详细讲这个模式。旧版本的服务端不会带这个字段,规范规定客户端必须把缺失当成 complete 处理,这是为了兼容留的口子。
还要注意 isError 和 JSON-RPC 的 error 是两回事。前者是"工具跑了,但业务上失败了",内容会被喂回模型让它自己改;后者是"这个请求本身就不对",比如工具名不存在。这条分界线第 2 天会展开。
自己拼这个 _meta 其实就几行代码,值得先手写一次,后面用 SDK 时才知道它替你填了什么:
const PROTOCOL_VERSION = '2026-07-28'
function buildRequest(id, method, params = {}) {
return {
jsonrpc: '2.0',
id,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': { name: 'my-client', version: '0.1.0' },
// 空对象表示:我不支持 elicitation 之类的任何客户端能力
'io.modelcontextprotocol/clientCapabilities': {},
},
},
}
}
// stdio 传输就是一行一条 JSON,末尾一个换行,消息内部不许有裸换行
process.stdout.write(JSON.stringify(buildRequest(1, 'tools/list')) + '\n')import json
import sys
PROTOCOL_VERSION = "2026-07-28"
def build_request(request_id: int, method: str, params: dict | None = None) -> dict:
meta = {
"io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION,
"io.modelcontextprotocol/clientInfo": {"name": "my-client", "version": "0.1.0"},
# 空字典表示:我不支持 elicitation 之类的任何客户端能力
"io.modelcontextprotocol/clientCapabilities": {},
}
return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": {**(params or {}), "_meta": meta}}
# stdio 传输就是一行一条 JSON,末尾一个换行,消息内部不许有裸换行
sys.stdout.write(json.dumps(build_request(1, "tools/list")) + "\n")无状态是这一版的地基
如果你之前读过 MCP 的旧文档,这里要重新学一次。2026-07-28 这一版做了一次重构级的改动:协议变成无状态的,initialize 握手和 notifications/initialized 通知都被删掉了。
旧版本的流程是先握手、协商好版本和能力,之后每条消息都省着不写。新版本反过来:没有握手,每条请求自己带全部信息。规范的原话是所有处理这条请求需要的信息都必须包含在请求本身里,服务端不得依赖同一条连接上的先前请求来建立上下文。
跟着一起消失的还有几样东西,都值得单独记一笔:HTTP 上的会话标识没了、单独开一条 GET 长连接的用法没了、断流之后按事件号续传也没了。取而代之的是一个新方法 server/discover,服务端必须实现它,客户端可以在任何请求之前调它一次,拿到对方支持的版本、能力和身份。
代价当然存在。最直接的一条是报文变胖了:每条请求都要重复带一遍版本和能力块。换来的是三件在生产里很值钱的事:请求可以被任意一个副本处理,所以扩容不用做粘性路由;一条连接上可以随意穿插毫不相关的请求;进程挂了重启,在途的请求重发一次就好,因为对面本来就没记住什么。
那真正需要跨调用保存状态的场景怎么办?比如一个购物车、一个数据库事务。规范给的答案是显式句柄:创建时由服务端返回一个 id,后续调用把它当成普通的工具参数传回来。
// 无状态协议下保存状态的唯一正确姿势:句柄由服务端铸造,由模型带着走
const baskets = new Map() // 真实实现里换成 Redis 或数据库
function createBasket(userId) {
const id = `bsk_${crypto.randomUUID()}` // 必须足够随机,句柄不能被猜
baskets.set(id, { userId, items: [], expiresAt: Date.now() + 86_400_000 })
return { basket_id: id }
}
function addItem({ basket_id, sku }, callerUserId) {
const basket = baskets.get(basket_id)
// 关键一步:句柄只是名字,不是凭证——每次都要重新校验调用者有没有权限
if (!basket || basket.userId !== callerUserId) throw new Error('购物车不存在或已过期')
basket.items.push(sku)
return { count: basket.items.length }
}import time
import uuid
# 无状态协议下保存状态的唯一正确姿势:句柄由服务端铸造,由模型带着走
baskets: dict[str, dict] = {} # 真实实现里换成 Redis 或数据库
def create_basket(user_id: str) -> dict:
basket_id = f"bsk_{uuid.uuid4()}" # 必须足够随机,句柄不能被猜
baskets[basket_id] = {"user_id": user_id, "items": [], "expires_at": time.time() + 86400}
return {"basket_id": basket_id}
def add_item(basket_id: str, sku: str, caller_user_id: str) -> dict:
basket = baskets.get(basket_id)
# 关键一步:句柄只是名字,不是凭证——每次都要重新校验调用者有没有权限
if basket is None or basket["user_id"] != caller_user_id:
raise ValueError("购物车不存在或已过期")
basket["items"].append(sku)
return {"count": len(basket["items"])}注意注释里那句"句柄只是名字,不是凭证"。这正是第 6 天要讲的一类攻击:拿到别人的句柄就能操作别人的购物车。规范对此的要求很硬——服务端不得把持有句柄当成身份认证。
MCP 不是函数调用的替代品
最后划一条边界,因为这是面试里最容易露怯的地方。
模型的函数调用(function call)和 MCP 不是同一层的东西,它们甚至不冲突。函数调用是模型 API 的能力:你在请求里带上工具定义,模型返回一个"我要调这个"。MCP 是工具从哪里来的问题:工具的定义和执行体住在另一个进程里,通过协议拿过来。一个 MCP 客户端最后还是要把工具翻译成模型 API 的工具参数,走的仍然是函数调用那条路——第 5 天你会亲手写这段翻译。三者(函数调用、MCP、技能)的完整对比放在了 Agent Skills 课:函数调用、MCP、Skills 三者的分工。
那什么时候不该用 MCP?三种情况值得直接跳过:
第一,工具只有你这一个程序会用,而且可预见的将来也不会有第二个。此时协议带来的进程、序列化和调试成本全是净损失,直接写一个本地函数更快。
第二,调用极其频繁、对延迟敏感。每次工具调用都要过一次序列化和进程边界,本机 stdio 的开销不算大,但如果服务端在公网另一头,一次往返几十到几百毫秒;一轮对话里连调五次,用户就能感觉到卡。
第三,这件事根本不需要模型决定。如果你的产品逻辑是"用户点了按钮就查订单",那就直接查,不要绕一圈让模型来挑工具。把确定性的流程交给模型来决定,是在花钱买不确定性。
反过来,只要出现"这个能力要给多个应用用""这个数据源想被别人的助手接入""我想换掉宿主但保留工具"这三句话中的任何一句,就是 MCP 该出场的时候。
源码导读
动手实验
今天的实验不写代码,产出是一份记录。这件事看着枯燥,但它是后面六天的地基:你只要能凭记忆默写出一条 tools/call 请求的骨架,第 2 天到第 5 天的所有调试都会快一倍。 卡住的时候回到上面"一条报文的解剖"那一节对照着看。
- 按 README 的说明在本机起一个现成的 MCP 服务端,观察它的标准输入与标准输出,确认输出里每条消息独占一行。
- 把一轮完整会话的报文抄进 starter 的记录表:先 server/discover,再 tools/list,最后 tools/call,请求与响应都要留。
- 逐字段标注:用两种记号区分「JSON-RPC 2.0 规定的」与「MCP 额外加的」,标完你会发现 MCP 加的东西其实很少。
- 在表格的三个空位里填上协议版本、客户端能力、resultType 各自的位置,并写清楚少了它服务端会回哪个错误码。
- 去规范网站上找一处和你印象中不一样的地方(握手、会话、订阅都是高发区),写下页面名与结论。
面试题
今天 3 道题在下方题库区,侧重 MCP 与函数调用的边界、三角架构的隔离设计、无状态协议的取舍。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能用一句话说清 MCP 解决的是 M 乘 N 的重复接线问题,并举出一个不该用 MCP 的场景
- 能画出宿主、客户端、服务端三者的关系,并说明为什么一个客户端只连一个服务端
- 能逐字段读懂一条 tools/call 请求与响应,指出协议版本和客户端能力写在哪里
- 能说清工具、资源、提示模板三者分别由谁决定用不用
- 实验的 4 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D2)我们就动手写第一个服务端。顺序是有意的:先读懂报文再写代码,你才知道 SDK 那几行 API 背后到底往线上放了什么。 很多人跳过今天直接抄 SDK 示例,结果一遇到工具没被模型选中就完全不知道从哪查起——因为他从来没看过线上真正传的是什么。明天还会遇到本课的第一个坑:官方 JavaScript SDK 目前实现的还是上一版协议,我们会正面处理这件事。
面试题库
MCP 和模型自带的函数调用到底差在哪?什么情况下你不该用 MCP?How is MCP actually different from a model's built-in function calling, and when should you not use MCP?
国内高频海外高频基础#mcp-basics#architecture分析过程 · 先想清楚再作答
- 这题在筛「有没有真正接过工具」。把 MCP 说成「函数调用的升级版」就露馅了,因为两者根本不在同一层,答对的人第一句就会先把层次拆开。
- 拆法:问自己「这一步是模型 API 的事,还是工具从哪来的事」。函数调用是模型 API 的能力——你把工具定义放进请求,模型回一个要调谁;MCP 管的是那份定义和执行体住在哪个进程里、用什么语言交换。
- 接着点出两者是叠加而非替代:MCP 客户端拿到 tools/list 之后,还要把它翻译成模型 API 的工具参数,最终仍然走函数调用那条路。
- 结论:MCP 解决的是 M 个应用乘 N 个工具的重复接线,把乘法变成加法;它换来的代价是多一层进程、一层序列化、一层要排查的地方。
- 不该用的三种情况:工具只有自己这一个程序用;调用极频繁且对延迟敏感(远程一次往返几十到几百毫秒,一轮连调五次用户就有感);这件事根本不需要模型决定,产品逻辑本来就是确定的。
- 可预期的追问:那本机 stdio 的开销很小,是不是就可以随便用?答案是开销不只在传输,还在多一个要部署、要监控、要授权的进程上。
How to reason about it · think before answering
- The screen is whether you have actually wired tools yourself. Calling MCP an upgraded function call fails, because the two sit at different layers.
- Separate the layers first: function calling is a model API feature — you pass tool definitions in the request and the model replies with which one to invoke. MCP governs where that definition and its executor live and how they are exchanged.
- They compose rather than compete: an MCP client still translates tools/list output into the model API's tool parameters, so the final hop is ordinary function calling.
- Conclusion: MCP turns an M-applications-by-N-tools wiring problem into M plus N, at the cost of an extra process, an extra serialization boundary, and an extra place to debug.
- Skip MCP when the tool has exactly one consumer, when calls are hot and latency-sensitive (a remote round trip is tens to hundreds of milliseconds, five per turn is noticeable), or when the decision does not need a model at all.
- Likely follow-up: local stdio is cheap, so why not use it everywhere? Because the cost is not only transport — it is one more process to deploy, monitor, and authorize.
答题要点
- 函数调用是模型 API 的能力,MCP 是工具定义与执行体的分发协议,两者叠加而不是替代
- MCP 的价值是把 M 乘 N 的适配器数量变成 M 加 N,代价是多一层进程与序列化
- 单一消费者、延迟敏感的热路径、以及本来就确定的产品流程,这三种情况不该用 MCP
- 判据是「这个能力要不要给第二个程序用」,只要答案是要,协议的成本就摊得开
Key points
- Function calling is a model API capability; MCP is a distribution protocol for tool definitions and executors — they stack, not compete
- MCP converts M-by-N adapters into M plus N, paying with an extra process and serialization hop
- Skip it for single-consumer tools, latency-sensitive hot paths, and flows that are deterministic by design
- The test is whether a second program will ever need this capability; if yes, the protocol cost amortizes
MCP 规范为什么规定一个客户端只连一个服务端?多路复用不是更省资源吗?Why does the MCP spec require one client per server instead of multiplexing many servers over one connection?
国内高频海外高频进阶#architecture#security分析过程 · 先想清楚再作答
- 这题看着在问性能,其实在问安全边界。只从连接数和资源占用切入的回答会被判为没读过设计原则那一节。
- 拆法:先问「共享一条通道之后,谁能看见谁」。规范写死了两条原则——服务端不应该读到整段对话,也不应该看得见别的服务端;一对一是实现这两条最直接的手段。
- 举一个具体后果:接一个第三方天气服务端时,一对一隔离让它只能看到你传的城市名;共享通道则可能让它读到你和内部数据库服务端之间的往来,那就是一次数据泄露。
- 结论:完整对话历史留在宿主,服务端只拿到这次真正需要的参数;宿主是唯一的安全边界执行者,也是唯一做跨服务端编排的地方。
- 代价要主动说:接 N 个服务端就有 N 条连接、N 套生命周期要管,客户端实现的复杂度大头正是在这里,而不是在发报文上。
- 可预期的追问:那多个服务端的工具重名怎么办?答案是聚合与消歧是宿主侧的职责,规范建议加服务端标识前缀,并且明确说不要依赖服务端自报的名字,因为它不保证唯一也未经验证。
How to reason about it · think before answering
- It reads like a performance question but is really about security boundaries. Answering only in terms of connection count signals you never read the design principles.
- Ask who can see whom once a channel is shared. The spec fixes two principles: servers should not read the whole conversation, and should not see into other servers. One-to-one is the most direct way to enforce both.
- Concrete consequence: with isolation, a third-party weather server sees only the city you passed. On a shared channel it could observe traffic between you and an internal database server — a data leak.
- Conclusion: full history stays with the host, each server receives only the arguments this call needs, and the host is the single place where boundaries are enforced and cross-server orchestration happens.
- State the cost yourself: N servers means N connections and N lifecycles, and that is where most client complexity lives, not in sending messages.
- Likely follow-up: how do you handle tool name collisions across servers? Aggregation and disambiguation belong to the host; the spec suggests prefixing with a server identifier and explicitly warns against relying on the server's self-reported name, which is neither unique nor verified.
答题要点
- 一对一是安全设计而非性能设计:服务端读不到整段对话,也看不见别的服务端
- 完整历史留在宿主,服务端只收到本次调用真正需要的参数
- 跨服务端的聚合、消歧、授权都由宿主统一做,边界只有一处需要加固
- 代价是连接与生命周期管理,这是客户端实现复杂度的主要来源
Key points
- One-to-one is a security decision, not a performance one: servers cannot read the conversation or see peers
- Full history stays in the host; a server receives only the arguments for the current call
- Aggregation, disambiguation, and authorization all happen in the host, so there is a single boundary to harden
- The cost is connection and lifecycle management, which dominates client implementation complexity
2026-07-28 这一版把 MCP 改成了无状态协议,删掉了 initialize 握手。这么改的代价是什么?服务端还想保存状态该怎么办?The 2026-07-28 revision made MCP stateless and removed the initialize handshake. What does that cost, and how should a server that still needs state handle it?
国内高频海外高频深入#protocol-versions#statelessness分析过程 · 先想清楚再作答
- 这题的区分度在「知不知道这一版改了什么」。凭旧记忆答握手、会话标识、断流续传的人会当场暴露,因为这三样在这一版全被删了。
- 先说改了什么:没有 initialize 与 notifications/initialized,每条请求在 _meta 里自带协议版本与客户端能力;新增 server/discover 供客户端一次性取回版本、能力与身份,服务端必须实现它。
- 拆代价的角度是「省了什么、贵了什么」。贵的是报文:每条请求都要重复带版本与能力块。省的是三件事——任意副本都能处理请求所以扩容不用粘性路由、一条连接可以穿插无关请求、进程重启后在途请求重发即可。
- 结论:这是一次拿带宽换可伸缩性的交易,对本机 stdio 几乎无感,对多副本的远程部署收益很大。
- 状态怎么办:显式句柄。创建工具返回一个服务端铸造的 id,后续调用把它当普通参数传回来;服务端把状态按这个 key 存在自己的库里,并在工具描述里写清有效期。
- 可预期的追问:句柄安全吗?必须补一句——句柄是名字不是凭证,服务端每次都要重新校验调用者身份,句柄要用安全随机数生成、绑定到已认证的主体、并设过期时间。
How to reason about it · think before answering
- The discriminator is whether you know what this revision changed. Anyone answering from memory about handshakes, session IDs, or stream resumption exposes themselves — all three were removed.
- State the change first: no initialize and no notifications/initialized; every request carries its protocol version and client capabilities in _meta, and a new server/discover method, which servers MUST implement, returns versions, capabilities, and identity in one call.
- Weigh it as saved versus paid. You pay in payload size, repeating the version and capability block on every request. You save three things: any replica can serve any request so scaling needs no sticky routing, unrelated requests can interleave on one connection, and after a restart in-flight requests simply get resent.
- Conclusion: it trades bandwidth for scalability — near-invisible on local stdio, valuable for multi-replica remote deployments.
- For state, use explicit handles: a creation tool returns a server-minted id, and later calls pass it back as an ordinary argument while the server keys its own storage on it and documents the lifetime in the tool description.
- Likely follow-up: is a handle safe? Say it unprompted — a handle is a name, not a credential. Re-authorize the caller on every call, generate handles with a secure random source, bind them to the authenticated principal, and expire them.
答题要点
- 这一版删掉了 initialize 握手、协议级会话、GET 长连接与断流续传,改为每条请求自带版本与能力
- 新增 server/discover,服务端必须实现,客户端可在任何请求前一次性取回版本、能力与身份
- 代价是报文变胖,收益是无粘性路由的横向扩容、连接上可穿插无关请求、重启后重发即可
- 跨调用状态改用服务端铸造的显式句柄,作为普通工具参数传递,并且句柄不等于身份认证
Key points
- The revision removed the initialize handshake, protocol-level sessions, the GET stream, and stream resumption; each request now carries version and capabilities
- It added server/discover, which servers must implement, letting clients fetch versions, capabilities, and identity up front
- The cost is larger payloads; the payoff is sticky-free horizontal scaling, interleaved unrelated requests, and cheap retry after restarts
- Cross-call state moves to server-minted explicit handles passed as ordinary tool arguments, and a handle is never authentication
评论
登录后即可参与讨论
还没有评论,来说第一句。