learn-claude-code s14:MCP 工具发现与动态工具池——把外部服务接入 Agent Loop
本篇基于 learn-claude-code 课程 s14 章节(s14_mcp_plugin/README.ja.md)及配套实现 s14_mcp_plugin/code.py,讲解如何用 MCP(Model Context Protocol)思路解决"每接入一个外部服务就要手写一套工具定义、参数 schema 和调用 handler"的维护问题。读完后你将理解 MCPClient、connect_mcp、assemble_tool_pool 三个核心组件的职责划分,掌握 mcp__{server}__{tool} 前缀命名、host 侧权限策略与工具边界错误处理的设计,并能在本仓库中直接运行该章节代码验证整个动态工具池流程。
一、背景问题:硬编码工具无法扩展
课程前面章节(如 s04_hooks)的基础工具——bash、read_file、write_file、edit_file、glob——都直接写在 code.py 中。若要把文档系统和部署平台接进来,最直接的做法是手写 search_docs、deploy_status、trigger_deploy 三个工具;但每增加一个服务,就要在 harness 里新增一组工具定义、参数 schema 和调用 handler,维护成本随服务数量线性增长。
MCP 把这部分工作拆成两个角色:
- server 端:提供工具列表(对应
tools/list)和调用入口(对应tools/call),并自带每个工具的 description 与 inputSchema; - harness 端:负责连接 server、给工具分配 model-facing 名称、做权限检查,最后把发现的工具交给模型。
s14_mcp_plugin/code.py 完整实现了这套机制(约 530 行),其中 docs 和 deploy 两个 server 是进程内 mock,用来演示 tools/list、tools/call 边界和动态工具池;本章不实现真实的 MCP transport。
二、方案总览:三个新增组件
s14 在 s04 的五个基础工具和 Hooks 之上,新增三个部分:
| 组件 | 职责 | 源码位置 |
|---|---|---|
MCPClient |
保存 server 返回的工具定义(tools/list 结果)与调用 handler(tools/call 边界) |
code.py#L160-L187 |
connect_mcp |
连接一个 server 并取得其工具列表,暴露给模型作为内置工具 | code.py#L279-L296 |
assemble_tool_pool |
每轮 model call 前,把基础工具与所有已连接 server 的 MCP 工具合并成一个工具池 | code.py#L313-L356 |
两个 mock server 通过工厂函数注册在 MOCK_SERVERS 中(code.py#L273-L276):
docsserver:search(文档搜索)、get_version(查 API 版本),两者均带readOnlyHint: true注解;deployserver:trigger(触发部署,带destructiveHint: true注解)、status(查部署状态)。
三、机制详解
3.1 基础 Agent Loop 保持不变
关键设计是:Agent Loop 本身不因 MCP 而改变,变化的只是"每轮调用模型前组装当前工具池"这一步:
def agent_loop(messages: list):
while True:
tools, handlers = assemble_tool_pool()
response = client.messages.create(
model=MODEL,
system=assemble_system_prompt(),
messages=messages,
tools=tools,
max_tokens=8000,
)
...
对应源码 code.py#L467-L506:新 server 连接后,下一次 assemble_tool_pool() 就会把它的工具加入 model input;工具执行结果仍按惯例以 tool_result 块追加到 messages。此外 assemble_system_prompt()(code.py#L359-L362)会在已连接 server 非空时,向 system prompt 追加 Connected MCP servers: ... 提示,帮助模型知道哪些服务可用。
3.2 MCPClient:发现结果与调用边界
class MCPClient:
def register(self, tool_defs, handlers):
self.tools = list(tool_defs)
self._handlers = dict(handlers)
def call_tool(self, tool_name, args):
handler = self._handlers.get(tool_name)
if not handler:
return f"MCP error: unknown tool '{tool_name}'"
try:
return str(handler(**args))
except Exception as error:
return f"MCP error: {type(error).__name__}: {error}"
register() 代表发现得到的工具列表,call_tool() 代表调用边界;错误被捕获后返回给模型,而不是终止 Agent Loop。
从源码看,实际的 register() 还做了三道校验(code.py#L168-L178):
- 每个工具的
name必须是非空字符串; - 同一 server 内不允许重名工具(
Duplicate MCP tool name); - 每个工具都必须有对应 handler(
Missing MCP handlers)。
这三道校验把"server 提供不完整工具清单"这类错误提前到注册阶段暴露。
3.3 connect_mcp:只做连接与发现
def connect_mcp(name: str) -> str:
if name in mcp_clients:
return f"MCP server '{name}' already connected"
factory = MOCK_SERVERS.get(name)
if not factory:
return f"Unknown server '{name}'"
server = factory()
mcp_clients[name] = server
...
对应 code.py#L279-L292,它对已连接 server 去重、对未知 server 返回可用的 server 列表,成功后把客户端存入全局 mcp_clients 并打印发现结果([mcp] connected: docs -> search, get_version)。
connect_mcp 本身是一个内置工具(CONNECT_TOOL,code.py#L299-L307),其 name 参数用 enum: ["docs", "deploy"] 限制了可选 server。也就是说,模型启动时看到的只有 5 个基础工具加 connect_mcp;执行 connect_mcp(name="docs") 之后,下一次 model call 的工具列表才会多出:
mcp__docs__search
mcp__docs__get_version
这正是"动态工具池"的含义——工具集合随会话演进,而不是一次性固定。
3.4 前缀命名:区分不同 server 的同名工具
多个 server 都可能提供 search、status 这类同名工具,harness 统一使用如下模型可见名:
mcp__{server}__{tool}
配套规则(code.py#L192-L208):
normalize_mcp_name()用正则[^a-zA-Z0-9_-]把 model 工具名不允许的字符替换成下划线,例如docs.one/get.version会变成docs_one/get_version;- 组装工具池时检查归一化后的名称冲突和 64 字符长度上限,冲突直接抛
ValueError终止组装:
prefixed = f"mcp__{safe_server}__{safe_tool}"
if prefixed in origins:
raise ValueError("MCP tool name collision after normalization")
这样,docs.one/get.version 与 docs_one/get_version 两个 server 不会在归一化后静默映射到同一个 mcp__docs_one__get_version。仓库测试 tests/test_agent_teams_runtime.py 中的 test_normalized_mcp_tool_name_collisions_are_rejected 正是构造了 docs.one + get.version 与 docs_one + get_version 两个 client,断言 assemble_tool_pool() 抛出含 collision 的 ValueError。
3.5 工具定义与 handler 成对入池
tools.append({
"name": prefixed,
"description": tool_def.get("description", ""),
"input_schema": schema,
})
handlers[prefixed] = (
lambda *, client=server, tool=raw_name, **kwargs:
client.call_tool(tool, kwargs)
)
对应 code.py#L342-L350。这里有两个易错点被显式处理:
- 模型看到的是带前缀的名字(
mcp__docs__search),而 handler 内部用 server 的原始工具名(search)去调MCPClient.call_tool,两边通过闭包绑定; - default argument 捕获循环变量:
client=server, tool=raw_name把当前 client 和原始工具名固定在每个 lambda 的默认参数里,避免"循环结束后所有 lambda 都指向最后一个工具"的经典闭包陷阱。
同时组装阶段还会校验 server 提供的 inputSchema 必须是 object 类型(code.py#L338-L340),并按 host 策略写入每个前缀工具的权限策略。
3.6 权限由 host 决定,而非 server 声明
MCP server 可以返回 readOnlyHint、destructiveHint 注解(本仓库 mock server 就带上了这些注解),但这些只是 server 的自述,不是授权依据。本章采用 host 侧策略:
MCP_HOST_POLICY = {
("docs", "search"): "allow",
("docs", "get_version"): "allow",
("deploy", "status"): "allow",
("deploy", "trigger"): "confirm",
}
permission_hook() 中针对 mcp__ 前缀工具的处理(code.py#L402-L407):
if block.name.startswith("mcp__"):
policy = mcp_tool_policies.get(block.name, "confirm")
if policy != "allow":
print(f"\n[permission] External tool {block.name}({block.input})")
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
return "Permission denied by user"
两点值得注意:
- 未在策略中配置的外部工具,默认
confirm(需用户确认),即"未声明即不信任"; - description 里写
readOnly也不会自动放行。测试 tests/test_agent_teams_runtime.py 的test_mcp_permission_uses_host_policy构造了一个名为mcp__third_party__erase、描述含(readOnly)的伪造工具,断言其依然要求用户确认——这是对"server 自述不可信"原则的直接验证。同一测试还确认mcp__deploy__status(策略allow)可静默执行、mcp__deploy__trigger(策略confirm)被拒时返回Permission denied by user。
权限 hook 通过 register_hook("PreToolUse", permission_hook) 挂载,与 bash deny list、文件路径越界检查共同构成 s04 遗留的权限层。
3.7 输入错误留在工具边界内
模型可能漏传 required 参数(如 search 缺 query),或发送 server 不接受的字段。execute_tool()(code.py#L450-L462)与 MCPClient.call_tool() 双层都会捕获异常并返回 error 形式的 tool_result:
MCP error: TypeError: <lambda>() missing 1 required argument: 'query'
课程脚本因此不会崩溃,模型可以在下一轮自行修正参数重试。
四、与 s04 的完整差异
| 组件 | s04 | s14 |
|---|---|---|
| 基础工具 | 5 个固定工具 | 不变 |
| 工具来源 | code.py 内的定义 |
基础工具 + 发现的 MCP 工具 |
| 工具池 | 固定 TOOLS |
每轮由 assemble_tool_pool() 组装 |
| 外部工具名 | 无 | mcp__{server}__{tool} |
| 权限 | Shell deny list 与 path check | 额外引入 host-side MCP policy |
| MCP transport | 无 | 用进程内 mock server 演示边界 |
本章刻意不引入 Task、Background、Cron、Team、Worktree 等机制;它们会在 s15 Integrated Harness(s15_integrated_harness)中与 MCP 汇合到同一个 runtime。
五、动手运行
运行前提(来自 code.py 头部注释与 requirements.txt):
pip install anthropic python-dotenv
# .env 中配置 ANTHROPIC_API_KEY(及 MODEL_ID 环境变量)
然后:
cd learn-claude-code
python s14_mcp_plugin/code.py
输入:
docs server に接続し、agent hooks を検索して、現在の documentation API version を教えてください。
典型的工具调用 trace 为:
connect_mcp(name="docs")
mcp__docs__search(query="agent hooks")
mcp__docs__get_version()
继续输入:
deploy server に接続して web service の status を確認してください。deployment は trigger しないでください。
按 host 策略,mcp__deploy__status 直接执行;若模型尝试 mcp__deploy__trigger,则会弹出 Allow? [y/N] 确认。
仓库还有两条自动化验证路径可以参考:
- tests/test_agent_teams_runtime.py 的
test_mcp_lesson_builds_on_the_base_kernel:断言连接前工具池恰为{bash, read_file, write_file, edit_file, glob, connect_mcp},连接docs后mcp__docs__search出现在工具池中且 handler 可调用并返回[docs] Found 3 results for 'hooks'; - Web 端场景数据 web/src/data/scenarios/s15.json 中编排了
connect_mcp→mcp__deploy__status的调用序列,用于站点上的交互演示。
六、小结与适用边界
s14 给出的是一套可移植到真实 MCP 集成的设计模式:
- 发现与调用分离:
MCPClient保存tools/list结果与tools/call边界,server 侧改动不影响 loop; - 动态工具池:工具集合随
connect_mcp逐轮增长,Agent Loop 结构不变; - 归一化 + 冲突检测 + 长度上限保证模型可见名无歧义;
- host 侧权限策略(默认 confirm、description 不可信)保证外部工具默认安全;
- 错误留在工具边界,以 error
tool_result形式交还模型自纠。
适用边界需要说明:本章的 docs/deploy 是进程内 mock,connect_mcp 的可选 server 由 enum 固定为两者,未实现跨进程/跨网络的真实 MCP transport;生产环境中这部分需要替换为真实的 MCP client transport,但 assemble_tool_pool 的命名、策略与错误处理逻辑可以直接沿用。后续阅读可参见 s14_mcp_plugin/README.md 与 s14_mcp_plugin/README.zh.md 的英中版本,以及下一章 s15 Integrated Harness 中 MCP 与其他 harness 层的合并方式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00