1. 定位:工具来源变了,基础循环还在

1.1 我想弄清楚的问题

S02 的工具由 Harness 手写定义和函数。接入文档系统、部署服务时,我不想在 Agent 里为每个服务重写一套能力,能否让服务提供工具列表,再接进现有循环?

我的理解是:MCP Tools 把“服务提供什么”与“Agent 怎样使用”分开。Server 提供工具定义与调用入口;Harness 发现、适配、检查权限,再把结果接回 S01 的工具循环。 模型选择工具和参数,实际调用仍由 Harness 执行。

源码基准为本地 ce8f9f1 的 s14_mcp_plugin/code.py。完整函数按源码摘录,类方法保留 self;伪代码和局部片段另行说明。按 MIT 许可使用。

1.2 先分清模型、Harness、Client 和 Server

角色知道什么/负责什么
模型本轮可见工具的 name、description、参数 schema;产生 tool_use
Harness / host连接哪些 server、组装工具池、权限策略与 tool_result 回传
MCP Client保存发现的定义,将原始工具名与参数交给对应 server
MCP Server提供业务工具,例如 search、status、trigger

MCP 的工具协议用 tools/list 发现定义,用 tools/call 提交 name 与 arguments;返回值也可以包含多种内容。本章模拟这两个接口的职责,只返回字符串。依据:MCP Tools 规范(2025-11-25)。

本例边界: docs / deploy 是进程内 mock,搜索、版本和部署状态都是固定示例文本。代码没有发出 JSON-RPC 请求,也没有实现 stdio、HTTP、握手、认证或服务启动;MCPClient 是教学替身,不是真实 SDK Client。

1.3 整体过程:从 S13 到 S14

图 1:回顾 S13 的默认总览

上一轮整体流程:本章改造前的已有系统蓝色已有;橙色本轮将改造;灰色虚线细节在图 2 合并。上一轮整体流程:本章改造前蓝色已有;橙色本轮将改造;灰色虚线细节在图 2 合并。Lead 对话历史允许整理与替换请求前处理先注入通知,再整理完成队列为空就跳过请求模型继续原任务响应与工具判断仍按原循环判断工具分发调用工作 / 团队 / compact回传结果所有调用按 ID 配对TOOLS扩展团队 schemaSYSTEM背景 + 团队协调约定用户输入沿用 S04 入口输入事件UserPromptSubmitStop检查是否继续PreToolUse权限 / 审批 → S13.2PostToolUse执行后观察技能目录名称 / 描述 · 发现参考Skill 装载正文入历史 → S07.9compact先登记并返回确认工具实现read / write / edit / globtask→S06TODO→S05持久任务 / 依赖 → S10足够 →整理后仍超限主动 compact 且结果交齐API 超长 → S08无工具 ↓拒绝也返回配对结果工具结果或 Stop 反馈 → 更新历史 → 下一轮持久队友通过文件邮箱协作;旧接口为累积复习骨架。Compact 处理自动 / 主动 / 超长补救 → S08细节合并;异常最多重试 1 次记忆召回 ▸每个用户回合一次选择相关文件,再读正文记忆文件与索引.memory / MEMORY.md主题文件跨回合保存Memory 保存接口提取 / 保存 / 整理 → S09保留退出边界;内部细节合并本轮结束不等待后台线程新用户回合无工具调用,Stop 无反馈保存检查完成 →后台通知接口 → S11S11 结果注入保留为已学接口启动队友 ▸spawn → 先认领 → 启动线程立即返回启动确认Teammate 独立循环 ▸自己的 system / messages / tools模型 → 工具 → 结果 → 模型文件邮箱与事件 ▸.mailboxes · Lead ↔ TeammatesIDLE:等待或认领 ▸先等消息,超时扫描 ready task共享任务 / owner / cwd → S13.3–4Lead 外层唤醒 ▸CLI 收信 → [Team events]追加 user 消息,下一轮处理定时事件接口 → S12到期排队 → 空闲交付 prompt登记返回与确认/恢复细节 → S12
图例 蓝:保留橙:将改造灰虚线:合并 完整说明
  • 蓝色:已有且保留
  • 橙色:本轮将改造(仍是旧实现)
  • 紫色:真正新增(本图未用)
  • 红色虚线:本轮将移出(仍是旧流程)(本图未用)
  • 灰色实线框:存储/辅助节点(本图未用)
  • 灰色虚线框:细节合并,功能保留
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环
  • 实线箭头:流程/数据方向
  • 下划线章节:点击查看具体实现
图 1:仍是上章默认节点与连线。橙色位置本章改为按连接状态组装 schema、handlers、SYSTEM,并增加 MCP 权限策略;五个灰色团队细节在图 2 合并到 S13 接口索引。左右滑动查看全图。

图 2:发现与调用怎样接入已有系统

图 2 · MCP 接入现有工具循环Harness 发现 mock server 工具并保存 Client,每轮适配模型 schema 与本地 handlers;外部调用经过宿主权限并返回工具结果,团队内部回到 S13 索引。图 2 · 发现、适配与外部调用每轮同时生成模型 tools 与本地 handlers;MCP 调用仍返回原 tool_result。Lead 对话历史允许整理与替换请求前处理整理并组装本轮工具池工具池每轮重建请求模型继续原任务响应与工具判断仍按原循环判断工具分发调用内置 / MCP / compact回传结果所有调用按 ID 配对TOOLS内置 + 已连接 MCPSYSTEM背景 + 已连接 server用户输入沿用 S04 入口输入事件UserPromptSubmitStop检查是否继续PreToolUse权限 / 宿主 MCP 策略PostToolUse执行后观察技能目录名称 / 描述 · 发现参考Skill 装载正文入历史 → S07.9compact先登记并返回确认工具实现read / write / edit / globtask→S06TODO→S05持久任务 / 依赖 → S10足够 →整理后仍超限主动 compact 且结果交齐API 超长 → S08无工具 ↓拒绝也返回配对结果工具结果或 Stop 反馈 → 更新历史 → 下一轮紫色是发现与调用适配;旧接口为累积复习骨架,本脚本范围见正文。Compact 处理自动 / 主动 / 超长补救 → S08细节合并;异常最多重试 1 次记忆召回 ▸每个用户回合一次选择相关文件,再读正文记忆文件与索引.memory / MEMORY.md主题文件跨回合保存Memory 保存接口提取 / 保存 / 整理 → S09保留退出边界;内部细节合并本轮结束不等待后台线程新用户回合无工具调用,Stop 无反馈保存检查完成 →后台通知接口 → S11S11 结果注入保留为已学接口定时事件接口 → S12到期排队 → 空闲交付 prompt登记返回与确认/恢复细节 → S12连接并发现工具 ▸connect_mcp → mock server 工厂发现确认仍经原 tool_resultClient 定义与调用映射 ▸mcp_clients · 进程内注册表tools / _handlers · 原始工具名每轮组装 tools + handlers ▸前缀命名 / schema 适配 / 宿主策略Client 调用原始工具 ▸mcp__docs__search → docs.search参数与结果适配 → 工具回传Team 运行时接口 → S13独立 WORK / IDLE 与消息交付任务 / 协议 / cwd 细节 → S13.1–4
图例 蓝:保留橙:改动紫:新增灰:辅助 完整说明
  • 蓝色:已有且保留
  • 橙色:修改/职责迁移
  • 紫色:真正新增
  • 红色虚线:真实移除(本图未用)
  • 灰色实线框:存储/辅助节点
  • 灰色虚线框:细节合并,功能保留(本图未用)
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环
  • 实线箭头:流程/数据方向
  • 下划线章节:点击查看具体实现
图 2:紫色展开连接、工具注册、每轮组装和 MCP 调用。组装分别输出模型 schema 与本地 handlers;调用仍经过 PreToolUse、PostToolUse 和配对 tool_result。蓝色 S13 接口保留启动确认和后续事件交付,内部循环回到 S13。左右滑动查看全图。

点击本章模块查看详情;局部图中的步骤与下方函数联动高亮。可拖动右下角,或用“放大/恢复尺寸”。

悬停总图节点即可预览输入、输出和引用连线;移开恢复原状态,点击可固定查看或打开详情。相关连线与箭头加粗,其他连线淡化;蓝、橙、紫等变化颜色含义不变。图 1 保持静态回顾;图 3 布局固定,可按章节设置预览连线。

连接与发现:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
模型调用 connect_mcp。
输入
注册 server 名 docs / deploy。
输出/状态
工厂创建 Client,保存定义与映射并返回确认。
控制流
本例无网络传输;新工具在下一轮才可见。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

Client 内部登记:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
mock server 工厂调用 register。
输入
服务定义和原始名字到业务函数的映射。
输出/状态
保存 tools / _handlers 到进程内 mcp_clients。
控制流
校验名字、同服务重复和缺失 handler,不做完整 schema 校验。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

名称、schema 与动态工具池:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
每轮模型请求前调用 assemble_tool_pool。
输入
内置工具、已连接 Client 与宿主权限配置。
输出/状态
模型 tools、本地 handlers,以及 mcp_tool_policies。
控制流
前缀规范化、冲突和长度检查;缓存定义不会自动刷新。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

宿主权限与确认:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
execute_tool 的 PreToolUse。
输入
模型名称、参数、当前宿主策略。
输出/状态
allow 放行,其他策略需 CLI 确认。
控制流
服务 annotations 不替代授权;拒绝返回 tool_result。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

原名调用与循环回传:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
模型选中的外部工具经本轮 handlers 分发。
输入
Client 引用、原始工具名、业务 kwargs。
输出/状态
字符串结果或 MCP error,配对原调用 ID。
控制流
工具异常留在工具边界;组装/API 失败则结束本轮。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

比较范围: 图 2 是累积学习骨架。独立 S14 脚本实际从 S04 基础工具和 Hooks 出发,只加 MCP 连接/发现/调用;没有合入 Task、Background、Cron、Team、Worktree、Memory、Skills 或 Compact。这些旧节点是复习接口;到 S15 才讨论综合 Harness。

图 3:动态工具池的最小核心

图 3 · 动态工具池与统一调用发现改变注册状态;组装改变下轮可见工具,业务错误仍走原结果循环。图 3 · 动态工具池与统一调用发现改变注册状态;组装改变下轮可见工具,业务错误仍走原结果循环。连接并发现本例:进程内 mock 工厂connect_mcp()_mock_server_docs()登记 Client 状态tools 与原始 handlersMCPClient.__init__()MCPClient.register()每轮适配工具池模型定义 + 本地映射assemble_tool_pool()normalize_mcp_name()请求模型并选择调用本轮快照;新工具下轮可见agent_loop()assemble_system_prompt()宿主权限检查allow / 其余需确认permission_hook()trigger_hooks()按原始名字调用Client → mock 业务函数execute_tool()MCPClient.call_tool()配对结果,进入下轮正常 / 参数错误 / 拒绝agent_loop()组装/API 异常退出原错误路径,新增组装异常agent_loop()模型产生 tool_use → 宿主执行组装失败API 失败拒绝也返回结果追加结果 → 下一轮重新组装
图例 蓝:保留橙:改动紫:新增 完整说明
  • 蓝色:已有且保留
  • 橙色:修改/职责迁移
  • 紫色:真正新增
  • 红色虚线:真实移除(本图未用)
  • 灰色实线框:存储/辅助节点(本图未用)
  • 灰色虚线框:细节合并,功能保留(本图未用)
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环(本图未用)
  • 实线箭头:流程/数据方向
图 3:上方是发现到模型可见,下方是调用与回传;回到下一轮才重新组装工具池。工具错误返回 tool_result;组装或模型请求失败记录 assistant 错误并结束本轮。点击内部函数名就近阅读实现。左右滑动查看全图。
图 3 内部函数索引:点击查看实现
步骤/职责内部方法与循环实现
模拟连接/发现connect_mcp()
_mock_server_docs()
_mock_server_deploy()
登记 Client 状态MCPClient.__init__()
MCPClient.register()
双向工具适配assemble_tool_pool()
normalize_mcp_name()
本轮模型请求agent_loop()
assemble_system_prompt()
宿主权限permission_hook()
trigger_hooks()
原始名字调用execute_tool()
MCPClient.call_tool()
配对与下轮组装agent_loop()
组装/请求失败退出agent_loop()

2. 连接与发现:先保存服务提供的能力

2.1 模型工具入口与内部 Client 分层

层次对应代码作用
模型可见 schemaCONNECT_TOOL 与组装后的 tools描述工具名称、参数与用途
具体工具入口run_connect_mcp接收 name,转交内部连接逻辑
Harness 组装逻辑assemble_tool_pool / assemble_system_prompt每轮生成模型定义、本地分发表和请求背景
Harness 执行入口execute_tool / permission_hook权限、函数查找、异常和 Hooks
Client 内部方法register / call_tool保存定义与调用映射,执行原始工具
模拟服务实现_mock_server_docs / _mock_server_deploy代替发现响应与远端业务逻辑

run_ 前缀不是自动注册规则。下面的绑定明确把 connect_mcp 模型工具接到 run_connect_mcp;外部工具则由 assemble_tool_pool 生成 handler,不要求每个 server 工具另写一个 run_* 函数。

CONNECT_TOOL = {
    "name": "connect_mcp",
    "description": "Connect to an MCP server and discover its tools.",
    "input_schema": {
        "type": "object",
        "properties": {"name": {"type": "string", "enum": ["docs", "deploy"]}},
        "required": ["name"],
    },
}

BUILTIN_TOOLS = [*BASE_TOOLS, CONNECT_TOOL]

BUILTIN_HANDLERS = {**BASE_HANDLERS, "connect_mcp": run_connect_mcp}
def run_connect_mcp(name: str) -> str:
    return connect_mcp(name)

初始工具池是五个基础工具加 connect_mcp,共 6 个工具。连接 docs 后是 8 个,docs 和 deploy 都连接后是 10 个;这是本例固定 mock 的数量,不是 MCP 限制。

2.2 MCPClient 的内部状态与 register

所属层: Client 内部状态初始化。name 保存服务身份;tools 保存定义列表;_handlers 保存原始工具名到本地 mock 函数的映射。

def __init__(self, name: str):
    self.name = name
    self.tools: list[dict] = []
    self._handlers: dict[str, callable] = {}

register 输入: 工具定义与模拟函数表。输出/副作用: 检查非空名字、同 server 重名及缺失 handler,再保存两份状态。这里模拟已经拿到 tools/list 结果后的登记,不会调用模型,也没有真实网络发现。

def register(self, tool_defs: list[dict], handlers: dict[str, callable]):
    names = [tool.get("name") for tool in tool_defs]
    if any(not isinstance(name, str) or not name for name in names):
        raise ValueError("Every MCP tool needs a non-empty name")
    if len(set(names)) != len(names):
        raise ValueError(f"Duplicate MCP tool name on server {self.name!r}")
    missing = [name for name in names if name not in handlers]
    if missing:
        raise ValueError(f"Missing MCP handlers: {', '.join(missing)}")
    self.tools = list(tool_defs)
    self._handlers = dict(handlers)

list / dict 复制的是外层容器,内部 schema 并没有深拷贝。注册检查也不等于完整 JSON Schema 校验;本章关注发现与分发边界。

2.3 docs server:定义与业务函数各放在哪里

所属层: 模拟服务工厂。创建 Client,再 register 两项工具:search 需要 query,get_version 无必需参数。服务使用 inputSchema,暂时还不是传给模型 SDK 的 input_schema。

def _mock_server_docs() -> MCPClient:
    server = MCPClient("docs")
    server.register(
        tool_defs=[
            {
                "name": "search",
                "description": "Search the documentation.",
                "inputSchema": {
                    "type": "object",
                    "properties": {"query": {"type": "string"}},
                    "required": ["query"],
                },
                "annotations": {"readOnlyHint": True},
            },
            {
                "name": "get_version",
                "description": "Get the documentation API version.",
                "inputSchema": {"type": "object", "properties": {}},
                "annotations": {"readOnlyHint": True},
            },
        ],
        handlers={
            "search": lambda query: f"[docs] Found 3 results for '{query}'",
            "get_version": lambda: "[docs] API v2.1.0",
        },
    )
    return server

annotations 的 readOnlyHint 描述服务声称的行为;实际宿主授权不在这里。两个 lambda 才是本例业务实现,固定返回搜索示例与版本示例,不能把这里的 v2.1.0 当成当前真实服务版本。

2.4 connect_mcp:选择工厂并记录连接状态

所属层: Harness 内部连接/发现逻辑。输入注册的 server 名;重复连接返回说明,未知名字返回可选列表。成功时调用工厂、保存 mcp_clients[name],返回发现工具数量和原始名字。

MOCK_SERVERS = {
    "docs": _mock_server_docs,
    "deploy": _mock_server_deploy,
}
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}'. Available: {', '.join(MOCK_SERVERS)}"
    server = factory()
    mcp_clients[name] = server
    names = ", ".join(tool["name"] for tool in server.tools)
    print(f"  [mcp] connected: {name} -> {names}")
    return (
        f"Connected to MCP server '{name}'. "
        f"Discovered {len(server.tools)} tools: {names}"
    )

此时改变的是 Client 注册表。connect_mcp 的 tool_result 只是连接确认;新工具的 schema 要等下一次 assemble_tool_pool 才发给模型。mcp_clients 在进程内保存,同一 CLI 的后续用户回合继续可用,进程重启不自动恢复。

3. 工具池:模型定义和本地 handler 必须一起变

3.1 名字适配:前缀与规范化

server 的原始工具名可以重复,例如两个服务都有 search。本例把模型可见名字变成 mcp__{safe_server}__{safe_tool};调用时仍使用原名。

所属层: 命名辅助。替换模型工具名字符集以外的字符,空字符串不能通过。

_DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
def normalize_mcp_name(name: str) -> str:
    """Replace characters outside the model tool-name alphabet."""
    normalized = _DISALLOWED_CHARS.sub("_", name)
    if not normalized:
        raise ValueError("MCP names cannot normalize to an empty string")
    return normalized
原始来源本例模型可见名称
docs / searchmcp__docs__search
deploy / statusmcp__deploy__status
docs.one / get.versionmcp__docs_one__get_version

最后一行可能与 docs_one / get_version 冲突,所以不能只替换字符然后覆盖旧条目。组装时会检查冲突和 64 字符上限。前缀、规范化和这个长度上限是本例给模型适配的约定,不是 MCP 协议要求使用这套名称。

3.2 assemble_tool_pool:一轮的一对快照

所属层: Harness 动态组装。输入内置工具及所有已连接 Client;输出 tools 列表与 handlers 字典,同时更新本轮 mcp_tool_policies。两份快照使用同一组名称。

def assemble_tool_pool() -> tuple[list[dict], dict[str, callable]]:
    """Combine built-in tools with every connected server tool."""
    global mcp_tool_policies
    tools = list(BUILTIN_TOOLS)
    handlers = dict(BUILTIN_HANDLERS)
    policies: dict[str, str] = {}
    origins = {
        tool["name"]: f"built-in tool {tool['name']!r}"
        for tool in tools
    }

    for server_name, server in mcp_clients.items():
        safe_server = normalize_mcp_name(server_name)
        for tool_def in server.tools:
            raw_name = tool_def["name"]
            safe_tool = normalize_mcp_name(raw_name)
            prefixed = f"mcp__{safe_server}__{safe_tool}"
            if len(prefixed) > 64:
                raise ValueError(f"MCP tool name is longer than 64 characters: {prefixed}")
            origin = f"MCP tool {server_name!r}/{raw_name!r}"
            if prefixed in origins:
                raise ValueError(
                    "MCP tool name collision after normalization: "
                    f"{prefixed!r} maps both {origins[prefixed]} and {origin}"
                )
            schema = tool_def.get("inputSchema", {})
            if not isinstance(schema, dict) or schema.get("type", "object") != "object":
                raise ValueError(f"Invalid input schema for {origin}")
            origins[prefixed] = origin
            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)
            )
            policies[prefixed] = MCP_HOST_POLICY.get(
                (server_name, raw_name), "confirm"
            )

    mcp_tool_policies = policies
    return tools, handlers

这里每轮重建:先复制内置部分,再遍历已经保存的 server 定义。检查命名冲突、长度与参数 schema 的对象类型;把 inputSchema 适配为 SDK 的 input_schema,并建立带前缀名字到原始调用的 handler。

它不会每轮重新连 server 或重新发送 tools/list。 本例只重读 mcp_clients 里的缓存;没有 list_changed 通知与自动刷新实现。模型只收到 name / description / input_schema,Python handler 和权限字典都留在 Harness。

3.3 为什么 lambda 要写 client=server、tool=raw_name

这是上一段源码的局部摘录:

handlers[prefixed] = (
    lambda *, client=server, tool=raw_name, **kwargs:
    client.call_tool(tool, kwargs)
)

循环每次都换 server / raw_name。默认参数在创建函数时求值,让各 handler 默认保存当次 Client 引用与原始工具名;稍后调用时,kwargs 才是模型提供的业务参数。这里保存引用,不会复制或冻结整个 Client。依据:Python 函数定义。

如果直接在函数体引用循环变量,多个 handler 会在调用时读到循环最终的绑定。下面是独立语言示例,说明两种写法的结果:

late = [lambda: i for i in range(3)]
bound = [lambda i=i: i for i in range(3)]

assert [f() for f in late] == [2, 2, 2]
assert [f() for f in bound] == [0, 1, 2]

对这个 MCP 示例而言,默认参数保证正常业务调用分别去 docs.search、docs.get_version、deploy.trigger、deploy.status,不全指向最后一个工具。

3.4 SYSTEM 是背景,TOOLS 才是可调用定义

所属层: 请求背景组装。无连接时返回 BASE_SYSTEM;有连接时只附上 server 名称。工具参数 schema 由 tools 提供,handlers 由 Harness 保存。

def assemble_system_prompt() -> str:
    if not mcp_clients:
        return BASE_SYSTEM
    return BASE_SYSTEM + "\n\nConnected MCP servers: " + ", ".join(mcp_clients)

“Connected MCP servers: docs” 说明当前背景,但不会单独注册 search。真正注册本轮可调用名称的是 assemble_tool_pool 返回的 tools / handlers。

3.5 为什么同批 connect 后不能立刻调用新工具

这一轮发送模型前,tools / handlers 已经组装好。connect 工具虽然在执行时更新 mcp_clients,当前工具批次仍用旧 handlers;直到回传结果后进入下一轮,模型才看到新定义。

Round 1: 6 tools -> connect_mcp("docs") -> connection tool_result
Round 2: 8 tools -> mcp__docs__search(query="hooks") -> search tool_result
Round 3: 8 tools -> answer, or another tool call

如果模型在第 1 轮同一批里自行写出 mcp__docs__search,它先走当前默认的外部工具确认;放行后,旧 handler 仍查不到这个名字,会返回 Unknown tool。不是 connect 一执行就修改了已经发出去的模型请求或本轮分发表。

这一节是官网行为对照与扩展示意,本地 S14 尚未实现。 前面三张图和源码仍对应“已连接工具全量进入模型 tools”的版本。

3.6.1 服务发现与模型搜索是两层

工具很多时,可以保留完整工具池,再缩小模型当轮要阅读的定义。关键是区分 Harness 已经登记的工具 与 模型当前可见的工具定义。

层次谁负责得到什么
服务发现Client / Harness已连接 server 的工具定义,保存在完整目录中
工具搜索模型提出需求,Harness 或 API 执行查找当前任务相关的工具
按需加载Harness 或 API将匹配工具的完整 schema 提供给模型
业务调用模型选择,Harness 执行实际文档、部署状态等结果

search_tools("查询部署状态") 是寻找合适的工具;随后调用 mcp__deploy__status(service="web"),才查询业务数据。搜索范围是已接入的工具目录。

本地 connect 的 schema 只列出 docs / deploy 名称。若希望模型更容易判断何时使用服务,可另提供简短的用途目录,例如 docs 负责文档搜索,deploy 负责部署状态;下面的示意把这份轻量目录作为请求背景。

3.6.2 现代 Claude 的公开做法

Claude Code 启用 ToolSearch 时,先提供工具名称与 server instructions,完整工具定义按需加载。server instructions 用于说明服务用途和何时查找其工具;完整参数 schema 可以等选中能力后再加载。Claude Code 官方说明

API 的 Tool Search 还区分“发送给 API”与“进入模型初始上下文”:请求仍可提交全部定义,把延迟项标记为 defer_loading: true;模型先看到搜索工具和未延迟工具,搜索返回 tool_reference,由 API 展开为完整定义后再调用。Tool Search API 文档

所以,“都登记进工具池”与“都立即占用模型上下文”可以分开。下面只示意 API 请求中的工具配置;它不是本地 agent_loop 的完整替换:

request_tools = [
    {"type": "tool_search_tool_bm25_20251119",
     "name": "tool_search_tool_bm25"},
    *[
        {**tool, "defer_loading": True}
        if tool["name"].startswith("mcp__") else tool
        for tool in discovered_tools
    ],
]

discovered_tools 表示组装出的工具定义;原始 handlers 留在 Harness。这里只展示配置,使用官方机制还需支持 Tool Search 的模型和相应的响应处理。

3.6.3 扩展局部图:完整目录与可见定义分开

扩展示意:完整工具目录与模型可见定义分开自建 Harness 教学方案:已登记目录提供轻量信息,模型提出搜索需求,Harness 查找并加载 schema,下一轮通过已有入口调用。不是本地源码或官方内部实现。扩展示意 · 工具目录与按需加载完整目录留在 Harness;搜索之后,匹配定义进入模型下一轮请求。已登记工具目录完整定义与调用映射沿用 S14 注册模型可见信息名称/用途/搜索入口已加载 schema 才展开查找相关工具模型提需求,宿主查目录search_tools(query)按需加载定义记录本次匹配名称更新 loaded_names调用业务工具宿主权限 → 原名调用沿用 execute_tool查询目录;尚未执行业务搜索结果回传 → 下一轮提供匹配 schema教学扩展,非本地实现;官方 Tool Search API 的延迟定义由 API 处理。
图例 蓝:保留橙:改动紫:新增 完整说明
  • 蓝色:已有且保留
  • 橙色:修改/职责迁移
  • 紫色:真正新增
  • 红色虚线:真实移除(本图未用)
  • 灰色实线框:存储/辅助节点(本图未用)
  • 灰色虚线框:细节合并,功能保留(本图未用)
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环
  • 实线箭头:流程/数据方向
扩展局部图:以本地 S14 为参照,蓝色保留工具登记和业务执行,橙色调整模型可见信息,紫色新增工具搜索与按需加载。该图说明下方自建 Harness 伪代码,不是本地已有源码或 Claude Code 内部实现。左右滑动查看全图。

3.6.4 如何接回原循环:自建 Harness 伪代码

下面选择一种容易理解的教学实现:Harness 自己搜索目录、记录 loaded_names,下一轮只提供已加载的外部 schema。这是自建方案;官方 API 的延迟加载由 API 处理,不要求应用照搬这个集合。

假定 SEARCH_TOOL_SCHEMA 定义了 search_tools(query);summarize_catalog 只返回简短名称与用途,search_catalog 在已登记 MCP 工具中查找。示例省略连接、异常、搜索算法与 Hooks 细节,业务调用沿用 execute_tool 的权限入口。

loaded_names = set()
while True:
    all_tools, all_handlers = assemble_tool_pool()
    catalog = {
        tool["name"]: tool for tool in all_tools
        if tool["name"].startswith("mcp__")
    }
    visible_tools = [
        tool for tool in all_tools
        if tool["name"] not in catalog or tool["name"] in loaded_names
    ]
    # 本轮分发表固定;刚搜索到的工具也要等下一轮
    visible_handlers = {
        tool["name"]: all_handlers[tool["name"]]
        for tool in visible_tools
    }
    response = ask_model(
        system=assemble_system_prompt() + summarize_catalog(catalog),
        messages=messages,
        tools=[*visible_tools, SEARCH_TOOL_SCHEMA],
    )
    messages.append(assistant_message(response))
    if not response.tool_calls:
        break
    results = []
    for call in response.tool_calls:
        if call.name == "search_tools":
            matches = search_catalog(catalog, call.input["query"])
            loaded_names.update(tool["name"] for tool in matches)
            output = json.dumps([tool["name"] for tool in matches])
        else:
            output = execute_tool(call, visible_handlers)
        results.append(tool_result(call.id, output))
    messages.append(user_message(results))

搜索结果先作为 tool_result 告诉模型找到了什么;下一轮 visible_tools 才包含这些工具的参数 schema。找不到就返回空列表,模型可以调整搜索需求。loaded_names 在这段示意中保留到会话结束,长期使用还可设计卸载策略。

加载不是授权。 查到或加载工具,只改变可见能力;实际调用仍走宿主权限和原来的 Client 分发。MCP 的服务发现、工具搜索、参数 schema 加载与业务执行分别承担不同职责。

4. 调用、权限与错误:接回原来的工具循环

4.1 宿主策略与 deploy 示例

deploy 的定义与 mock 函数如下,status 和 trigger 有相同 service 参数,但实际授权不同:

def _mock_server_deploy() -> MCPClient:
    server = MCPClient("deploy")
    server.register(
        tool_defs=[
            {
                "name": "trigger",
                "description": "Trigger a deployment.",
                "inputSchema": {
                    "type": "object",
                    "properties": {"service": {"type": "string"}},
                    "required": ["service"],
                },
                "annotations": {"destructiveHint": True},
            },
            {
                "name": "status",
                "description": "Check deployment status.",
                "inputSchema": {
                    "type": "object",
                    "properties": {"service": {"type": "string"}},
                    "required": ["service"],
                },
                "annotations": {"readOnlyHint": True},
            },
        ],
        handlers={
            "trigger": lambda service: f"[deploy] Triggered: {service}",
            "status": lambda service: f"[deploy] {service}: running (v1.4.2)",
        },
    )
    return server
MCP_HOST_POLICY = {
    ("docs", "search"): "allow",
    ("docs", "get_version"): "allow",
    ("deploy", "status"): "allow",
    ("deploy", "trigger"): "confirm",
}

所属层: PreToolUse 权限回调。模型选中的 mcp__ 名称用本轮策略查询;allow 才直接放行,其他值都需要 CLI 确认,用户拒绝则返回拒绝文本。

def permission_hook(block):
    if block.name == "bash":
        command = block.input.get("command", "")
        for pattern in DENY_LIST:
            if pattern in command:
                return f"Permission denied by deny list: {pattern}"
        if contains_destructive_command(command) or any(
            keyword in command for keyword in DESTRUCTIVE
        ):
            print(f"\n[permission] {block.name}({block.input})")
            if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
                return "Permission denied by user"

    if block.name in {"read_file", "write_file", "edit_file"}:
        raw_path = block.input.get("path", "")
        if not (WORKDIR / raw_path).resolve().is_relative_to(WORKDIR.resolve()):
            print(f"\n[permission] {block.name}({block.input})")
            if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
                return "Permission denied by user"

    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"
    return None

宿主用原始 (server_name, raw_name) 配置策略,组装时映射到模型可见名字;未配置默认 confirm。annotations / description 不直接变成权限。MCP 规范也要求按来源信任工具 annotations;本例采取的是明确的宿主配置。依据:MCP Tools 规范。

这不是一个完整 allow / deny / confirm 策略解析器:当前代码仅区分 allow 与其余需确认,不把字符串 deny 自动解释成永久拒绝。

4.2 execute_tool 与 call_tool 分别在哪一层

所属层: Harness 统一执行入口。输入模型 block 与本轮 handlers;先 PreToolUse,再按名称取 handler,执行或把异常变成错误文本,最后运行 PostToolUse。

def trigger_hooks(event: str, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:
            return result
    return None
def execute_tool(block, handlers: dict[str, callable]) -> str:
    blocked = trigger_hooks("PreToolUse", block)
    if blocked:
        return str(blocked)
    handler = handlers.get(block.name)
    if not handler:
        return f"Unknown tool: {block.name}"
    try:
        output = str(handler(**block.input))
    except Exception as exc:
        output = f"Error: {type(exc).__name__}: {exc}"
    trigger_hooks("PostToolUse", block, output)
    return output

所属层: MCPClient 内部调用。handler 已保存 Client 和原名;call_tool 用原名找到 mock 业务函数,将 args 展开传入,并把输出或异常转成字符串。

def call_tool(self, tool_name: str, args: dict) -> str:
    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 exc:
        return f"MCP error: {type(exc).__name__}: {exc}"

于是 mcp__docs__search 只在模型/Harness 侧存在,Client 收到的是 search 和 {"query": "hooks"}。权限拒绝、未知工具与业务参数错误都能作为配对 tool_result 回到模型。

4.3 哪些错误可以修正,哪些会结束这一轮

出错位置本例行为
Client 未知工具、漏参数或业务异常MCP error 字符串,仍回传 tool_result
Harness handler 未找到/执行异常Unknown tool / Error 字符串,仍回传 tool_result
用户拒绝Permission denied 字符串,handler 不运行
工具池命名冲突、过长或 schema 外形错误在模型请求前抛错,agent_loop 记录 assistant 错误并结束
模型 API 请求异常同样记录 assistant 错误并结束本轮

例如 search 漏 query 会在 Python 参数绑定处形成 TypeError;模型可在下一轮重新调用。schema 当前只检查是否是对象定义,没有按完整 JSON Schema 检查 required、数值类型或额外字段。真正 MCP 的结构化结果、isError 等字段也没有在这份 mock 中保留。

4.4 完整 Agent Loop 与串联伪代码

所属层: 模型/工具循环。每次请求前组装当前 tools / handlers,模型输出照常追加;所有工具按当前 handlers 执行,结果配对追加到 user 消息后继续。

def agent_loop(messages: list):
    while True:
        try:
            tools, handlers = assemble_tool_pool()
            response = client.messages.create(
                model=MODEL,
                system=assemble_system_prompt(),
                messages=messages,
                tools=tools,
                max_tokens=8000,
            )
        except Exception as exc:
            messages.append({
                "role": "assistant",
                "content": [{
                    "type": "text",
                    "text": f"[Error] {type(exc).__name__}: {exc}",
                }],
            })
            trigger_hooks("Stop", messages)
            return

        messages.append({"role": "assistant", "content": response.content})
        tool_calls = [
            block for block in response.content if block.type == "tool_use"
        ]
        if not tool_calls:
            trigger_hooks("Stop", messages)
            return

        results = []
        for block in tool_calls:
            print(f"> {block.name}")
            output = execute_tool(block, handlers)
            print(output[:300])
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })
        messages.append({"role": "user", "content": results})

下面是流程伪代码,省略异常和 Hooks;它把新增部分放回 S01 的原循环:

messages.append(user_message(question))
while True:
    tools, handlers = assemble_tool_pool()
    response = ask_model(
        system=assemble_system_prompt(), messages=messages, tools=tools
    )
    messages.append(assistant_message(response))
    if not response.tool_calls:
        break
    results = []
    for call in response.tool_calls:
        output = execute_tool(call, handlers)
        results.append(tool_result(call.id, output))
    messages.append(user_message(results))

connect 的调用属于这条循环里的一个工具。它修改注册状态;下一轮重新组装时才使外部工具变成模型可用能力,不需要另外一套推理循环。

4.5 我的理解:接入边界,而不是新的 Agent

S02 让我理解“工具多了,循环不变”;S14 再往前一步:工具定义和实现可以来自服务,Harness 负责把发现与调用适配到同一个循环。

本章已经说明动态工具池、名称与 schema 映射、宿主授权、原名调用和错误回传。真正接服务时,还需实现协议初始化、transport、认证、分页发现、连接生命周期、结果适配与更新通知;这些不是本章 mock 已完成的内容。