从0打造编码Agent 阅读 + 实验 ≈ 35 分钟 页内实验 ×2
A2 工具系统:模型怎么说清「改哪里」? 第 4 课

外部工具怎么接进来?MCP 放在哪一层?

外部工具与 MCP
本机实跑三个真实 MCP server:chrome-devtools-mcp 的 30 个工具,在项目模型的 chat template 里占 7,047 token,是 miniagent 自己 6 个工具的 10 倍,而且每一轮都要重发。MCP 是 harness 接工具与资源的协议,模型从不直接说 MCP;2026-07-28 版去掉了握手、废弃了 Sampling。工具多了就只放名字,用到再取。
❓

本课一问:到上一课为止,工具都是 harness 自己写的。用户还想让 agent 操作浏览器、查数据库、读公司的工单系统——这些外部工具怎么接进来?业界的答案叫 MCP,它该放在 harness 的哪一层?

这一课先不碰:外部工具执行危险操作谁来拦(A3、A4);让模型写脚本批量调工具(岔路课 Code Mode);技能文件 SKILL.md 经 MCP 分发(A6)。

从已有出发

每一轮请求都带着全部工具定义:本课项目 miniagent 的 6 个工具,在第 1 轮请求里占 685 个 token,而 system prompt 加任务描述加模板标记一共只有 214 个(岔路课 B1 逐轮拆过)。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

贯穿任务本身用不上外部工具——修 dateparse.py 只要读、改、跑测试。所以这一课换一个例子:假设用户在同一个项目里还接了浏览器调试工具(Chrome DevTools)、网页自动化(Playwright)和 MCP 官方的参考 server。这三个 server 都是真实的 npm 包,本课在本机实际启动它们、拿到了它们的工具清单(2026-09-26)。

先自己设计:接进来的工具,全部塞进每一轮

最省事的接法:启动时把每个 server 的工具清单拿来,原样加进工具区,每一轮都发。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案启动时把所有外部工具的完整定义加进工具区

在下面的条上,把三个 server 都切到「全量加载」。每一段是一个工具,悬停看它是谁、多少 token。

这一轮请求里,工具定义占多少 token

口径:token 数用项目录制模型 Qwen3-8B 的 tokenizer 与 chat template 算——miniagent 的 6 个工具合计 685 token,第 1 轮完整 prompt 899 token,与 trace 里服务端返回的 usage.prompt_tokens 一致;换成别家模型,tokenizer 与模板不同,数会变。MCP 工具定义来自本机实跑 tools/list(@modelcontextprotocol/server-everything 2026.8.31、@playwright/mcp 0.0.80、chrome-devtools-mcp 1.10.1,2026-09-26);「deferred」按工具名文本计,不含 server instructions。OpenCode:只计 .txt 描述原文;参数 JSON schema 未计入。bash 的描述在运行时由 shell/prompt.ts 按平台渲染,这里计的是模板 shell.txt;task 的描述运行时会追加可用子 agent 列表。websearch 只对 opencode 自家 provider 或开了 exa/parallel 开关时出现(registry.ts:58-65),question 只在 app/cli/desktop 客户端出现(:207)。「缓存」提示依据 Claude Code 官方 prompt-caching 文档(2026-09-26 核对)。

还有第二笔账:缓存。每一轮的请求前缀如果和上一轮一样,服务端可以复用上一轮算过的部分(A8 细讲)。工具定义就在前缀里,所以中途增删一个完整的工具定义,前缀就变了。Claude Code 的缓存文档把两种情况写得很清楚:「Tools loaded into the prefix: adding a definition invalidates the cache」;而 deferred 的工具只在末尾追加名字,「doesn’t disturb anything already cached」 。在上面的条上切换时,条下方会提示这次切换属于哪一种。

规模再大一档,Anthropic 的工程博客《Code execution with MCP》给过一个量级:把工具定义与中间结果都塞进上下文的做法,和让模型写代码按需调工具相比,token 从 150,000 降到 2,000,「a time and cost saving of 98.7%」 。那是岔路课 Code Mode 的话题。

改进:MCP 到底是什么,放在哪一层

先把位置钉死。MCP(Model Context Protocol)是 harness 和外部工具之间的协议:harness 里有一个 MCP client,它通过 stdio 或 HTTP 和各个 MCP server 说 JSON-RPC,拿到工具清单、替模型发起调用、拿回结果。模型从来不直接说 MCP:它看到的仍然是 harness 发来的工具定义,它写出的仍然是 A1 里那种 tool_call 文本。

下面是一次真实的往返,三条泳道:模型 API、harness(MCP client)、MCP server。

一次 MCP 往返 · 三条泳道

轨道 ①:本机实跑 @modelcontextprotocol/server-everything 2026.8.31(serverInfo mcp-servers/everything 2.0.0),stdio,2026-09-26;客户端是 gen_a02_tools_data.py 里的最小 JSON-RPC 客户端,消息为原始字节(只格式化了缩进)。轨道 ②:modelcontextprotocol.io 的 2026-07-28 规范原文示例(server/discover 页、schema 示例 list-tools-request.json、server/tools 页),2026-09-26 核对。

🎯

试三件事:① 在轨道 ① 的前两步看「新客户端遇到旧 server」:先问 server/discover,被回 -32601 Method not found,于是回落到旧握手——这是 2026-09 的真实现状;② 走到第 9 步「harness 翻译」:MCP 的 inputSchema 被改名成 parameters,annotations 等字段被丢掉,这才是模型看到的样子;③ 切到轨道 ②,对比 2026-07-28 版:没有 initialize,每个请求自带 _meta。

2026-07-28 版改了什么

截至 2026-09-26,MCP 的最新规范是 2026-07-28 版,之后没有新版本 。和本课有关的四条:

  1. 无状态:去掉了 initialize / notifications/initialized 握手和 Mcp-Session-Id 会话头;每个请求在 _meta 里自带 io.modelcontextprotocol/protocolVersion、clientCapabilities(以及建议带上的 clientInfo);新增必须实现的 server/discover。
  2. 废弃 Sampling、Roots、Logging:Sampling 让 server 反过来请 client 替它调模型,规范给的迁移建议是「integrate directly with LLM provider APIs instead of Sampling」。
  3. 废弃不等于移除:规范的功能生命周期规定废弃窗口「at least twelve」个月,所以这三项最早要到 2027-07-28 之后才可能被移除;ping、logging/setLevel 等几项则这一版就直接删了。
  4. 工具清单要稳定排序:server「SHOULD return tools from tools/list in a deterministic order」,理由正是提高客户端缓存和 prompt 缓存的命中率——和上面那笔缓存账是同一件事。

落地是渐进的(2026-09-26,各家官方记录):

客户端对 2026-07-28 的支持依据
Claude Codev2 MCP client 默认只和 HTTP server 协商新版;stdio server 仍走旧握手,要设 MCP_PROTOCOL_NEGOTIATION=auto官方文档与 CHANGELOG
Codexv0.147.0 起可选开启,feature flag mcp_2026_07_28;是否已默认开启未核实官方 release notes
OpenCode依赖的 @modelcontextprotocol/sdk 是 1.29.0package.json;推断尚未支持(新版随 TS SDK 2.0 提供)
本课实测的 server-everythingSDK 1.30.0,回 server/discover 为 -32601本机实跑,见上面轨道 ①

CoreCoder v0.7.0 的 MCP client 是一个 208 行的最小实现,只说 stdio、协议版本写死 2025-06-18,并把远程工具统一命名成 mcp__<server>__<tool>,让权限、hook 和主循环把它们当内置工具一样对待:

mcp.py CoreCoder@v0.7.0:corecoder/mcp.py:1-31需联网 MIT
1"""MCP stdio client, distilled to the slice of the protocol an agent uses.
2
3Servers are configured in ~/.corecoder/mcp.json:
4
5 {"mcpServers": {"fs": {"command": "npx", "args": ["-y", "some-fs-server", "/tmp"]}}}
6
7Each server is a subprocess speaking JSON-RPC 2.0, one message per line over
8stdin/stdout. Startup handshakes (`initialize`), pulls `tools/list`, and every
9remote tool joins the agent as `mcp__<server>__<tool>`, so consent, hooks and
10the main loop treat them exactly like the built-ins. A server that hangs or
11dies fails that one call as an ordinary error string; it never kills the loop.
12"""
13
14import atexit
15import json
16import logging
17import os
18import subprocess
19import threading
20import time
21from pathlib import Path
22
23from . import __version__
24from .tools.base import Tool
25
26log = logging.getLogger(__name__)
27
28CONFIG_FILE = Path.home() / ".corecoder" / "mcp.json"
29PROTOCOL_VERSION = "2025-06-18"
30INIT_TIMEOUT = 15 # seconds for initialize + tools/list at startup
31CALL_TIMEOUT = 60 # seconds for one tools/call
👆 带下划线的行可以点:点哪行,讲哪行。

把外部工具接进自己的 prompt,工业实现还要处理名字。Codex 把 MCP 工具名限制在 64 字节,重名或超长时补一段 12 位的 SHA-1 后缀消歧:

tools.rs codex@8cabf5a:codex-rs/codex-mcp/src/tools.rs:225-250需联网 Apache-2.0
225const MCP_TOOL_NAME_DELIMITER: &str = "__";
226const MAX_TOOL_NAME_LENGTH: usize = 64;
227const CALLABLE_NAME_HASH_LEN: usize = 12;
228fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String {
229 if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
230 namespace.to_string()
231 } else {
232 format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}")
233 }
234}
235
236fn sha1_hex(s: &str) -> String {
237 let mut hasher = Sha1::new();
238 hasher.update(s.as_bytes());
239 let sha1 = hasher.finalize();
240 format!("{sha1:x}")
241}
242
243fn callable_name_hash_suffix(raw_identity: &str) -> String {
244 let hash = sha1_hex(raw_identity);
245 format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN])
246}
247
248fn append_hash_suffix(value: &str, raw_identity: &str) -> String {
249 format!("{value}{}", callable_name_hash_suffix(raw_identity))
250}
👆 带下划线的行可以点:点哪行,讲哪行。

工具集本身也随模型变。OpenCode 给 GPT 系模型用 apply_patch、拿掉 edit 与 write,给其他模型反过来(registry.ts:297-300):在上面的条上切到「OpenCode」,再换模型试试。

⚠
常见误解一:「MCP 是让模型调用模型的协议。」

不是。MCP 连接的是 harness 与工具、资源。唯一一个「server 反过来请 client 调模型」的功能是 Sampling,2026-07-28 版已经把它废弃,官方建议直接对接各家模型 API。模型调用归 A8 的模型接入层,工具接入归本章。

⚠
常见误解二:「废弃了就是已经不能用了」,以及「Codex 也能当 MCP server」。

废弃窗口至少 12 个月,Sampling、Roots、Logging 在窗口内仍然可用,Claude Code 在旧连接上仍会响应 roots/list。至于 Codex:pin 8cabf5a 上还有一个自称「Prototype」的 codex mcp-server,但它已于 2026-09-05 在上游删除(#42993,勘误表 X5),讲义里不再这么说。

极小真数字:一轮的工具区

把上面的账写成一行:

一轮请求里工具区的 token(本课组件的算法)
= + + +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
口径:项目录制模型 Qwen3-8B 的 tokenizer 与 chat template;换一家模型,模板与 tokenizer 不同,各项数值会变,但结构一样。

证据三问

问本课的回答
① 它解决什么问题?外部工具的接入要有统一协议;接进来的工具不能无限撑大每一轮的上下文
② 为什么可能有效?协议把「工具在哪、怎么调」与「模型看到什么」分开,harness 可以决定只放名字
③ 实验上呢?本机实测三个真实 server 的工具区大小(项目实测);Anthropic 自报的 150,000 → 2,000(官方自报,场景不同);deferred 能让任务成功率提高多少,本轮没有找到公开对照

演进地图:你在这里

模型怎么说清「改哪里」:每一代都被上一代的某个真数字逼出来
  1. 朴素 按行号改 「把第 a–b 行换成……」
    解决了模型不用复述原文,只报行号,输出最短。
    卡在行号是「上一次读文件时」的快照:第一处修复只要多插 2 行,第二次按旧行号 41 改,就落在 if m: 上(第 1 课现算);CoreCoder README 的原话是「Line numbers are a trap」。
  2. 第一代 整文件覆写 write_file;aider 的 whole 格式
    解决了不需要定位,格式最不容易错:aider 同一个 Qwen2.5-Coder-32B,whole 格式合规 99.6%,diff 格式只有 71.6%。
    卡在只改 1 行,也要把 44 行的文件整个再写一遍:参数 487 token,片段替换只要 54(9.0 倍,项目模型 tokenizer 实数);还会覆盖别人在此期间的改动。
  3. 第二代 唯一片段替换(search-replace) CoreCoder、本课项目 miniagent
    解决了按内容定位,不怕行号过期;0 处或多处都报错、文件不动,让模型自己补上下文。
    卡在真实 trace pd-truncate-s1:第 4 轮 old_string 在文件里 2 处、第 5 轮缩进多 4 格 0 处,两次报错多跑了两轮,花掉 8,481 token、109.5 秒;唯一也不等于对:pd-v2-unlimited-s1 第 7 轮的 old_string 恰好是已修好那一行的前半截,计数 = 1,于是 + 1 变成了 + 1 + 1。
  4. 解决了模型复述时的小漂移不再白费一轮:上面第 5 轮那条缩进多 4 格的 edit,OpenCode 直接接住(LineTrimmed 级)。
    卡在接住 ≠ 改对:多转义一层的引号,OpenCode 的 EscapeNormalized 级照样接住,写回去的是 \",模块导入即 SyntaxError;OpenCode 的 v2(packages/core)眼下只有精确匹配,「edit fuzzy parity」还列在待移植清单里。
  5. 第四代 语法约束解码的 patch Codex apply_patch(19 行 Lark 语法);strict 工具
    解决了语法由解码器保证,不再靠 prompt 教。对照:靠 prompt 教格式时,aider 榜上 47 个 diff 格式条目的「格式正确」比例最低只有 64.4%(官方自报)。
    卡在语法只管格式,定位仍是 seek_sequence:它不检查唯一、取第一处——两处相同的 return datetime.date(year, month, day) 会改到第 31 行 ISO 分支,test_parse_iso 跟着坏;整行比较还会把没写缩进的 new 行原样写进去,得到 IndentationError。
  6. 工具从哪来 工具集随模型变,外部工具经 MCP 接入 OpenCode registry.ts;MCP 2026-07-28 你在这里
    解决了一套协议接任意外部工具;同一个 harness 给 GPT 换上 apply_patch、给 Claude 用 edit。
    卡在接进来的每个工具每轮都要付上下文:chrome-devtools-mcp 的 30 个工具在项目模型的 chat template 里占 7,047 token,是 miniagent 自己 6 个工具(685)的 10.3 倍。
  7. 岔路 Code Mode:让模型写脚本调工具 Codex exec / wait;OpenCode codemode
    解决了N 次「模型 → 工具 → 模型」往返压成一段脚本。
    卡在省的是往返,不省判断:真实 trace 里模型每一步都要看上一步的结果才知道下一步做什么(岔路课逐轮算)。
数字口径:token 数用项目录制模型 Qwen3-8B 的 tokenizer 与 chat template 算(与服务端 usage 逐轮核对一致);裁决与测试结果是原仓库代码实跑(a02-edit-fixtures.json,2026-09-26);trace 来自本课项目 miniagent 的真实运行(2026-09-26);aider 数字来自 aider 仓库的 polyglot_leaderboard.yml(官方自报);MCP 工具数来自本机实跑 tools/list。

一句话

📌
MCP 是接工具的协议,不是接模型的协议;接进来的每个工具每轮都要付上下文。
🚦

先停在这里:记住位置(harness 与工具之间)和账单(每轮都付)两件事。
再深一层:去岔路课 Code Mode:如果让模型写一段脚本、在脚本里调工具,往返次数和中间结果都不必进上下文。

下一个缺口

工具能执行 rm -rf,谁来拦? → A3 第 1 课:拦危险命令,写个黑名单行不行?

这一章给了模型改文件、跑命令、接外部系统的能力。能力越大,越需要一道闸:A3 从最朴素的黑名单开始。本课 CoreCoder 把 MCP 工具命名成 mcp__server__tool,就是为了让那道闸能按名字认出它们。

你可能会问