写一个 MCP client:在自己的 Agent 循环里发现并调用工具、多 server 聚合与命名冲突
反过来站到客户端一侧:手写一个能发现工具、把工具翻译成模型能看懂的形状、再把结果喂回循环的客户端,并处理多服务端聚合时的重名与失效。
今日目标
- 能手写一个 stdio 客户端,完成发现、调用、错误处理三步
- 能把 MCP 的工具定义翻译成模型 API 的工具参数,并说明两边名字不一样的地方
- 能给多服务端聚合设计一套命名前缀与降级策略,避免一个服务端挂掉拖垮整个循环
前四天你一直在写服务端:注册工具、暴露资源、搬到公网、加上授权。今天换到桌子的另一边。这个顺序是有意的——只有先把服务端能回的花样都实现过一遍,才知道客户端要接住多少种情况。读完回来把上面三条勾掉。
小白版讲解
客户端到底做什么
先破一个误解:很多人以为 MCP 客户端就是个转发器,把模型说的话原样递给服务端,再把服务端的话原样递回来。真写一遍就会发现,转发那部分只有几十行,剩下的全是边界。
回到万能插座那个比喻。服务端是电器,协议是插头标准,客户端是你家墙上那块带保护的插座面板:它认插头、它限流、它在某个电器冒烟的时候只跳自己那一路的闸,而不是让整栋楼停电。
具体是三条边界。
第一条是形状的边界。 MCP 的工具定义和模型 API 的工具参数不是同一个东西。字段名不一样,命名规则不一样,能表达的信息也不一样。中间必须有一次翻译,而翻译一定是有损的——损在哪,你得心里有数。
第二条是命名的边界。 一个宿主同时连着五个服务端很正常,规范只保证工具名在单个服务端内唯一。两个服务端各有一个 search,撞名是必然事件,不是意外。谁来消歧?只能是客户端。
第三条是失败的边界。 服务端跑在别人的进程里、别人的机器上。它会超时、会崩、会被用户手滑删掉。这些失败必须被挡在 Agent 循环之外——一个服务端挂掉,最坏的后果应该是少几个工具,而不是这一轮对话失败。
这三条边界,恰好就是今天要写的三段代码。
发现与翻译
发现分两步:server/discover 问一句「你支持哪个版本、有什么能力」,然后 tools/list 把工具全要过来。
第一步在这一版是可选的。规范要求每个服务端都必须实现 server/discover,但客户端可以不调——因为版本、身份、能力都写在每条请求的 _meta 里,你完全可以直接发 tools/list,对面不支持你的版本时会回一个 -32022 并附上它支持的版本列表,你再挑一个重发。调 server/discover 的好处是一次问清楚,省掉一轮试错;实现里我建议调,因为返回里还有 instructions 和缓存提示。
第二步有个坑:tools/list 是分页的。页大小由服务端说了算,你本机那个服务端一页发得完,用户装的那个可能十页。判据只有一条——nextCursor 缺失才是结束。不要用「这一页少于页大小」去猜,规范没保证服务端一定把页填满;更不要去解析游标,它对客户端是不透明的。
拿到工具之后是翻译。MCP 这边叫 inputSchema,模型 API 那边可能叫 input_schema 或者 parameters;MCP 这边有 title、annotations、outputSchema,模型 API 那边通常一个都没有。
// 一、翻完分页。判据只有一条:nextCursor 缺失才是结束
async function listAllTools(client: StdioClient): Promise<McpToolDef[]> {
const tools: McpToolDef[] = []
let cursor: string | undefined
for (let page = 0; page < 50; page++) {
// 游标对客户端不透明:原样带回去,不解析、不构造
const result = await client.request('tools/list', cursor === undefined ? {} : { cursor })
tools.push(...(result.tools as McpToolDef[]))
if (typeof result.nextCursor !== 'string') break // 空数组不是结束,缺 nextCursor 才是
cursor = result.nextCursor
}
return tools
}
// 二、翻译。丢掉的东西比看上去多,见下方表格
function toModelTool(alias: string, def: McpToolDef) {
return {
name: modelToolName(alias, def.name),
description: `[来自 ${alias}] ${def.description ?? def.name}`,
input_schema: def.inputSchema,
}
}# 一、翻完分页。判据只有一条:nextCursor 缺失才是结束
async def list_all_tools(client: StdioClient) -> list[dict]:
tools: list[dict] = []
cursor: str | None = None
for _ in range(50):
# 游标对客户端不透明:原样带回去,不解析、不构造
params = {} if cursor is None else {"cursor": cursor}
result = await client.request("tools/list", params)
tools.extend(result["tools"])
cursor = result.get("nextCursor")
if not isinstance(cursor, str): # 空数组不是结束,缺 nextCursor 才是
break
return tools
# 二、翻译。丢掉的东西比看上去多,见下方表格
def to_model_tool(alias: str, definition: dict) -> dict:
return {
"name": model_tool_name(alias, definition["name"]),
"description": f"[来自 {alias}] {definition.get('description', definition['name'])}",
"input_schema": definition["inputSchema"],
}翻译时丢掉的三样东西,每一样都有后果:
| 丢掉的 | 后果 | 客户端该怎么补 |
|---|---|---|
annotations | 模型不知道哪个工具是破坏性的 | 客户端自己按注解决定要不要弹确认框 |
outputSchema | 下游代码只能解析自然语言 | 有结构化返回就自己校验,别塞给模型再解析一遍 |
title | 界面上没有人类可读的名字 | 界面用 title,给模型的仍然是 description |
注解那一条要单独说。 规范写得很硬:客户端必须把工具注解当成不可信输入,除非它来自可信服务端。也就是说 readOnlyHint: true 不是「这个工具安全」的证明,它只是服务端的一句自我声明。把它当权限用,等于让被调用方自己给自己发通行证。这条为什么这么重要,明天整天都在讲。
把调用接回 Agent 循环
翻译完了,剩下的其实就是 30 天课里那个循环,一步没变。
Mermaid 源码
sequenceDiagram
participant U as 用户
participant L as Agent 循环
participant M as 模型
participant S as MCP 服务端
U->>L: 问题
L->>M: 消息 + 工具表
M-->>L: 我要调 mcp__notes__search
L->>S: tools/call name=search
S-->>L: content + isError
L->>M: 把结果作为一条消息塞回去
M-->>L: 最终回答
L-->>U: 回答有三件事值得盯住。
一次工具调用在循环里占两步。 模型的一次输出算一步,我们回填结果之后再问模型算另一步。所以循环必须有最大步数这个硬闸:模型完全可能在两个工具之间来回横跳,没有闸就是死循环加账单。
回填的时候要区分两类错误。 这是第 2 天讲过的分工,到客户端这边变成了两种写法:协议错误(工具不存在、参数结构不合法、版本不支持)是程序的 bug,模型改不了,该抛就抛;工具执行错误(isError 为真的成功响应)是给模型看的,要原样喂回去,让它换个参数再试。规范对此的措辞很准确:客户端可以把协议错误给模型,但应当把工具执行错误给模型。
我见过最常见的一个 bug 就是这里反了——有人图省事,把 isError 也抛成异常,于是模型永远等不到「你的日期格式不对,当前日期是某某」这句话,只能眼睁睁看着一次本来能自愈的调用变成一次失败。
还有一类失败必须被吞掉。 工具名不存在、服务端已掉线、调用超时,这些都不该让循环崩。正确做法是把它们翻译成一条 isError 的工具结果,模型看得见,就有机会改口。
多服务端聚合:前缀加在哪一层
现在把两个服务端接进来:一个笔记库,一个工单系统。它们各有一个叫 search 的工具。
规范对这件事的态度非常明确:工具名的唯一性只在单个服务端内成立;聚合多个服务端的客户端或代理可能遇到重名,应当实现一套消歧策略,比如给工具名加上服务端标识作为前缀。紧接着还有一句关键的限制:服务端自报的 serverInfo.name 不保证跨服务端唯一,不应当拿它来消歧。
这句话把答案钉死了:前缀必须来自客户端自己的配置。用户在配置文件里给每个服务端起一个本地别名,别名重复就在启动时报错——这是配置错误,要在用户第一次跑的时候就炸,而不是等模型调错工具。
名字怎么拼,还得看模型 API 的规矩。MCP 允许的工具名字符集是字母、数字、下划线、连字符和点,长度建议 128 以内;而模型 API 那边通常更严,比如只允许字母数字下划线连字符、最长 64 个字符。取两边的交集,本课约定拼成 mcp__别名__工具名 这种形状。超长了就截断再缀一段短哈希——截断本身会制造新的重名,哈希是用来把唯一性补回来的。
然后是最容易写错的一步:调用时发回服务端的必须是原名。带前缀的名字只在客户端与模型之间流通,服务端根本不认识它。
// 反查只走这张表。绝不能靠切字符串把名字拆回别名与原名:
// 工具原名里本来就允许有下划线(read_note、close_ticket),截断过的更是拆不回来
const entries = new Map<string, Entry>()
async function call(modelName: string, args: Record<string, unknown>) {
const entry = entries.get(modelName)
if (!entry) {
// 不抛:翻译成 isError 结果,模型看得见就有机会改口
return { isError: true, content: [{ type: 'text', text: `没有名为 ${modelName} 的工具` }] }
}
if (down.has(entry.alias)) {
return { isError: true, content: [{ type: 'text', text: `服务端 ${entry.alias} 当前不可用` }] }
}
try {
// 注意:发给服务端的是 entry.toolName(原名),不是带前缀的 modelName
return await clients[entry.alias].request('tools/call', { name: entry.toolName, arguments: args })
} catch (error) {
return { isError: true, content: [{ type: 'text', text: `调用失败:${String(error)}` }] }
}
}# 反查只走这张表。绝不能靠切字符串把名字拆回别名与原名:
# 工具原名里本来就允许有下划线(read_note、close_ticket),截断过的更是拆不回来
entries: dict[str, Entry] = {}
async def call(model_name: str, args: dict) -> dict:
entry = entries.get(model_name)
if entry is None:
# 不抛:翻译成 isError 结果,模型看得见就有机会改口
return {"isError": True, "content": [{"type": "text", "text": f"没有名为 {model_name} 的工具"}]}
if entry.alias in down:
return {"isError": True, "content": [{"type": "text", "text": f"服务端 {entry.alias} 当前不可用"}]}
try:
# 注意:发给服务端的是 entry.tool_name(原名),不是带前缀的 model_name
return await clients[entry.alias].request(
"tools/call", {"name": entry.tool_name, "arguments": args}
)
except Exception as error: # noqa: BLE001
return {"isError": True, "content": [{"type": "text", "text": f"调用失败:{error}"}]}还有一个小而实用的技巧:把来源写进描述里。名字前缀是给程序看的,描述才是模型选工具的唯一依据。在描述前面加一句「来自工单系统」,比只在名字里埋一个前缀更容易让模型选对。
失败隔离
聚合层最值得投入的地方不是命名,是失败。
原则一句话:发现阶段逐个服务端 try/catch,调用阶段一律不抛。
发现阶段,每个服务端的 server/discover 加 tools/list 包在一起 catch。失败了做两件事:把原因记进一张「掉线表」,然后继续下一个。记原因而不是只记一个布尔值——事后你要能回答「少了什么、为什么少」,而且可以把这句话写进系统提示,让模型知道工单系统这会儿用不了。
调用阶段前面已经说过:所有失败翻译成 isError 结果。
还有一件必须做但很多人忘的事:每条请求都要有超时。stdio 上服务端不回你就永远不回,那个 await 会一直挂着,整个 Agent 就卡在那儿。超时值怎么定没有标准答案,但不设超时一定是错的。
工具太多的时候
最后回答一个必然会遇到的问题:连了七八个服务端,工具表膨胀到一百多个,全塞进上下文里,用户还没开口就吃掉了大半个窗口。
MCP 这一层能做的只有一件事:按需挂载。官方的客户端最佳实践把它叫做把渐进式发现从工具级扩展到服务端级——宿主先维护一张「有哪些服务端可用」的目录,只在模型判断需要时才真正连上某个服务端,任务做完再断开、把上下文让出来。对通用型 Agent 这招特别有效,因为用户想干什么事先不知道。
再往下就不是 MCP 的事了:工具定义要不要按需注入、结果太长怎么裁剪、什么时候该换成让模型写代码去调工具——那是上下文工程的主场,去看工具结果与检索的上下文管理,本课只负责把 MCP 这一层的接口讲干净。
有一条跨界的坑值得先记下来:动态增删工具会打掉提示缓存。大多数模型服务缓存的是提示前缀,工具表就在前缀里,你中途加一个工具,缓存全失效,省下的那点定义 token 还不够赔的。所以要么把新发现的定义追加在缓存断点之后,要么干脆只暴露一个稳定的转发工具,让工具表从头到尾不变。
源码导读
动手实验
实验目录里的 servers/ 放了两个现成的服务端,零依赖、不联网,它们不是练习,是你的连接对象。它们被刻意做坏了两处:笔记库的 tools/list 每页只回 2 个工具,工单系统有一个和笔记库撞名的 search。跑起来 starter 是 4 项绿 5 项红,你的任务是把那 5 个红的变绿。
- 先读 solution 的 stdio-client.ts,找到 start、request、close 三个阶段,特别看 onLine 是怎么按 id 把响应配回请求的。
- 在 starter 的 onLine 里补上 error 分支,跑自测看第 2 项从红变绿——补之前它会一直等到超时才报错,那是最难查的一种慢。
- 把 listAllTools 改成循环翻页,再把 modelToolName 改成带别名前缀、超长截断加哈希,看第 3、5 项变绿。
- 给 refresh 的单服务端循环加 try/catch,把失败原因记进掉线表,看第 8 项变绿。
- 最后用 QUESTION 环境变量跑一轮完整的 Agent 循环,看模型选中的是带前缀的名字,而发给服务端的是原名。
面试题
今天 3 道题在下方题库区,侧重多服务端聚合的命名冲突、单服务端失败的处理方式、以及工具定义翻译时的信息损失。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能手写一个 stdio 客户端,完成发现、调用、错误处理三步
- 能把 MCP 的工具定义翻译成模型 API 的工具参数,并说明两边名字不一样的地方
- 能给多服务端聚合设计一套命名前缀与降级策略,避免一个服务端挂掉拖垮整个循环
- 能说出为什么不能用服务端自报的名字做前缀,以及为什么反查必须走表而不是切字符串
- 实验的 5 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D6)讲安全与治理。今天你已经碰到一句话:客户端必须把工具注解当成不可信输入。明天会把这句话推到底——工具描述、工具返回、状态句柄、令牌、本机服务端,一共五个入口,各能出什么事、各该怎么堵。今天写的这个客户端会在明天的实验里当靶子:你会亲手看到一段藏在工具描述里的指令,是怎么原封不动进到模型的上下文里的。
面试题库
你的客户端同时连了五个 MCP 服务端,其中两个都有一个叫 search 的工具。合并成一张工具表给模型时,重名该怎么处理?为什么不能直接拿服务端名做前缀?Your client is connected to five MCP servers and two of them expose a tool called search. How do you merge them into one tool list for the model, and why can't you just prefix with the server's name?
国内高频海外高频进阶#client#tool-naming分析过程 · 先想清楚再作答
- 这题在筛「有没有真的聚合过多个服务端」。只答「加个前缀」能拿一半分,题眼在后半句——为什么不能用服务端自报的那个名字。
- 先把前提摆正:规范只保证工具名在**单个服务端内**唯一,并且明确说聚合多个服务端的客户端或代理可能遇到重名,应当实现一套消歧策略。也就是说重名不是异常情况,是设计上就允许的,消歧责任在客户端这一层,服务端管不着。
- 再答后半句:服务端在 serverInfo 里自报的 name **不保证跨服务端唯一**,规范明说不应当拿它来消歧。它由服务端自己填,两个不相干的服务端都叫 github 完全合法;更糟的是它是不可信输入,一个恶意服务端可以故意把自己报成别人的名字,让模型把请求发到错的地方。所以前缀必须来自客户端自己的配置——用户在配置文件里给每个服务端起的本地别名,别名重复时在启动阶段直接报错,因为那是配置错误。
- 接着说名字怎么拼。MCP 允许字母数字下划线连字符和点、长度建议 128 以内;模型 API 那边通常更严,比如只允许字母数字下划线连字符、最长 64。取交集,超长就截断并缀一段短哈希——要主动说出为什么加哈希:截断本身会制造新的重名,哈希是把唯一性补回来的。
- 结论也是最容易被追问的一条:客户端必须留一张反查表,从带前缀的名字映射回「哪个服务端 + 原来的工具名」。调用时发给服务端的必须是**原名**,服务端根本不认识带前缀的那个。绝不能靠切字符串反推,因为工具原名里本来就允许有下划线,截断过的名字更是拆不回来。
- 可预期的追问一:光靠名字前缀够不够?答不够,模型选工具看的是描述,所以还应当把来源写进描述里。追问二:工具列表变了怎么办?服务端支持 listChanged 时会发通知,客户端收到就重新拉取并重建反查表;同时注意频繁增删工具会打掉提示缓存,因为工具表在缓存前缀里。
How to reason about it · think before answering
- The screen is whether you have actually aggregated multiple servers. 'Add a prefix' is half the answer; the real question is the second half, why the server's own name will not do.
- Set the premise straight: the spec guarantees tool-name uniqueness only within a single server, and explicitly says clients or proxies that aggregate multiple servers may hit collisions and should implement a disambiguation strategy. Collisions are permitted by design, and disambiguation is the client's job.
- Now the second half: the name a server reports in serverInfo is not guaranteed to be unique across servers, and the spec says it should not be relied upon for disambiguation. The server fills it in itself, two unrelated servers may both call themselves github, and worse, it is untrusted input, so a malicious server can impersonate another. The prefix must come from the client's own configuration, a local alias the user assigns per server, with duplicate aliases rejected at startup as a configuration error.
- Then the naming mechanics. MCP allows letters, digits, underscore, hyphen and dot with a suggested 128-character limit; model APIs are usually stricter, often letters, digits, underscore and hyphen with a 64-character cap. Take the intersection, and on overflow truncate plus append a short hash — say why: truncation itself creates new collisions, and the hash restores uniqueness.
- The conclusion, and the most likely follow-up: keep a reverse map from the prefixed name back to server plus original tool name. The call sent to the server must carry the original name, since the server has never heard of the prefixed one. Never recover it by string splitting, because original names may legitimately contain underscores and truncated names cannot be split back at all.
- Likely follow-ups: is the prefix enough? No, the model chooses by description, so put the source in the description too. And what about list changes? Servers declaring listChanged send a notification, on which the client refetches and rebuilds the map, keeping in mind that churning the tool list invalidates prompt caching because the tool array sits in the cached prefix.
答题要点
- 唯一性只在单个服务端内成立,聚合时重名是设计允许的,消歧责任在客户端
- 前缀必须来自客户端配置的本地别名,服务端自报的 name 不保证唯一且是不可信输入
- 名字取 MCP 与模型 API 的字符集与长度交集,超长截断并缀短哈希补回唯一性
- 留一张反查表,调用时发原名;不能靠切字符串反推,工具原名里本来就有下划线
Key points
- Uniqueness holds only within one server; collisions are expected on aggregation and the client owns disambiguation
- The prefix must come from a client-configured local alias, since the server-reported name is neither unique nor trustworthy
- Build names from the intersection of MCP and model-API charset and length limits; truncate plus a short hash on overflow
- Keep a reverse map and send the original name on calls; never split the prefixed string, as original names contain underscores
线上一个 MCP 服务端超时了。你的 Agent 循环应该怎么反应?One of your MCP servers times out in production. How should your agent loop react?
国内高频海外高频进阶#client#reliability分析过程 · 先想清楚再作答
- 这题看的是工程直觉:能不能把「一个依赖挂了」和「这一轮对话失败」分开。答「重试三次」是把问题往后推了一步,面试官会立刻追问重试期间用户在等什么。
- 先分阶段。超时发生在两个完全不同的时刻:发现阶段(server/discover 或 tools/list)和调用阶段(tools/call)。两个阶段的正确反应不一样,混着答就会露怯。
- 发现阶段:逐个服务端 try/catch,失败的记进一张掉线表并继续下一个。整张工具表少几个工具,但循环照常起得来。记的必须是原因而不是一个布尔值,因为事后你要能回答少了什么、为什么少。
- 调用阶段:把失败翻译成一条 isError 为真的工具结果喂回模型,不要抛。理由是 MCP 本来就用 isError 表达「工具执行失败但协议是成功的」,模型看得见这句话就有机会换个工具或换个参数;抛出去只会把整轮对话打断,而且用户什么解释都得不到。
- 接着补三件配套的事。一是**每条请求都必须有超时**,stdio 上服务端不回你就永远不回;二是**幂等性决定能不能重试**,工具注解里的 idempotentHint 是提示不是保证,写操作的重试要靠客户端自己的去重键;三是**掉线要让用户看得见**,把掉线的服务端标在界面上或写进系统提示,否则模型会表现得像那个能力从来不存在,一本正经地说查不到。
- 结论:一个服务端超时,最坏的后果应该是少几个工具加一条明确的说明,而不是这一轮对话失败。
- 可预期的追问:要不要熔断?连续失败到阈值就把这个服务端标记为不可用一段时间,避免每一轮都白等一次超时;恢复用探活或下一次会话重连。再追问会问到超时值怎么定——按工具而不是按服务端定,一个跑三十秒的分析工具和一个查缓存的工具不该共用一个阈值。
How to reason about it · think before answering
- This probes engineering instinct: can you separate 'one dependency is down' from 'this turn fails'. Answering 'retry three times' just moves the problem, and the interviewer will ask what the user is staring at meanwhile.
- Split by phase first. A timeout happens at two very different moments: discovery (server/discover or tools/list) and invocation (tools/call). The correct reaction differs, and blurring them shows you have not built this.
- Discovery: wrap each server in its own try/catch, record the failure with its reason in a down list, and continue to the next server. The tool table loses a few entries but the loop still starts. Record the reason, not a boolean, because afterwards you must be able to say what is missing and why.
- Invocation: translate the failure into a tool result with isError true and feed it back to the model rather than throwing. MCP already uses isError for 'the tool failed but the protocol succeeded', so the model can switch tools or arguments; throwing kills the turn and leaves the user with no explanation.
- Then three supporting points. Every request needs a timeout, because on stdio a silent server is silent forever. Idempotency decides whether a retry is safe, and the idempotentHint annotation is a hint, not a guarantee, so writes need a client-side dedup key. And outages must be visible, surfaced in the UI or in the system prompt, or the model will behave as if the capability never existed and confidently report nothing found.
- Conclusion: the worst outcome of one server timing out should be a few missing tools plus an explicit note, never a failed turn.
- Likely follow-ups: should you add a circuit breaker? Yes, after consecutive failures mark the server unusable for a while so you stop paying a timeout every turn, with recovery by health check or reconnect on the next session. And how do you set the timeout? Per tool rather than per server, since a thirty-second analysis tool and a cache lookup should not share a threshold.
答题要点
- 分阶段:发现阶段逐个服务端 try/catch 记进掉线表并继续,调用阶段一律不抛
- 调用失败翻译成 isError 为真的工具结果喂回模型,让它换工具或换参数
- 每条请求必须设超时;能不能重试取决于幂等性,注解只是提示不是保证
- 掉线必须对用户和模型可见,否则会变成静默降级,模型会假装那个能力不存在
Key points
- Split by phase: per-server try/catch during discovery with a recorded reason, and never throw during invocation
- Translate call failures into isError tool results so the model can switch tools or arguments
- Every request needs a timeout; retry safety depends on idempotency, and the annotation is a hint, not a guarantee
- Outages must be visible to user and model, otherwise silent degradation makes the model deny the capability ever existed
把 MCP 的工具定义翻译成模型 API 的工具参数时,最容易丢掉的是什么?丢了会怎样?When translating an MCP tool definition into a model API's tool parameters, what is most easily lost, and what goes wrong when it is?
国内高频海外高频深入#client#tool-schema分析过程 · 先想清楚再作答
- 这题在考「你知不知道这一步是有损的」。答「字段名对一下就行」的人多半没写过客户端,因为 MCP 的工具定义里有好几样东西在模型 API 那边根本没有对应位置。
- 先列清单再讲后果。丢的主要是三样:annotations(readOnlyHint、destructiveHint、idempotentHint 这类行为提示)、outputSchema(结构化返回的形状)、title(给人看的名字)。另外还有一样常被忽略的是分页——只取 tools/list 第一页等于把后面的工具整批丢掉。
- 逐条讲后果。annotations 丢了,模型不知道哪个工具是破坏性的,客户端也就没法自动决定要不要弹确认框——所以确认逻辑必须由客户端按注解自己做,不能指望模型自觉。outputSchema 丢了,下游只能靠解析自然语言拿数据,而且做代码模式(让模型写代码调工具)时生成不出准确的返回类型。title 丢了,界面上只能显示一串带前缀的机器名。
- 这里必须主动补一句最重要的:**注解本身是不可信输入**。规范要求客户端把工具注解当成不可信的,除非来自可信服务端。readOnlyHint 为真不是「这个工具安全」的证明,它只是服务端的自我声明。所以注解可以用来决定 UI 上要不要多问一句,但不能拿它当权限判据。
- 结论:翻译这一步的正确心态是「知道自己丢了什么,并在客户端补回来」。补法是——确认与拦截由客户端按注解做、结构化返回自己校验、界面用 title 而给模型用 description、分页翻到 nextCursor 消失为止。
- 可预期的追问:description 要不要改写?可以适度加工,比如在前面缀一句来源说明帮助模型在重名时选对,但不要重写语义——描述是服务端作者调过的,也是他们唯一能影响模型选择的地方。再追问可能是「outputSchema 缺失怎么办」,官方建议先用泛型接住往下游传,真需要类型时用一个小模型做一次抽取并校验,别在循环里做。
How to reason about it · think before answering
- This checks whether you know the step is lossy. Anyone answering 'just map the field names' has probably not written a client, because several parts of an MCP tool definition have no home on the model-API side.
- List first, then consequences. Three things go missing: annotations (readOnlyHint, destructiveHint, idempotentHint), outputSchema, and title. A fourth, often overlooked, is pagination — taking only the first page of tools/list silently drops whole batches of tools.
- Consequences one by one. Without annotations the model cannot tell which tool is destructive and the client has nothing to base a confirmation prompt on, so confirmation logic must live in the client and read the annotations directly. Without outputSchema, downstream code parses natural language, and code-mode generation cannot produce accurate return types. Without title, the UI can only show a prefixed machine name.
- Volunteer the most important caveat: annotations are untrusted input. The spec requires clients to treat tool annotations as untrusted unless they come from trusted servers. readOnlyHint being true is not proof of safety, only the server's own claim, so annotations may drive whether you ask the user, never whether the caller is authorized.
- Conclusion: the right posture is to know exactly what you dropped and compensate in the client — confirmation driven by annotations, structured results validated by you, title for the UI and description for the model, and pagination followed until nextCursor disappears.
- Likely follow-ups: may you rewrite the description? Light augmentation is fine, such as prefixing the source to help disambiguate collisions, but do not rewrite the meaning, since the description is what the server author tuned and their only lever on model choice. And what if outputSchema is absent? The official guidance is to accept a generic type and move on, or extract a typed result with a fast model outside loops and validate it.
答题要点
- 丢的是 annotations、outputSchema、title,外加只取第一页时整批丢掉的工具
- annotations 丢了就没法决定要不要弹确认框,确认逻辑必须由客户端按注解自己做
- 注解是不可信输入,只能驱动 UI 提示,不能当权限判据
- outputSchema 丢了下游只能解析自然语言;description 可以缀来源但不要重写语义
Key points
- Annotations, outputSchema and title are lost, plus every tool past the first page if pagination is ignored
- Without annotations there is nothing to drive a confirmation prompt, so that logic must live in the client
- Annotations are untrusted input: they may drive UI prompts, never authorization decisions
- Losing outputSchema forces downstream natural-language parsing; you may prefix the description but must not rewrite its meaning
评论
登录后即可参与讨论
还没有评论,来说第一句。