逐日AI
第 3 周 · D15约 4 小时

接入 MCP:手写 JSON-RPC 客户端、两种传输与工具命名空间

把别人的工具接进自己的 Agent:不用官方 SDK,手写一个 MCP 客户端,跑通 stdio 与 Streamable HTTP 两种传输,把远端工具聚合进本地清单并加命名空间,最后让它们复用第五天那道审批门。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能手写一个能完成初始化、列工具、调工具的 MCP 客户端
  2. 能说清 stdio 与 Streamable HTTP 两种传输各自的适用场景与失败模式
  3. 能把远端工具安全地聚合进本地工具清单,处理命名冲突与信任边界

前两周做的是一个自给自足的 Agent:每一个工具都是我们自己写的。第三周开始给它接外面的东西,第一站是别人的工具。读完回到页面顶部把三条目标勾掉。

小白版讲解

借用别的部门的工具间

前十四天,mca 会的每一件事都长在我们自己手里:读文件、改文件、跑命令、拍快照。今天要处理的是另一类需求——用户要它干的活,工具不在我们这儿。

比如:动手改代码之前先查一下团队笔记里有没有相关约定,改完之后看看工单系统里有没有对应的工单。这两样东西都不在这个仓库里,也不该由我们实现——笔记库是文档组维护的,工单系统是另一个部门的。

换成车间的说法:新人手边有自己的一套工具,但隔壁部门有一间工具间,里面的家伙事更专业。今天的事情就是给他一把那间工具间的钥匙,同时把三件事说清楚:里面都有什么、怎么借、出了事算谁的。

MCP(Model Context Protocol)就是这把钥匙的标准形状。它规定了工具间怎么挂牌子、怎么递工具、怎么报错,于是按这个标准写的工具和客户端可以互相认得。今天我们写客户端那一半。

这里必须先回答一个问题:为什么不用官方 SDK。理由和前十四天一样——这门课的卖点就是你能自己写出来。但今天还有一个额外理由:协议这一层的坑,用了 SDK 你一个都碰不到,而它们恰恰是线上出事的地方。 半行报文、分页没翻完、把通知当成响应、把别人的自我声明当成权限——这四件事 SDK 都替你挡了,于是你也永远不知道它们存在。

协议的三件事,与今天只用到的那一小半

先划范围。MCP 服务端提供工具、资源、提示词三类东西,今天只接工具——工具是模型自己会去调的,另外两类更像「人挑好了再塞进上下文」。

然后是版本。本课按 2026-07-28 这一版规范写,它和网上大量教程差别大到会把人绕进去。三条最要紧的:

一、协议是无状态的。 没有 initialize 握手,也没有握手完成那条通知。版本、身份、能力写在每一条请求_meta 字段里,每次都重复一遍。听起来啰嗦,好处是任何一次请求都可以打到任何一个副本上,不需要粘性路由。

二、服务端不再向客户端发起 JSON-RPC 请求。 它改成把「我还需要什么」塞进结果里返回,客户端补齐后换一个新 id 重发原请求。今天我们不实现这一支,但必须认得它——认不出来就会把一个「还没做完」的结果当成「做完了」交给模型。

三、传输只剩两种:stdio 与 Streamable HTTP。上一版那条单独的 GET 长连接、会话头、断流续传全部删除——不是弃用,是删除。

一条完整的请求长这样:

JSONJSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": { "query": "除数为 0" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": { "name": "mca", "version": "0.15.0" }
    }
  }
}

三个键各有分工。版本必填,服务端不认就回 -32022 并附上它支持的版本列表。能力也必填,我们这个客户端不弹表单、也不把模型借出去,所以老实声明成空对象。客户端身份是选填的自报家门,规范特别提醒它没经过任何验证,只能用来显示和记日志,不能拿来做安全判断

还有一个坑:_meta 加在 params 上,不是加在报文顶层。写错的表现是服务端回一句「你没声明能力」,而你明明写了——写在了它不看的地方。

手写 JSON-RPC:发号、配对、超时、收摊

协议这一层的代码其实很短,因为它只做四件事。

发号:每条请求一个自增 id,它只需要在这条连接上唯一。配对:收到消息按 id 找回在等它的那个 Promise,没有 id 的是通知不是响应超时:stdio 上服务端不回你就永远不回,那个 await 会一直挂着,整个 Agent 就停在那儿;超时值定多少没有标准答案,但不设一定是错的收摊:传输死掉时把所有在等的请求一次性失败掉,否则服务端起不来时每条请求都要各等满一次超时,一次发现就是十几秒干等。

src/mcp/client.ts
request(method: string, params: Record<string, JsonValue> = {}) {
  if (this.dead) return Promise.reject(this.dead)
  const id = this.nextId++
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      this.pending.delete(id)
      reject(new McpError(`${method} 超时`))
    }, this.timeoutMs)
    this.pending.set(id, { resolve, reject, timer })
    this.transport.send({ jsonrpc: '2.0', id, method, params: withMeta(params) })
  })
}
 
private onMessage(message: JsonRpcMessage): void {
  // 没有 id 的是通知,不是响应。按下标或到达顺序配对的客户端会在这里错位
  if (typeof message.id !== 'number') return
  const waiter = this.pending.get(message.id)
  if (!waiter) return // 迟到的响应:早就超时了。丢掉,但不要报错
  this.pending.delete(message.id)
  clearTimeout(waiter.timer)
  if (message.error) reject_(waiter, message.error)
  else waiter.resolve(message.result ?? {})
}

拿工具清单还有一个必踩的坑:tools/list 是分页的。你本机那个一页发得完,用户装的那个可能十页。判据只有一条——没有下一页游标才是结束。不要用「这一页比上一页少」去猜(规范没保证每页填满),更不要去解析游标(它对客户端不透明,原样带回去就行)。写错的表现特别隐蔽:用户少看见几个工具,而没有任何东西报错。 演示笔记库一共三个工具、每页只回两个,就是为这一条准备的。

stdio:一条永不结束的流,边界要自己划

两种传输的差别不在于谁快,而在于一条消息的边界由谁划。stdio 这一侧字节是一条永不结束的流,边界要自己按换行切,于是有了今天最容易写错的一行代码。直觉写法是这样:

TextText
child.stdout.on('data', chunk => {
  for (const line of chunk.split('\n')) handle(line)   // 这一行是错的
})

错在哪:一次 data 事件拿到的不是一行,而是「上次剩下的半行 + 这次的若干行 + 这次剩下的半行」。操作系统只保证字节顺序,不保证边界。小报文一切正常,一个几 KB 的工具结果一定会被切开,于是解析开始失败——而且很不稳定:本机好好的,换台机器就坏。正确做法是留一个缓冲区,只把带换行的那部分交出去

src/mcp/transport.ts
export class LineFramer {
  private buffer = ''
 
  /** 喂一块字节,吐出这一块里所有完整的行 */
  push(chunk: string): string[] {
    this.buffer += chunk
    const parts = this.buffer.split('\n')
    // 最后一段没有换行结尾,它是半行,留到下一次
    this.buffer = parts.pop() ?? ''
    return parts.map((line) => line.trim()).filter((line) => line.length > 0)
  }
 
  /** 流结束时把剩下的半行交出来,否则最后一条报文会丢 */
  flush(): string[] {
    const rest = this.buffer.trim()
    this.buffer = ''
    return rest ? [rest] : []
  }
}

stdio 剩下的坑全在进程生命周期上,三条一起记住。

服务端的日志只能走 stderr。 它往标准输出多打一个字,客户端的解析就开始报错,而报出来的错长得像「协议坏了」,跟日志毫无关系。客户端这边对应的护栏是:一行解析不了只记一笔,不要杀掉整个传输

子进程不会跟着父进程一起死。 退出时要显式杀掉,顺序是先关标准输入再杀进程。漏了这一步,跑几次之后机器上会留一串孤儿进程。

起不来要立刻暴露。 命令拼错、依赖没装走的是进程的 error 事件而不是退出事件,两条都要接住并转成「所有在等的请求一起失败」。

Streamable HTTP:一次 POST,两种响应形状

HTTP 这一侧边界由 HTTP 自己划,不用分帧,代价是别的地方复杂了。

第一件事是必填请求头。 这一版给每个 POST 加了三个头,把请求体里的关键字段镜像到头上:

httphttp
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

为什么要抄一遍?因为中间层不该为了路由去解析请求体。网关想按方法名分流、想给调用类请求单独限速,只看头就够了。而既然中间层按头做决策、服务端按体做执行,两边不一致就是一个漏洞:一个配了「列工具免鉴权、调工具要鉴权」的网关,遇到头写列工具、体写调工具的请求就被绕过去了。所以规范规定服务端必须校验头体一致,不一致回 400 加 -32020。演示服务端真的实现了这条校验,自检里也真的发了一条头体不一致的请求去撞它。

顺带一条编码坑:HTTP 头值只能是可见 ASCII,工具名叫「查天气」就表示不了。规范给了一个把字节 Base64 之后包起来的哨兵格式,服务端必须先解码再比对

第二件事是响应有两种形状,由服务端临时决定。 简单请求回一个普通 JSON,可能慢的请求回一条事件流:中间夹着进度通知,最后一条才是带 id 的响应。所以客户端两种都得能收,而且不能假设「流里最后一条就是响应」——服务端完全可以在结果之后再发一条日志通知,判据只有一个:带 id 且 id 对得上的那条才是响应。 配套的还有一条:流结束不等于请求成功,中途断掉要主动报错,否则调用方会一直等到超时,而真正的原因再也看不见了。

演示工单系统就是这么回的:一次调用先发一条进度通知再发结果,自检断言的就是「收到 1 条通知 + 1 条按 id 配对的响应」——把通知当成响应的客户端会拿到一句「正在查工单系统」然后以为自己查完了。

两种传输什么时候用哪个,一句话:本地的、跟着你机器走的用 stdio;跨机器的、多人共用的用 HTTP。 失败方式正好对称——stdio 是进程起不来、日志污染标准输出、孤儿进程;HTTP 是连不上、流中断、被中间层改写。

聚合与命名空间:两个 search 怎么办

现在把两个服务端一起接进来:一个笔记库(stdio),一个工单系统(HTTP)。它们各有一个叫 search 的工具——这不是意外,是必然事件:规范只保证工具名在单个服务端内唯一。消歧只能由客户端做,而且有一条硬性限制——服务端自报的名字不保证跨服务端唯一,不该拿它消歧。所以前缀必须来自我们自己的配置别名,别名重复就是配置错误,要在启动时炸而不是等模型调错工具。名字形状还得看模型 API 的规矩:多数只允许字母数字下划线连字符、最长 64 个字符,取交集,本课约定拼成 mcp__别名__工具名

src/mcp/mount.ts
export function namespacedName(alias: string, toolName: string): string {
  const raw = `mcp__${alias}__${toolName}`.replace(/[^A-Za-z0-9_-]/g, '_')
  if (raw.length <= 64) return raw
  // 截断本身会制造新的重名,哈希是用来把唯一性补回来的
  const digest = createHash('sha1').update(`${alias}::${toolName}`).digest('hex').slice(0, 6)
  return `${raw.slice(0, 57)}_${digest}`
}
 
// 反查只走这张表。绝不能靠切字符串把名字拆回别名与原名:
// 工具原名里本来就允许有下划线,被截断加过哈希的更是拆不回来
entryOf(localName: string) {
  return this.entries.get(localName)
}

接下来是最容易写错的一步:调用时发回服务端的必须是原名。 带前缀的名字只在模型与客户端之间流通,服务端根本不认识它,这个 bug 的表现是所有远端调用都回一句「没有这个工具」。还有一个小技巧:把来源写进描述里,因为描述才是模型选工具的唯一依据。

聚合层剩下的投入全应该花在失败上,原则一句话:发现阶段逐个 try/catch,调用阶段一律不抛。 发现时每个服务端单独包起来,失败了记原因、继续下一个;调用时所有失败都翻译成一条失败的工具结果,模型看得见才有机会改口。本实验把工单系统指到一个空端口上,笔记库那三个工具照常可用——一个服务端挂掉,最坏的后果应该是少几个工具,不是这一轮对话失败。

最后一条别忘:掉线要让用户看得见。 悄悄把掉线的服务端从清单里拿掉,模型会表现得像那个能力从来不存在,只说「我查不到相关工单」。少了一只手是事故,装作从来没长过那只手是更大的事故。

信任边界:谁给谁发通行证

今天最后一个决定,也是唯一一个和安全直接相关的决定。

远端工具的定义里可以带一组注解,其中有一个是「这是只读的」。看起来正好能用:只读的直接放行,别的才问用户。但这条不能采信。理由一句话:那是被调用方给自己发的通行证。 规范本身也写得很硬:客户端必须把工具注解当成不可信输入。

所以本课的口径写死一条:远端工具一律按「会改东西」对待,全部落进第五天那道审批门的 ask 分支。权限三态与规则匹配第五天已经讲透,这里一个字都不重讲——今天要做的只是别绕过它

同样白拿的还有第三天那条截断:远端结果和本地结果共用同一份 8000 字符的预算。本实验读一篇故意写得很长的笔记,结果被截到 8052 字符(8000 的正文加上一句说明截掉了多少),走的就是本地那条路。

这一切之所以成立,是因为远端工具在循环眼里只是又一个工具定义。今天的实验里内核一行都没改:截断、审批、打转检测、快照全都自动生效。这是第二天那个分层决定的又一次分红——只要新能力能表达成一个工具,它就免费继承前十四天攒下的全部机制。

一条边界也要说清楚:挂载必须在建会话之前,因为工具清单是一次性造出来的。真要做「运行中挂载」,代价是动态增删工具会打掉提示缓存(工具表就在提示前缀里),那是另一个决定,本课不做。

源码导读

动手实验

🧪 D15 实验:一个零依赖的 MCP 客户端,能同时挂载 stdio 与 HTTP 两个 server

代码位置:labs/my-coding-agent-21days/day-15-mcp-client

实验目录里的两个演示服务端不是练习,是你的连接对象:笔记库跑在子进程上、一共三个工具但每页只回两个,工单系统跑在 3115 端口、和笔记库撞了一个 search。今天挖了五个练习点,全是「直觉写法在小数据上一切正常」的坑:按换行直接切帧、只取第一页、把流里最后一条当成响应、采信服务端自报的只读声明、把带命名空间的名字发回服务端。起点代码原样跑是十四项里过四项。

  1. 给分帧器补上缓冲区,然后看第一项与那条读长笔记的断言从红变绿——后者的失败原因正是几 KB 的结果被切开了。
  2. 把取工具清单改成循环翻页,判据用「没有下一页游标」,看笔记库从两个工具变成三个。
  3. 让事件流那一支把每条消息都交上去、用 id 判断哪条才是响应,然后看通知条数从 0 变成 1。
  4. 把远端工具的只读标记改成一律为假,看那个自称只读的搜索工具也停下来等审批。
  5. 把发回服务端的名字改成原名,跑 MOCK=1 SELFTEST=1 pnpm start 看到 14 项全过,再用 README 里那几条管道命令看命名空间、审批与失败隔离。

验收看五条勾:自检 14 项全过;笔记库翻页翻出三个工具;一次远端调用收到一条通知加一条按 id 配对的响应;两个 search 在清单里互不打架且能反查回原名;把工单系统指到空端口时笔记库照常可用、调它的工具拿到的是一条失败结果而不是异常。

面试题

今天三道题,考的是协议客户端的实现判断,不是「MCP 是什么」:

  1. 不用 SDK 手写 MCP 客户端,要处理哪些协议细节?
  2. stdio 与 Streamable HTTP 两种传输,分别适合什么场景?失败方式有什么不同?
  3. 把第三方工具聚合进自己的 Agent,你会加哪些治理措施?

完整题干、分析过程与答题要点见本课面试题库的第十五天。第三题最有区分度——多数人会答「加前缀防重名」,能说出「服务端的只读声明不能当权限用」并解释原因的人很少。

检查清单与明日预告

  • 能说清无状态协议下版本与能力写在哪里,以及写错位置会怎样
  • 能说出手写 JSON-RPC 客户端的四件事:发号、按 id 配对、超时、传输死掉时一次性收摊
  • 能解释为什么「没有 id 的是通知」这条判断不能省
  • 能说出翻页的唯一判据,以及写错之后为什么不会有任何东西报错
  • 能解释半行报文是怎么来的,以及为什么它只在大结果上暴露
  • 能说出 stdio 的三条生命周期纪律:日志走 stderr、显式收摊、起不来要立刻暴露
  • 能说清三个必填请求头为什么存在,以及头体不一致为什么是漏洞
  • 能说出为什么不能假设「事件流里最后一条就是响应」
  • 能解释命名空间前缀为什么必须来自客户端自己的别名,反查为什么只能走表
  • 能说出为什么服务端自报的只读声明不能用来决定要不要审批

明天是 D16《加载 Skills:扫描、渐进披露与触发判定,让经验按需上桌》。今天接的是别人的工具,明天接的是别人的经验——差别值得先想一想:工具要占工具清单的位置,而经验只是一段文字,它凭什么也要讲「按需加载」。

面试题库

  • 不用官方 SDK 手写一个 MCP 客户端,你必须自己处理哪些协议细节?If you hand-write an MCP client without the official SDK, which protocol details do you have to handle yourself?
    国内高频海外高频基础#json-rpc#mcp-client

    分析过程 · 先想清楚再作答

    1. 这题在考「你是不是真写过一个协议客户端」。只用过 SDK 的人会答「连上、列工具、调工具」三步,写过的人会先说边界。
    2. 怎么拆:把它拆成协议形状、请求应答、分页、错误分类四块,每块各有一个必须自己做的决定。
    3. 协议形状这一块,2026-07-28 版是无状态的:没有握手,版本、身份、能力写在每一条请求的 _meta 里,而且是加在 params 上不是加在报文顶层。
    4. 请求应答这一块有四件事:发号、按 id 配对、超时、传输死掉时把所有在等的请求一次性失败掉。关键判断是「没有 id 的是通知不是响应」,按到达顺序配对一定会错位。
    5. 分页这一块判据唯一:没有下一页游标才是结束。不能用「这一页比上一页少」去猜,也不能解析游标——它对客户端不透明。
    6. 错误分类这一块要分两类:响应里带 error 的是协议错误,模型改不了;结果里标了执行失败的是给模型看的,要原样回灌让它换个参数。把后者也抛成异常,一次本来能自愈的调用就变成一次失败。
    7. 可预期的追问:为什么迟到的响应要丢掉而不是报错;超时值怎么定;客户端能力声明成空对象会有什么后果。

    How to reason about it · think before answering

    1. What is tested is whether you have actually written a protocol client. People who only used an SDK answer "connect, list tools, call tools"; people who wrote one start from the edge cases.
    2. How to break it down: protocol shape, request/response correlation, pagination, and error classification — each has a decision you must make yourself.
    3. Protocol shape - the 2026-07-28 revision is stateless. There is no handshake; version, identity and capabilities travel in the _meta of every single request, and _meta belongs on params, not at the top level of the envelope.
    4. Correlation needs four things - allocate ids, match responses by id, enforce a timeout, and fail every in-flight request at once when the transport dies. The key judgment is that a message without an id is a notification, not a response; matching by arrival order will drift.
    5. Pagination has exactly one criterion - you are done when there is no next cursor. Do not guess from a short page, and never parse the cursor; it is opaque to clients.
    6. Error classification splits in two - an error field in the response is a protocol error the model cannot fix, while a result flagged as a failed execution is meant for the model and must be fed back verbatim. Throwing on the latter turns a self-healing call into a hard failure.
    7. Likely follow-ups - why a late response is dropped instead of raised; how you pick a timeout; what happens if you declare an empty client capability set.

    答题要点

    • 无状态协议:没有握手,版本与能力写在每条请求的 _meta 里,且挂在 params 上
    • 请求应答四件事:发号、按 id 配对、超时、传输死掉时一次性收摊;没有 id 的是通知
    • 分页只认「没有下一页游标」,游标不透明、原样带回
    • 协议错误抛出去,工具执行错误原样回灌给模型
    • 迟到的响应丢掉但不报错;不设超时一定是错的

    Key points

    • Stateless protocol - no handshake; version and capabilities ride in the _meta of every request, attached to params
    • Four correlation duties - allocate ids, match by id, time out, fail all pending requests when the transport dies; a message without an id is a notification
    • Pagination ends only when there is no next cursor; the cursor is opaque and must be echoed back unchanged
    • Raise protocol errors; feed tool execution errors back to the model verbatim
    • Drop late responses silently; having no timeout at all is always wrong
  • stdio 与 Streamable HTTP 两种传输分别适合什么场景?它们的失败方式有什么不同?When would you use the stdio transport versus Streamable HTTP, and how do their failure modes differ?
    国内高频海外高频进阶#transports#stdio-vs-http

    分析过程 · 先想清楚再作答

    1. 这题在考「你有没有两种都真的接过」。只接过一种的人会从性能答起,两种都接过的人会先说边界由谁划。
    2. 怎么拆:先说适用场景,再说消息边界,最后把失败方式对照着列——第三块才是区分度所在。
    3. 适用场景:本地的、跟着用户机器走的、不需要鉴权的用 stdio;跨机器的、多人共用的、要鉴权与限流的用 HTTP。
    4. 消息边界:stdio 上字节是一条永不结束的流,边界要自己按换行切,所以必须留缓冲区处理半行报文——这个 bug 只在结果大到被切开时才暴露,小报文测不出来。HTTP 上边界由 HTTP 划,但响应有两种形状,事件流那一支里混着通知,判据是「带 id 且 id 对得上的那条才是响应」。
    5. 失败方式:stdio 是进程起不来、服务端把日志打进标准输出污染报文、父进程退出留下孤儿进程;HTTP 是连不上、流中途断开、被中间层改写或缓冲。
    6. 一条容易漏的:2026-07-28 删掉了断流续传,流断了这次请求就是丢了,必须换一个新 id 重发,协议这一层不做补偿投递。
    7. 可预期的追问:为什么服务端的日志只能走 stderr;流结束为什么不等于请求成功;两种传输能不能共用同一个客户端实现(能,把传输抽成只管收发字节的接口)。

    How to reason about it · think before answering

    1. This tests whether you have actually wired up both. People who used only one start from performance; people who used both start from who draws the message boundary.
    2. How to break it down - fit first, then message framing, then failure modes side by side. The third part is where candidates separate.
    3. Fit - stdio for local servers that travel with the user's machine and need no auth; HTTP for cross-machine, multi-user servers that need auth and rate limiting.
    4. Framing - on stdio the bytes are one endless stream and you must split on newlines yourself, which means buffering partial lines. That bug only shows up once a result is large enough to be chopped, so small payloads never reveal it. Over HTTP the boundary comes from HTTP, but a response has two shapes, and the event-stream shape interleaves notifications, so the rule is that only a message carrying a matching id is the response.
    5. Failure modes - stdio fails by a process that will not start, a server writing logs to stdout and corrupting the frames, and orphaned children after the parent exits. HTTP fails by refused connections, streams cut mid-flight, and middleboxes rewriting or buffering.
    6. Easy to miss - the 2026-07-28 revision removed stream resumption, so a broken stream means that request is lost and must be re-sent under a brand new id. The protocol layer does no compensating delivery.
    7. Likely follow-ups - why server logs must go to stderr; why the end of a stream does not imply success; whether one client implementation can serve both transports (yes, by abstracting the transport down to moving bytes).

    答题要点

    • stdio 适合本地、单用户、无鉴权;HTTP 适合跨机器、多用户、要鉴权与限流
    • stdio 的边界要自己划,必须处理半行报文;这个 bug 只在大结果上暴露
    • HTTP 的响应有两种形状,事件流里混着通知,判据是 id 对得上
    • stdio 的典型失败:起不来、日志污染标准输出、孤儿进程
    • HTTP 的典型失败:连不上、流中断、被中间层改写;流断了必须换新 id 重发

    Key points

    • stdio fits local, single-user, no-auth servers; HTTP fits cross-machine, multi-user servers needing auth and rate limits
    • On stdio you draw the boundaries yourself and must buffer partial lines; the bug only surfaces on large results
    • HTTP responses come in two shapes; the event stream interleaves notifications, and only a matching id marks the response
    • Typical stdio failures - process will not start, logs poison stdout, orphaned children
    • Typical HTTP failures - refused connection, stream cut mid-flight, middlebox rewriting; a broken stream must be re-sent under a new id
  • 把第三方 MCP server 的工具聚合进自己的 Agent,你会加哪些治理措施?What governance would you put in place before aggregating third-party MCP server tools into your own agent?
    国内高频海外高频深入#trust-boundary#tool-governance

    分析过程 · 先想清楚再作答

    1. 这题在考信任边界,而不是功能。多数人只答「加前缀防重名」,那只是治理里最浅的一层。
    2. 怎么拆:分成命名、信任、失败、预算四层,每层给一条具体措施和一条反例。
    3. 命名层:前缀必须来自客户端自己的配置别名,因为规范只保证工具名在单个 server 内唯一,而且明说服务端自报的名字不保证跨 server 唯一、不该拿它消歧。别名撞了要在启动时报错。反查只能走一张表,不能切字符串——原名里本来就允许有下划线,截断加过哈希的更是拆不回来。调用时发回 server 的必须是原名。
    4. 信任层是这题的题眼:服务端在工具注解里声明的只读提示不能当权限用,那是被调用方给自己发的通行证,规范要求客户端把工具注解当成不可信输入。稳妥口径是远端工具一律按「会改东西」对待,全部过审批门。同理,工具描述是别人写的文本,会进模型上下文,属于提示注入面。
    5. 失败层:发现阶段逐个 try/catch 并记下原因,调用阶段一律不抛、全部翻译成一条失败的工具结果;掉线要让用户看得见,静默降级会让模型说「我查不到」而不是「那个系统连不上」。
    6. 预算层:远端结果和本地结果共用同一份截断预算;工具太多时按需挂载,但要知道动态增删工具会打掉提示缓存。
    7. 可预期的追问:怎么防止一个 server 把整个工具表撑爆;工具描述算不算不可信输入;要不要给远端调用单独记审计日志。

    How to reason about it · think before answering

    1. This tests trust boundaries, not features. Most people only say "prefix the names to avoid collisions", which is the shallowest layer of governance.
    2. How to break it down - naming, trust, failure, budget. Give one concrete measure and one counter-example per layer.
    3. Naming - the prefix must come from your own configured alias, because the spec only guarantees tool-name uniqueness within a single server and explicitly says a server's self-reported name is not unique across servers and must not be used to disambiguate. Duplicate aliases should fail at startup. Reverse lookup goes through a table, never string splitting, since original names may contain underscores and truncated-plus-hashed names cannot be split back. The name sent back to the server must be the original one.
    4. Trust is the crux - a readOnly hint in the tool annotations cannot be used as a permission decision. It is a pass the callee issued to itself, and the spec requires clients to treat tool annotations as untrusted input. The safe stance is to treat every remote tool as state-changing and route all of them through the approval gate. By the same logic, tool descriptions are text written by someone else that lands in the model's context, so they are prompt-injection surface.
    5. Failure - wrap discovery per server in try/catch and record the reason; never throw during a call, translate every failure into a failed tool result. Make outages visible, because silent degradation makes the model say "I could not find anything" instead of "that system is unreachable".
    6. Budget - remote results share the same truncation budget as local ones, and when there are too many tools, mount on demand while knowing that adding or removing tools mid-session invalidates the prompt cache.
    7. Likely follow-ups - how to stop one server from blowing up the whole tool table; whether tool descriptions count as untrusted input; whether remote calls deserve their own audit log.

    答题要点

    • 命名空间前缀来自客户端配置的别名,别名冲突在启动时报错;反查走表不切字符串;发回 server 的是原名
    • 服务端自报的只读注解不可信,远端工具一律按会改东西对待、全部过审批门
    • 工具描述是别人写的文本且会进上下文,属于提示注入面,要当成不可信输入
    • 发现阶段逐个 try/catch 记原因,调用阶段一律不抛;掉线要让用户看得见,不做静默降级
    • 远端结果共用本地那份截断预算;按需挂载可以省上下文,但会打掉提示缓存

    Key points

    • Namespace prefixes come from client-side aliases; duplicate aliases fail at startup; reverse lookup uses a table, and the original name is what goes back to the server
    • A server's self-declared read-only annotation is untrusted; treat every remote tool as state-changing and route it through the approval gate
    • Tool descriptions are third-party text that enters the model's context, so they are prompt-injection surface and must be treated as untrusted input
    • Wrap discovery per server and record reasons; never throw during a call; make outages visible instead of degrading silently
    • Remote results share the local truncation budget; on-demand mounting saves context but invalidates the prompt cache

评论