首页
/ learn-claude-code s14 实战:MCP 工具发现、命名空间与动态工具池组装机制解析

learn-claude-code s14 实战:MCP 工具发现、命名空间与动态工具池组装机制解析

2026-09-06 13:21:30作者:范垣楠Rhoda

本篇基于 learn-claude-code 仓库的 s14 章节文档 展开,讲解如何用 MCP(Model Context Protocol)思想把外部服务的工具接入 Agent 的工具循环:MCPClient 保存 server 返回的工具定义与调用入口,connect_mcp 负责连接与工具发现,assemble_tool_pool 把基础工具与已连接的 MCP 工具组装进同一个每轮动态生成的工具池。读完后,你将掌握动态工具池的完整组装链路、mcp__{server}__{tool} 命名规范与冲突防护、宿主侧权限策略的设计理由,以及错误如何被约束在工具边界内而不中断 Agent Loop。

s14 MCP 工具发现与调用架构

问题背景:每接一个服务就要手写一批工具

前面章节的基础工具都直接写在 code.py 里。要接入文档系统和部署平台时,最直接的做法是继续手写 search_docsdeploy_statustrigger_deploy,但每增加一个服务,都要重新维护一套工具定义、参数 JSON Schema 和调用代码,工具来源与 Harness 深度耦合。

s14 的解法是把职责拆成两个角色:

  • server:提供工具列表(相当于 tools/list)和调用入口(相当于 tools/call);
  • Harness:负责连接、为工具分配模型可见的名字、做权限检查,并把发现的工具交给模型。

课程里的 docsdeploy 是进程内模拟 server,用来展示 tools/listtools/call 和动态工具池;真实的 MCP transport 不在本章实现,这一点在文档中明确说明,属于课程的教学边界。

方案骨架:三个新增组件

本章从 s04 Hooks 章节 的五个基础工具(bashread_filewrite_fileedit_fileglob)和 Hooks 出发,增加三个部分:

  1. MCPClient:保存 server 返回的工具定义(tools)和调用入口(_handlers);
  2. connect_mcp:连接一个 server 并取得它的工具列表,作为暴露给模型的工具;
  3. assemble_tool_pool:把基础工具与所有已连接 server 的工具组装到同一个工具池。

对应源码集中在 s14_mcp_plugin/code.py 的 "New in s14: MCP discovery and dispatch" 段落(约 L158-L356)。

工作原理详解

1. 基础 Agent Loop 不需要改变

每轮调用模型前,Harness 组装当前工具池,工具列表随连接状态动态变化:

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,
        )
        ...

连接新 server 后,下一轮 assemble_tool_pool() 会把新工具加入模型输入。工具执行后,结果仍作为 tool_result 追加到 messages,与基础工具的处理路径完全一致。s14 章节文档code.py 主循环 中的 agent_loop 实现了这段逻辑:模型返回 stop_reason != "tool_use" 时触发 Stop hooks 并退出,否则逐块执行 tool_use 并把 tool_result 以 user 消息回注。

从源码结构看,系统提示词也会同步更新:assemble_system_prompt() 在没有已连接 server 时返回 BASE_SYSTEM,有连接时追加 Connected MCP servers: ...L359-L362),让模型知道当前有哪些外部能力可用。

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}"

源码中的实现比文档骨架更严格(L160-L187):register() 在注册时会做三重校验——每个工具必须有非空字符串 name、server 内部不允许重名、每个工具名都必须有对应 handler,任一条件不满足直接抛 ValueError。这相当于把 server 侧的契约校验前置到发现阶段。

call_tool() 的注释写得很直白:错误会返回给模型,不会直接结束 Agent Loop。未知工具返回 MCP error: unknown tool '...',handler 抛异常则返回 MCP error: {异常类型}: {异常信息},模型可以在下一轮自行修正。

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
    ...

开始时,模型只看到五个基础工具和 connect_mcp。调用 connect_mcp(name="docs") 后,Harness 在模块级字典 mcp_clients 中保存 docs client。源码里 connect_mcp 还有两点值得注意(L279-L292):

  • 重复连接同名 server 不报错,而是返回 already connected,幂等友好;
  • 未知 server 名会返回可用清单,例如 Unknown server 'x'. Available: docs, deploy,方便模型在下一轮自我纠正。

而暴露给模型的 CONNECT_TOOL 定义中,name 参数直接用了 enum: ["docs", "deploy"]L299-L307),从工具 Schema 层面就限制了可连接的范围。

4. 前缀命名:区分不同 server 的同名工具

多个 server 都可能提供 searchstatus。Harness 统一使用如下命名:

mcp__{server}__{tool}

normalize_mcp_name()L203-L208)用正则 [^a-zA-Z0-9_-] 把不适合模型工具名的字符替换为下划线,并把"规范化后为空"视为非法。组装工具池时还会检查规范化后的名称冲突和 64 字符长度限制:

prefixed = f"mcp__{safe_server}__{safe_tool}"
if len(prefixed) > 64:
    raise ValueError(f"MCP tool name is longer than 64 characters: {prefixed}")
if prefixed in origins:
    raise ValueError("MCP tool name collision after normalization")

因此 docs.one/get.versiondocs_one/get_version 规范化后都会变成 mcp__docs_one__get_version,Harness 会抛出 ValueError 而不是让两个工具悄悄映射到同一个名字。tests/test_agent_teams_runtime.pytest_normalized_mcp_tool_name_collisions_are_rejected 正是构造这两个 server 来验证碰撞必被拒绝。

这里的设计取向值得注意:命名冲突属于配置/集成错误,直接 raise 让 Harness 停止组装,而不是静默降级;而工具调用期的错误才走"返回给模型"的容错路径。

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)
)

模型看到带前缀的名字;handler 内部仍使用 server 的原始工具名调用 MCPClient。两个细节(L313-L356):

  • lambda 闭包陷阱lambda *, client=server, tool=raw_name 用默认参数固化当前迭代的 client 和工具名。如果不加默认参数,循环结束后所有 lambda 都会捕获到最后一个工具,这是此类循环注册 handler 的经典 bug;
  • Schema 校验inputSchema 必须存在且 typeobject(缺省按 object 处理),否则抛 ValueError: Invalid input schema,保证交给模型的 Schema 合法。

同时组装过程会重建 mcp_tool_policies:每个 mcp__ 工具的策略取自 MCP_HOST_POLICY.get((server_name, raw_name), "confirm"),供后续权限检查使用。

6. 权限由宿主配置决定,而非 server 自述

MCP server 的工具定义里可以带 readOnlyHintdestructiveHint 注解(模拟 server 中 docs.search 标了 readOnlyHint: Truedeploy.trigger 标了 destructiveHint: True),但这些信息来自 server 自己,不能直接作为授权依据。本章使用宿主侧策略:

# Authorization comes from host configuration, never server descriptions.
MCP_HOST_POLICY = {
    ("docs", "search"): "allow",
    ("docs", "get_version"): "allow",
    ("deploy", "status"): "allow",
    ("deploy", "trigger"): "confirm",
}

permission_hook() 是一个 PreToolUse hook(L384-L408),对 mcp__ 前缀的工具按规范化名查询策略:策略不是 allow 就打印 [permission] 提示并要求用户输入 y/N 确认,拒绝则返回 Permission denied by user。未配置的外部工具默认 confirm——即使 description 写着 readOnly,也不会自动放行。

tests/test_agent_teams_runtime.pytest_mcp_permission_uses_host_policy 覆盖了三条路径:mcp__deploy__status 按策略直接放行(返回 None);mcp__deploy__trigger 用户答 no 时返回 Permission denied by user;构造一个未在策略中的 mcp__third_party__erase(description 里伪装成 readOnly),同样需要人工确认,验证了"server 自述不可信"这条边界。

7. 工具输入错误留在工具边界内

模型可能漏传参数,也可能传入 server 不接受的字段。execute_tool()L450-L462)先触发 PreToolUse hooks,权限被拦截则直接返回拦截信息;否则执行 handler,异常被捕获为 Error: {类型}: {信息}MCPClient.call_tool() 再兜一层,返回形如:

MCP error: TypeError: <lambda>() missing 1 required argument: 'query'

模型可以在下一轮修正参数,而不是让课程脚本直接退出。这就是"错误留在工具边界内"的含义:两层 try/except 分别覆盖 Harness 分发层和 MCP client 层,任何一层出错都转化为普通的 tool_result 文本。

相对 s04 的变化

组件 s04 s14
基础工具 五个固定工具 保持不变
工具来源 code.py 中的定义 基础工具加动态发现的 MCP 工具
工具池 固定 TOOLS 每轮由 assemble_tool_pool() 组装
外部工具名 mcp__{server}__{tool}
权限 Shell 和路径检查 增加宿主侧 MCP 策略
MCP transport 使用进程内模拟 server 展示协议边界

本章不带入 Task、Background、Cron、Team 或 Worktree;这些机制会在 s15 的 Integrated Harness 中与 MCP 合并。

试一下:完整运行流程

依赖见 requirements.txtanthropic>=0.25.0python-dotenv>=1.0.0),另外需要在 .env 中配置 ANTHROPIC_API_KEYMODEL_ID 从环境变量读取。运行:

cd learn-claude-code
python s14_mcp_plugin/code.py

输入:

连接 docs server,搜索 agent hooks,并告诉我当前文档 API 版本。

一次典型工具轨迹是:

connect_mcp(name="docs")
mcp__docs__search(query="agent hooks")
mcp__docs__get_version()

connect_mcp 的返回是 Connected to MCP server 'docs'. Discovered 2 tools: search, get_version,模型据此在下一轮直接按规范化名调用 mcp__docs__search。仓库中的场景数据 web/src/data/scenarios/s14.json 记录了同一条轨迹(连接 docs → 规范化命名 → 搜索 → 摘要),可作为交互过程的对照样本。

再输入:

连接 deploy server,查看 web 服务状态,不要触发部署。

status 会按宿主策略直接执行;trigger 需要用户确认。测试侧 test_mcp_lesson_builds_on_the_base_kernel 验证了核心断言:连接前工具池只有六个内置工具(五个基础工具加 connect_mcp)且不含 mcp__docs__searchconnect_mcp("docs") 之后 mcp__docs__search 出现在工具池中,且 handlers_after"mcp__docs__search" 返回 [docs] Found 3 results for 'hooks'

设计要点小结

从源码结构看,s14 传递的是一套可以迁移到真实 MCP 集成中的工程模式:

  1. 发现与调用分离register() 保存定义、call_tool() 执行调用,两者契约独立,替换成真实 transport 时只需换掉 client 内部实现;
  2. 命名空间即边界mcp__{server}__{tool} 前缀加规范化、碰撞检测、64 字符上限,把多 server 同名冲突变成显式错误;
  3. 授权只认宿主配置:server 的 readOnlyHint/destructiveHint 仅作展示参考,MCP_HOST_POLICY 才是授权来源,未配置默认 confirm
  4. 错误降级为文本:调用期异常一律变成 MCP error: ...tool_result,交给模型自我修正,保持 Agent Loop 不中断。

下一章 s15 Integrated Harness 会把基础工具、Hooks、Skills、Context、Memory、Task、Background、Cron、Teams 和 MCP 放进同一个运行时;s14 英文文档日文文档 提供同一内容的对照版本。

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