首页
/ learn-claude-code s14:MCP 工具发现与动态工具池——把外部服务接入 Agent Loop

learn-claude-code s14:MCP 工具发现与动态工具池——把外部服务接入 Agent Loop

2026-09-06 13:14:25作者:咎岭娴Homer

本篇基于 learn-claude-code 课程 s14 章节(s14_mcp_plugin/README.ja.md)及配套实现 s14_mcp_plugin/code.py,讲解如何用 MCP(Model Context Protocol)思路解决"每接入一个外部服务就要手写一套工具定义、参数 schema 和调用 handler"的维护问题。读完后你将理解 MCPClientconnect_mcpassemble_tool_pool 三个核心组件的职责划分,掌握 mcp__{server}__{tool} 前缀命名、host 侧权限策略与工具边界错误处理的设计,并能在本仓库中直接运行该章节代码验证整个动态工具池流程。

MCP 架构:Harness 通过 tools/list 发现工具、通过 tools/call 调用工具

一、背景问题:硬编码工具无法扩展

课程前面章节(如 s04_hooks)的基础工具——bashread_filewrite_fileedit_fileglob——都直接写在 code.py 中。若要把文档系统和部署平台接进来,最直接的做法是手写 search_docsdeploy_statustrigger_deploy 三个工具;但每增加一个服务,就要在 harness 里新增一组工具定义、参数 schema 和调用 handler,维护成本随服务数量线性增长。

MCP 把这部分工作拆成两个角色:

  • server 端:提供工具列表(对应 tools/list)和调用入口(对应 tools/call),并自带每个工具的 description 与 inputSchema;
  • harness 端:负责连接 server、给工具分配 model-facing 名称、做权限检查,最后把发现的工具交给模型。

s14_mcp_plugin/code.py 完整实现了这套机制(约 530 行),其中 docsdeploy 两个 server 是进程内 mock,用来演示 tools/listtools/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):

  • docs server:search(文档搜索)、get_version(查 API 版本),两者均带 readOnlyHint: true 注解;
  • deploy server: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):

  1. 每个工具的 name 必须是非空字符串;
  2. 同一 server 内不允许重名工具(Duplicate MCP tool name);
  3. 每个工具都必须有对应 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_TOOLcode.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 都可能提供 searchstatus 这类同名工具,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.versiondocs_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.versiondocs_one + get_version 两个 client,断言 assemble_tool_pool() 抛出含 collisionValueError

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 可以返回 readOnlyHintdestructiveHint 注解(本仓库 mock server 就带上了这些注解),但这些只是 server 的自述,不是授权依据。本章采用 host 侧策略:

MCP_HOST_POLICY = {
    ("docs", "search"): "allow",
    ("docs", "get_version"): "allow",
    ("deploy", "status"): "allow",
    ("deploy", "trigger"): "confirm",
}

code.py#L194-L200

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.pytest_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 参数(如 searchquery),或发送 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.pytest_mcp_lesson_builds_on_the_base_kernel:断言连接前工具池恰为 {bash, read_file, write_file, edit_file, glob, connect_mcp},连接 docsmcp__docs__search 出现在工具池中且 handler 可调用并返回 [docs] Found 3 results for 'hooks'
  • Web 端场景数据 web/src/data/scenarios/s15.json 中编排了 connect_mcpmcp__deploy__status 的调用序列,用于站点上的交互演示。

六、小结与适用边界

s14 给出的是一套可移植到真实 MCP 集成的设计模式:

  1. 发现与调用分离MCPClient 保存 tools/list 结果与 tools/call 边界,server 侧改动不影响 loop;
  2. 动态工具池:工具集合随 connect_mcp 逐轮增长,Agent Loop 结构不变;
  3. 归一化 + 冲突检测 + 长度上限保证模型可见名无歧义;
  4. host 侧权限策略(默认 confirm、description 不可信)保证外部工具默认安全;
  5. 错误留在工具边界,以 error tool_result 形式交还模型自纠。

适用边界需要说明:本章的 docs/deploy 是进程内 mock,connect_mcp 的可选 server 由 enum 固定为两者,未实现跨进程/跨网络的真实 MCP transport;生产环境中这部分需要替换为真实的 MCP client transport,但 assemble_tool_pool 的命名、策略与错误处理逻辑可以直接沿用。后续阅读可参见 s14_mcp_plugin/README.mds14_mcp_plugin/README.zh.md 的英中版本,以及下一章 s15 Integrated Harness 中 MCP 与其他 harness 层的合并方式。

登录后查看全文
热门项目推荐
相关项目推荐