写在前面

learn-claude-code 项目目前有两条教程线,一条是之前的 s12 章节的,另一条是最近更新的 s20 章节的,大家如果刚学习这个项目的话推荐直接看最新的 s20 章节的教程即可,由于博主之前学习过 s12 章节的内容,因此打算把 s20 章节中新增章节的内容给补充学习,内容重复的章节博主这边就跳过了。

下面是旧版到新版的对应关系:

Legacy 12-lesson track Current 20-lesson track Topic
old s01 new s01 Agent Loop
old s02 new s02 Tool Use
old s03 new s05 TodoWrite
old s04 new s06 Subagent
old s05 new s07 Skill Loading
old s06 new s08 Context Compact
old s07 new s12 Task System
old s08 new s13 Background Tasks
old s09 new s15 Agent Teams
old s10 new s16 Team Protocols
old s11 new s17 Autonomous Agents
old s12 new s18 Worktree Isolation
new only s03, s04, s09, s10, s11, s14, s19, s20 Permission, Hooks, Memory, System Prompt, Error Recovery, Cron, MCP, Comprehensive Agent

从上表中我们可以看出我们需要补充的内容包括 s03(已补充)s04(已补充)s09(已补充)s10(已补充)s11(已补充)s14(已补充)、s19 以及 s20 八个章节的内容。

新版学习路径如下:

主线:能动手 → 能做复杂任务 → 能记住和恢复 → 能长期运行 → 能协作 → 能扩展并合体

在这里插入图片描述

前言

在上篇文章 Learn-Claude-Code | 笔记 | Concurrency | s14_new Cron Scheduler 中,我们介绍了开源项目 learn-claude-code 新版第十四个章节 s14_new: Cron Scheduler 的内容,这篇文章我们继续跟着教程文档来学习多 Agent 相关内容,记录下个人学习笔记,和大家一起分享交流😄

Note:本篇文章主要学习记录 新版教程 第五部分 Multi-Agent Platform 中 s19: MCP Plugin 章节的内容。

githubhttps://github.com/shareAI-lab/learn-claude-code

referencehttps://chatgpt.com/

1. s19: MCP Plugin

到了 s19,这个项目开始进入一个新的阶段:Agent 不再只依赖本地手写工具,而是开始具备接入外部工具系统的能力。

在前面的章节中,我们已经一步步构建出了一个相当完整的 Agent Harness:有 Agent Loop,有工具调用,有权限检查,有 Hooks,有 TodoWrite,有 Subagent,有 Skill,有 Context Compact,有 Memory,有 System Prompt,有 Error Recovery,有 Task System,有 Background Tasks,有 Cron Scheduler,有 Agent Teams,有 Team Protocols,有 Autonomous Agents,也有 Worktree Isolation。

但是这些能力还有一个明显的边界:工具都是我们自己写死在代码里的

比如 bashread_filewrite_filecreate_taskclaim_taskcreate_worktreeremove_worktree 等,都是在 harness 内部手动定义、手动注册、手动实现的。这样做适合教学,也适合理解 Agent Harness 的基础结构,但在真实工程中会遇到一个很大的问题:外部系统太多了。

比如公司内部有 Jira、Notion、部署平台、监控平台、日志系统、知识库、CI/CD 系统,如果每接入一个服务都要在 Agent 代码里重新写一套工具定义、参数 schema、调用逻辑和错误处理,那么工具系统很快就会变得臃肿,而且无法复用。

所以 s19 引入了一个新的概念:MCP Plugin

MCP 的核心作用,就是让外部服务按照统一协议暴露工具,然后 Agent 通过标准方式发现工具、注册工具、调用工具。换句话说,从 s19 开始,Agent 的工具来源不再只有本地内置工具,还可以来自外部 MCP Server。

这也是 s19 相对于 s18 最关键的变化:s18 解决的是任务执行隔离问题,而 s19 解决的是外部能力接入问题

2. 问题

在 s18 中,我们已经有了 worktree 隔离能力。Agent 可以把不同任务放到不同 worktree lane 中执行,避免多个任务同时修改同一个目录而发生冲突。

但是,哪怕有了任务隔离,Agent 能调用的工具仍然是固定的。它能读文件、写文件、执行命令、创建任务、创建 worktree、和 teammate 通信,但它并不能天然访问外部系统。

如果我们想让 Agent 查询公司文档、触发部署、查看服务状态、创建 issue,就必须继续在代码里手写工具。例如:

def jira_search_issue(...):
    ...

def deploy_trigger(...):
    ...

def notion_search_page(...):
    ...

然后还要给每个工具写 input_schema,写 handler,写错误处理,写权限规则,写工具描述。这样做的问题是很明显的:工具越多,harness 越重;外部服务越多,Agent 主程序越难维护。

更重要的是,不同外部服务可能由不同团队、不同语言、不同进程实现。如果每个服务都必须改 Agent 主程序才能接入,那么 Agent 的扩展能力就被限制住了。

所以 s19 要解决的问题就是:

当 Agent 需要外部工具时,能不能不要把所有工具都写死在 Agent 里,而是让外部服务按照统一协议暴露工具,Agent 只负责发现、组装和调用?

这就是 MCP Plugin 要解决的核心问题。

它不是再新增一个普通工具,而是新增一种工具接入方式。以前是 “Agent 内置什么工具,就只能用什么工具”;现在是 “外部服务只要实现 MCP 协议,Agent 就可以把它接入自己的工具池”。

3. 解决方案

s19 的解决方案可以概括为一句话:

用 MCPClient 连接外部 MCP Server,发现 server 暴露的工具,然后把这些工具动态组装进 Agent 的工具池中。

这时 Agent 的工具系统就从固定工具池变成了动态工具池。内置工具仍然存在,比如 bashread_filewrite_filecreate_taskcreate_worktree 等;但在这些内置工具之外,Agent 还可以通过 connect_mcp 连接外部 MCP Server,然后把外部工具加入同一个工具池。

教程文档中提供了一张 MCP 架构图:

在这里插入图片描述

这张图的重点不在于多了一个工具,而在于工具来源发生了变化。上半部分仍然是熟悉的 Agent Loop:turn → messages → prompt → LLM → tool dispatch → tool results → next turn。这说明 s19 并没有推翻前面的循环结构,Agent 仍然是通过模型选择工具、harness 执行工具、工具结果回填 messages 的方式运行。

真正新增的是中间的 MCP 架构。Agent 侧有一个 MCPClient,它负责连接外部 MCP Server、发现工具、组装工具池,并在模型调用 MCP 工具时把请求转发给对应 server。外部 MCP Server 则负责提供真实能力,例如文档检索、部署触发、服务状态查询等。

也就是说,s19 的设计把工具系统拆成了两层:

Agent Side:
  connect_mcp → discover → assemble_tool_pool → call_tool

MCP Server Side:
  tools/list → tools/call → return result

在教学部代码中,MCP Server 是用 mock handler 模拟的,不会真正启动外部线程,也不会真的走 JSON-RPC 网络通信。但是它保留了 MCP 的核心形态:server 暴露工具定义,client 发现工具定义,Agent 把这些工具注册进自己的工具池,然后像调用普通工具一样调用它们。

这也是 MCP 最重要的工程意义:Agent 不需要知道工具是谁写的,也不需要知道工具运行在哪个语言或哪个进程里,只要 server 按协议暴露工具,Agent 就能接入

4. MCP Tool Bridge 流程图分析

Web 教程提供的 MCP Tool Bridge 六张图,实际上是在用一个更直观的方式说明:MCP 工具是如何从 “外部工具箱” 一步步变成 Agent 可调用工具的。

1. Need a New Tool

在这里插入图片描述

第一张图展示的是初始状态。Agent 现在只有 built-in belt,也就是本地内置工具,例如 read_fileedit_filebash。这些工具足够完成本地文件操作,但当任务需要访问外部系统时,这些工具就不够了。

右侧的 External toolbox 里有一个 docs-server,但是它还处于 offline 状态。也就是说,外部服务存在,但 Agent 还没有连接它,也不知道提供什么工具。

这张图想表达的是:Agent 遇到新任务时,不一定要靠自己写死更多工具,而是可以接入一个外部工具箱

2. Plug In a Server

在这里插入图片描述

第二张图中,docs-server 变成 connected 状态。这对应代码里的 connect_mcp("docs")

连接完成后,Agent 知道有一个外部 MCP Server 已经接入,但此时图中仍然显示 schemas hidden until connected。这个过程可以理解为:连接 server 是第一步,真正可用还要等 server 暴露工具 schema。

这里的关键是 “plug in”。MCP Server 就像一个外部工具箱,Agent 不需要把工具提前写死到自己代码里,而是在运行时把这个工具箱接进来。

3. Read the Tool Labels

在这里插入图片描述

第三张图展示了 MCP Server 暴露出来的工具,例如 searchfetchlist_sections。这些工具不是 Agent 原本就有的,而是 MCP Server 通过工具发现机制告诉 Agent 的。

这一步对应 MCP 协议中的 tools/list。server 不只是说 “我存在”,还要告诉 Agent:我有哪些工具?每个工具叫什么?每个工具需要什么参数?每个工具是只读的还是可能产生副作用?

在教学部代码中,这一步由 mock server 的 register() 模拟。它把工具定义和 handler 注册到 MCPClient 里。

4. Name the Tools Clearly

在这里插入图片描述

第四张图中,外部工具进入了 Agent workbench,并且名字变成了 mcp__docs__searchmcp__docs__fetch 这样的形式。

这一步非常关键,因为外部工具可能会重名。例如 docs server 有一个 search,notion server 也可能有一个 search,jira server 也可能有一个 search。如果全部直接叫 search,工具池里就会冲突。

所以 s19 使用统一命名规则:mcp__{server}__{tool},例如 mcp__docs__searchmcp__deploy__trigger,这样一来,不同 server 的工具就不会互相覆盖。模型看到工具名时,也能大致知道这个工具来自哪个外部服务。

5. Use It Like Any Tool

在这里插入图片描述

第五张图展示的是调用阶段。此时 MCP 工具已经进入 Agent 的工具池,模型可以像调用普通工具一样调用它:mcp__docs__search({ query })

这说明 MCP 工具在 Agent Loop 中不是特殊旁路,它仍然遵循前面所有章节都在使用的节奏:LLM 选择工具 → harness 执行工具 → tool_result 回填 messages → 下一轮模型继续推理。也就是说,MCP 改变的是工具来源,不改变 Agent Loop 的基本结构。

6. Result Comes Back

在这里插入图片描述

第六张图展示的是 MCP 工具调用结果返回。外部 server 返回的数据会被包装成普通 tool_result,然后进入下一轮 messages。

这一步很重要,因为它说明 MCP 工具调用结果对于 Agent 来说并不是一个特殊对象,而是和 bashread_filewrite_file 的结果一样,都会进入对话历史,供下一轮模型继续理解和决策。

所以 MCP Tool Bridge 的完整链路可以概括为:连接 MCP Server → 发现工具 schema → 规范化工具名 → 加入 Agent 工具池 → 模型正常调用 → 结果作为 tool_result 回到循环。

这就是 s19 的核心:外部工具通过 MCP 被桥接进 Agent Harness,并且一旦进入工具池,就和内置工具一样参与模型-工具-结果循环

完整动画演示如下图所示:

在这里插入图片描述

5. 工作原理(代码分析)

s19 的代码是在 s18 的基础上继续扩展的,所以前半部分仍然保留了 task system、worktree system、message bus、protocol state、teammate thread 等内容。真正新增的重点在于 MCP 系统,也就是 MCPClient、mock server、connect_mcpassemble_tool_pool 以及动态 agent_loop

我们从上到下分析。

首先,s19 新增了一个 MCPClient 类:

class MCPClient:
    """Discovers and calls tools on an MCP server (mock for teaching)."""

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

    def register(self, tool_defs: list[dict],
                 handlers: dict[str, callable]):
        self.tools = tool_defs
        self._handlers = handlers

    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 handler(**args)
        except Exception as e:
            return f"MCP error: {e}"

这个类就是教学版 MCP Client 的核心抽象,它里面有两个重要字段:tools_handlers

tools 保存的是 server 暴露给 Agent 的工具定义,也就是工具名、description、input schema 等信息;_handlers 保存的是工具名到实际执行函数的映射。

在真实 MCP 中,tools/list 会返回工具定义,tools/call 会调用外部工具。而在教学版中,这两件事被简化成了 register()call_tool()。也就是说,register() 模拟工具发现,call_tool() 模拟工具调用。

这一步的工程意义是:Agent 端不再直接拥有工具实现,而是通过 MCPClient 间接调用外部 server 的能力

接着看 mock server 的实现:

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

这里定义了一个 docs server。它暴露两个工具:searchget_version。其中 search 需要一个 query 参数,get_version 不需要参数。

注意这里的 description 中有 (readOnly) 标注。这说明工具本身可以声明自己的性质,例如只读工具、危险工具、会产生副作用的工具等。教学版只是把这些信息写在 description 里,真实 Claude Code 中会有更结构化的 tool annotations,用来参与权限判断。

再看另一个 mock server:

def _mock_server_deploy():
    client = MCPClient("deploy")
    client.register(
        tool_defs=[
            {"name": "trigger",
             "description": "Trigger a deployment. (destructive — requires approval in real CC)",
             "inputSchema": {"type": "object",
                             "properties": {"service": {"type": "string"}},
                             "required": ["service"]}},
            {"name": "status", "description": "Check deployment status. (readOnly)",
             "inputSchema": {"type": "object",
                             "properties": {"service": {"type": "string"}},
                             "required": ["service"]}},
        ],
        handlers={
            "trigger": lambda service: f"[deploy] Triggered: {service}",
            "status": lambda service: f"[deploy] {service}: running (v1.4.2)",
        })
    return client

deploy server 暴露了 triggerstatus 两个工具。status 是只读的,trigger 是 destructive,因为它会触发部署。这说明 MCP 不只是用来扩展 “查询类工具”,也可以接入会产生真实副作用的外部工具。

当然,在真实系统中,这类 destructive MCP 工具不能直接执行,需要经过权限系统审批。教学版这里没有接入完整权限管线,只是在 description 里标注出来,帮助我们理解 MCP 工具也可以携带风险语义。

两个 mock server 最后被放进 MOCK_SERVERS 字典:

MOCK_SERVERS = {
    "docs": _mock_server_docs,
    "deploy": _mock_server_deploy,
}

这相当于教学版的 MCP Server 注册表。用户调用 connect_mcp("docs") 时,就会从这里找到对应工厂函数,创建一个 MCPClient 实例。

然后是名称规范化:

_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')

def normalize_mcp_name(name: str) -> str:
    """Replace non [a-zA-Z0-9_-] with underscore."""
    return _DISALLOWED_CHARS.sub('_', name)

这段代码看起来很小,但它很重要。因为 MCP server 名和工具名可能来自外部,不一定符合 Agent 工具名的规范。如果直接拼接到工具名里,可能导致冲突、非法字符,甚至引入命名注入问题。

所以 s19 会把所有非 [a-zA-Z0-9_-] 的字符替换成 _。这也是为什么 MCP 工具最后会变成类似这样的名字:

mcp__docs__search
mcp__deploy__trigger

接着看 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:
        available = ", ".join(MOCK_SERVERS.keys())
        return f"Unknown server '{name}'. Available: {available}"
    mcp_client = factory()
    mcp_clients[name] = mcp_client
    tool_names = [t["name"] for t in mcp_client.tools]
    print(f"  \033[31m[mcp] connected: {name}{tool_names}\033[0m")
    return (f"Connected to MCP server '{name}'. "
            f"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}")

这就是 MCP server 接入的入口。它先判断 server 是否已经连接,如果已经连接就直接返回;如果没有连接,就从 MOCK_SERVERS 中找到对应工厂函数,创建 MCPClient,并放入全局的 mcp_clients

这里最关键的是 mcp_clients

mcp_clients: dict[str, MCPClient] = {}

它记录了当前已经连接的 MCP server。后续 assemble_tool_pool() 会遍历这个字典,把所有 MCP server 的工具都加入 Agent 工具池。

也就是说,connect_mcp 只是连接和发现,真正让工具变成 Agent 可用工具的是下面的 assemble_tool_pool()

def assemble_tool_pool() -> tuple[list[dict], dict]:
    """Assemble builtin tools + all MCP tools into one pool."""
    tools = list(BUILTIN_TOOLS)
    handlers = dict(BUILTIN_HANDLERS)
    for server_name, mcp_client in mcp_clients.items():
        safe_server = normalize_mcp_name(server_name)
        for tool_def in mcp_client.tools:
            safe_tool = normalize_mcp_name(tool_def["name"])
            prefixed = f"mcp__{safe_server}__{safe_tool}"
            tools.append({
                "name": prefixed,
                "description": tool_def.get("description", ""),
                "input_schema": tool_def.get("inputSchema", {}),
            })
            handlers[prefixed] = (
                lambda *, c=mcp_client, t=tool_def["name"], **kw: c.call_tool(t, kw))
    return tools, handlers

这段代码是 s19 最重要的代码之一。它把工具池从 “固定列表” 变成了 “动态组装”。

首先,它会复制一份内置工具:

tools = list(BUILTIN_TOOLS)
handlers = dict(BUILTIN_HANDLERS)

然后遍历已经连接的 MCP server:

for server_name, mcp_client in mcp_clients.items():

对于每个 server,它会规范化 server 名和 tool 名,然后生成带命名空间的工具名:

prefixed = f"mcp__{safe_server}__{safe_tool}"

最后把这个工具追加到 tools 中,并把对应 handler 加到 handlers 中:

tools.append({
    "name": prefixed,
    "description": tool_def.get("description", ""),
    "input_schema": tool_def.get("inputSchema", {}),
})
handlers[prefixed] = (
    lambda *, c=mcp_client, t=tool_def["name"], **kw: c.call_tool(t, kw))

这里有一个细节:lambda 中使用了默认参数 c=mcp_client, t=tool_def["name"]。这是为了避免 Python 闭包变量延迟绑定问题。如果直接写 lambda **kw: mcp_client.call_tool(tool_def["name"], kw),循环结束后所有 handler 可能都指向最后一个 tool。通过默认参数固定当前循环变量,就能保证每个 MCP 工具都绑定到正确的 server 和原始工具名。

所以这一段代码完成了三件事:

  • 1. 保留内置工具
  • 2. 加入所有已连接 MCP server 的工具
  • 3. 为 MCP 工具生成 mcp__server__tool 形式的安全名称

接着看系统提示词组装部分:

PROMPT_SECTIONS = {
    "identity": "You are a coding agent. Act, don't explain.",
    "tools": "Available tools: bash, read_file, write_file, "
             "create_task, list_tasks, get_task, claim_task, complete_task, "
             "spawn_teammate, send_message, check_inbox, "
             "request_shutdown, request_plan, review_plan, "
             "create_worktree, remove_worktree, keep_worktree, "
             "connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.",
    "workspace": f"Working directory: {WORKDIR}",
    "memory": "Relevant memories are injected below when available.",
}

相比 s18,s19 的 prompt section 中新增了 connect_mcp,并明确告诉模型:MCP 工具会使用 mcp__{server}__{tool} 前缀。

这一步很重要,因为模型需要知道 MCP 工具的命名规则。否则即使工具池里有 MCP 工具,模型也不一定知道这些工具来自哪里、应该如何理解它们。

系统提示词的组装逻辑如下:

def assemble_system_prompt(context: dict) -> str:
    sections = [PROMPT_SECTIONS["identity"],
                PROMPT_SECTIONS["tools"],
                PROMPT_SECTIONS["workspace"]]
    if context.get("memories"):
        sections.append(f"Relevant memories:\n{context['memories']}")
    mcp_names = list(mcp_clients.keys())
    if mcp_names:
        sections.append(f"Connected MCP servers: {', '.join(mcp_names)}")
    return "\n\n".join(sections)

这里新增了一个判断:

mcp_names = list(mcp_clients.keys())
if mcp_names:
    sections.append(f"Connected MCP servers: {', '.join(mcp_names)}")

也就是说,一旦连接了 MCP server,system prompt 中就会出现已连接 server 的信息。这样模型不仅能看到工具列表,也能知道当前已经接入了哪些外部服务。

然后看内置工具列表:

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

s19 新增的内置工具就是 connect_mcp。它本身不是外部工具,而是一个 “连接外部工具箱” 的工具。模型先调用 connect_mcp,harness 连接 MCP server,然后动态工具池中才会出现新的 MCP 工具。

对应的 handler 是:

def run_connect_mcp(name: str) -> str:
    return connect_mcp(name)

并注册到内置 handler 中:

BUILTIN_HANDLERS = {
    ...
    "connect_mcp": run_connect_mcp,
}

最后看 Agent Loop:

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

        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return

        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            print(f"\033[36m> {block.name}\033[0m")
            handler = handlers.get(block.name)
            output = handler(**block.input) if handler else "Unknown"
            print(str(output)[:300])
            results.append({"type": "tool_result",
                            "tool_use_id": block.id, "content": output})
        messages.append({"role": "user", "content": results})

        if any(b.name == "connect_mcp" for b in response.content
               if b.type == "tool_use"):
            tools, handlers = assemble_tool_pool()
            context = update_context(context, messages)
            system = assemble_system_prompt(context)

这段代码和前面章节的 Agent Loop 结构保持一致:调用模型、追加 assistant response、判断是否 tool_use、执行工具、把 tool_result 回填 messages。

但是 s19 有两个关键变化。

第一个变化是开头不再直接使用固定的 BUILTIN_TOOLSBUILTIN_HANDLERS,而是调用:

tools, handlers = assemble_tool_pool()

这说明当前工具池是动态生成的。只要 mcp_clients 发生变化,工具池就可能发生变化。

第二个变化是在执行完工具后,判断本轮是否调用了 connect_mcp

if any(b.name == "connect_mcp" for b in response.content
       if b.type == "tool_use"):
    tools, handlers = assemble_tool_pool()
    context = update_context(context, messages)
    system = assemble_system_prompt(context)

如果模型刚刚调用了 connect_mcp,说明外部工具池已经变化了。此时必须重新组装工具池,并重新生成 system prompt。否则模型下一轮仍然只能看到旧工具列表,无法调用新发现的 MCP 工具。

这也是为什么教程文档中特别强调 s19 去掉了 prompt cache。因为从 s10 开始,系统提示词可以缓存;但到了 s19,工具池会在运行时变化,缓存可能会导致工具列表过期。教学版为了简化,直接每次动态组装工具池,保证模型看到的是最新工具状态。

所以 s19 的工作原理可以总结成下面这条链路:用户要求连接外部服务 → 模型调用 connect_mcp → harness 创建 MCPClient → MCPClient 发现 server 工具 → assemble_tool_pool 生成 mcp__server__tool 工具 → system prompt 更新已连接 server 信息 → 模型下一轮可以调用 MCP 工具 → MCP 工具结果以 tool_result 返回 messages。

这条链路说明,MCP 并没有改变 Agent Loop 的本质。模型仍然只是选择工具,harness 仍然负责执行工具,工具结果仍然回到 messages。MCP 真正改变的是工具来源:工具不再只能来自本地代码,而可以来自外部 server。

博主在给定下面的提示词情况下:

Connect to the docs MCP server and search for something

想通过调试看看整个过程发生了什么,我们来具体分析下:

Loop 1

在这里插入图片描述

第一次循环的工具和系统提示词

在这里插入图片描述

第一次循环模型响应

在这里插入图片描述

第一次循环 connect mcp 工具执行

在这里插入图片描述

第一次循环工具执行结果

在这里插入图片描述

第一次循环重新整合工具和系统提示词

从第一次循环可以看到,Agent Loop 一开始调用 assemble_tool_pool() 时,工具池里还只有内置工具,例如 bashread_filewrite_filecreate_taskcreate_worktreeconnect_mcp 等。此时 handlers 中也只有这些内置工具对应的执行函数,还没有任何 mcp__docs__xxx 形式的外部工具。

这也说明了一个关键点:MCP 工具不是一开始就默认出现在工具池里的,而是需要通过 connect_mcp 先完成连接和发现。因此系统提示词里虽然已经告诉模型 “MCP tools are prefixed mcp__{server}__{tool}”,但当前真正可用的外部 MCP 工具还没有被注册进来。

随后模型第一次响应时,并没有直接调用 mcp__docs__search,而是先调用了内置工具 connect_mcp,参数是:

{"name": "docs"}

这一步非常符合 s19 的设计:模型先意识到自己需要外部 docs 能力,于是向 harness 申请连接 docs MCP server。真正的连接动作发生在 connect_mcp() 里,它会从 MOCK_SERVERS 中找到 docs 对应的 mock server 工厂函数,然后创建 MCPClient,并把它放入全局的 mcp_clients 字典中。

调试图里可以看到,mcp_clients 中新增了 docs,并且 tool_names 被解析为:

["search", "get_version"]

终端也打印出:

[mcp] connected: docs → ['search', 'get_version']

这说明 docs server 已经连接成功,并且 server 暴露的两个工具也已经被发现。工具执行结果随后被包装成标准的 tool_result

{
  "type": "tool_result",
  "tool_use_id": "...",
  "content": "Connected to MCP server 'docs'. Discovered 2 tools: search, get_version"
}

也就是说,对模型来说,connect_mcp 本身仍然只是一次普通工具调用;对 harness 来说,这次工具调用改变了后续工具池的结构。

因此在第一次循环末尾,代码检测到本轮调用过 connect_mcp,于是重新执行:

tools, handlers = assemble_tool_pool()
context = update_context(context, messages)
system = assemble_system_prompt(context)

这一步就是 s19 的关键。重新组装之后,tools 中已经新增了:

mcp__docs__search
mcp__docs__get_version

handlers 中也新增了对应的 lambda handler,用来把模型调用转发到 MCPClient.call_tool()。同时 system prompt 也更新出:

Connected MCP servers: docs

所以第一次循环完成了从 “只有内置工具” 到 “内置工具 + MCP 外部工具” 的转变。换句话说,Loop 1 的核心不是完成搜索,而是完成外部工具箱的接入和工具池刷新。

Loop 2

在这里插入图片描述

第二次循环模型响应

在这里插入图片描述

第二次循环工具执行结果

第二次循环时,模型已经可以看到更新后的工具池了。因此它没有再次调用 connect_mcp,而是直接调用新注册进来的 MCP 工具:

mcp__docs__search

参数为:

{"query": "Claude Code SDK"}

这说明 MCP 工具一旦进入 tools,对模型来说就和普通工具没有本质区别。模型并不需要知道背后是 mock server、外部进程,还是 JSON-RPC;它只需要看到工具名、description 和 input schema,然后按照工具 schema 生成工具调用即可。

执行阶段仍然走 Agent Loop 中统一的工具执行逻辑:

handler = handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"

区别只在于,这里的 handler 不再是普通的本地函数,而是 assemble_tool_pool() 里为 MCP 工具动态生成的 lambda。这个 lambda 会继续调用:

c.call_tool(t, kw)

也就是转发给 docs MCPClient 中的 search handler。

最终返回结果是:

[docs] Found 3 results for 'Claude Code SDK'

这个结果同样被包装成普通的 tool_result,追加到 messages 中,供下一轮模型继续使用。

所以 Loop 2 证明了一点:MCP 工具虽然来自外部 server,但一旦完成注册,它在 Agent Loop 里的行为和内置工具完全一致,仍然是 “tool_use → handler → tool_result → messages”。

Loop 3

在这里插入图片描述

第三次循环模型响应

在这里插入图片描述

第三次循环工具执行结果

第三次循环中,模型根据第二次循环返回的搜索结果继续推理。它认为搜索已经返回了 3 条结果,但为了完整展示 docs MCP server 的能力,还需要再查询一下 API version,于是继续调用另一个 MCP 工具:

mcp__docs__get_version

这个工具没有输入参数,因此 input 是空字典:

{}

这一步说明 MCP server 暴露的多个工具可以在同一个连接状态下被连续调用。第一次连接 docs server 后,mcp__docs__searchmcp__docs__get_version 都已经进入工具池,模型可以根据任务需要自由选择其中任意一个。

工具执行结果为:

[docs] API v2.1.0

它同样被加入 results,并作为 tool_result 回填到 messages。这说明 MCP 工具的结果不会走特殊通道,而是继续复用原来的消息循环机制。

这也是 s19 设计得很干净的地方:MCP 插件只负责扩展工具来源,不改变主循环的数据结构。无论是 read_file 的结果,还是 mcp__docs__get_version 的结果,最终都以 tool_result 的形式进入下一轮上下文。

Loop 4

在这里插入图片描述

第四次循环模型响应

第四次循环中,模型已经拿到了两个关键信息:一个是 mcp__docs__search 返回的搜索结果,另一个是 mcp__docs__get_version 返回的 API 版本。此时模型不再继续调用工具,而是生成最终文本响应,因此 response.stop_reason 不再是 tool_use,Agent Loop 直接返回。

最终模型整理出一个 docs MCP server 的摘要,包括连接状态、API version、可用工具、搜索 query 和搜索结果:

Status: Connected
API Version: v2.1.0
Tools: search, get_version
Search query: Claude Code SDK
Results: 3 results found

这一步说明 MCP 工具调用并不是孤立执行的。模型会把前几轮 MCP 工具返回的 tool_result 综合起来,然后形成最终答复。换句话说,MCP 只是把外部能力接入进来,最终的理解、总结和决策仍然由模型完成。

本次调试过程中的完整输出如下:

在这里插入图片描述

从完整输出可以看到,整个过程非常清晰:

> connect_mcp
[mcp] connected: docs -> ['search', 'get_version']
Connected to MCP server 'docs'. Discovered 2 tools: search, get_version

> mcp__docs__search
[docs] Found 3 results for 'Claude Code SDK'

> mcp__docs__get_version
[docs] API v2.1.0

这几行输出正好对应 s19 的完整 MCP 调用链路。第一步,模型先调用内置工具 connect_mcp,让 harness 连接 docs MCP server,并发现 searchget_version 两个外部工具。第二步,工具池刷新后,模型开始调用动态生成的 MCP 工具 mcp__docs__search。第三步,模型继续调用 mcp__docs__get_version 补充信息。最后,模型根据这些工具结果生成总结。

这次调试最能说明的点是:MCP 并不是给 Agent Loop 另开一条旁路,而是把外部工具 “翻译” 成 Agent 已经认识的工具格式。模型看到的是工具名、描述和 schema;harness 执行的是 handler;结果返回的仍然是 tool_result

所以,s19 的核心工程价值可以概括为一句话:MCP 让工具池从静态内置列表变成动态外部能力集合,但 Agent Loop 的运行节奏仍然保持不变

OK,以上就是 s19 MCP Plugin 工作原理的完整分析了。

那大家感兴趣的话可以试试下面这些 prompt 感受下 MCP Tools 引入后的一些变化:

1. Connect to the docs MCP server and search for something

2. Connect to the deploy server and trigger a deployment

3. Connect both servers — what tools are now available?

6. 相对 s18 的变更

组件 之前 (s18) 之后 (s19)
工具来源 全部手写 builtin 手写 + MCP 外部工具动态发现
工具池 固定 BUILTIN_TOOLS assemble_tool_pool 动态组装 mcp__ 前缀工具
名称安全 normalize_mcp_name 规范化
新类型 MCPClient 类(模拟 tools/list + tools/call)
命名空间 mcp__server__tool 避免冲突
工具描述 无标注 (readOnly)/(destructive) 标注
prompt 缓存 有(s10 起) 去掉——工具池动态变化后缓存失效
Lead 工具 17 (s18) 18 (+connect_mcp)
Teammate 工具 8 (s18) 8(不变,MCP 工具仅 Lead 可用)
扩展方式 写代码加工具 标准协议,任意语言实现 server

7. 小结

在 s18 中,Agent 已经可以通过 worktree 隔离多个任务的执行空间;而到了 s19,Agent 开始通过 MCP 接入外部工具系统。这个变化让工具不再必须写死在 Agent 主程序里,而是可以由外部 MCP Server 按标准协议暴露出来。

从代码上看,s19 新增了 MCPClientconnect_mcpnormalize_mcp_nameassemble_tool_pool。其中 MCPClient 负责模拟 server 工具发现和调用,connect_mcp 负责连接外部 server,normalize_mcp_name 负责保证工具命名安全,assemble_tool_pool 则负责把内置工具和 MCP 工具组装成统一工具池。

从运行流程上看,模型先调用 connect_mcp 连接外部服务,harness 发现该 server 暴露的工具,然后把这些工具以 mcp__server__tool 的形式加入工具池。下一轮模型就可以像调用普通工具一样调用 MCP 工具,工具结果也会以 tool_result 的形式回到 messages。

所以,s19 的重点不是 “多了几个工具”,而是 “工具扩展方式变了”。以前扩展工具需要改 harness 代码;现在外部服务只要实现 MCP 协议,就可以被 Agent 动态接入。

至此,Agent Harness 已经不只是一个本地任务执行系统,而是开始具备连接外部世界的能力。MCP 让 Agent 的工具边界从本地代码扩展到外部服务,也为下一节 s20 Comprehensive Agent 把所有机制整合到同一个完整 harness 中做好了准备。

OK,以上就是本期想要分享的全部内容了。

结语

本篇文章我们围绕 s19 MCP Plugin 这一节,从问题出发,结合流程图与代码实现,完整梳理了 Agent 是如何通过 MCP 协议,将外部服务动态接入自身工具体系的。

相比前面的章节,s19 最本质的变化并不是新增了某个工具,而是第一次改变了工具的来源。此前无论是 bashread_filecreate_task 还是 create_worktree,所有能力都来自 Agent Harness 内部;而到了这一节,工具开始可以来自外部 MCP Server。Agent 不再需要提前知道工具的实现细节,只需要按照统一协议发现、注册和调用这些能力即可。

从工程视角来看,这一步实际上完成了一次非常重要的架构解耦:Agent 不再直接依赖具体工具,而开始依赖工具协议。 外部服务负责暴露工具定义和执行能力,MCPClient 负责发现和转发,Agent Harness 则负责统一组装工具池并驱动模型使用。这样一来,新增能力不再意味着修改主程序,而是可以通过接入新的 MCP Server 来完成。

如果说前面的章节一直在构建一个越来越完整的 Agent Runtime,那么 s19 则完成了另一件同样重要的事情:让这个 Runtime 开始具备连接外部生态的能力。 Agent 不再只是一个封闭运行的本地系统,而开始演化为一个能够持续吸收外部能力的平台。

从整个 learn-claude-code 的演进路径来看,前面的章节一直在回答 “Agent 自己如何变强”,而 s19 开始回答另一个问题—当 Agent 自己的能力不够时,如何借助外部世界的能力继续扩展。 这也正是 MCP 最核心的价值所在。

也正因为如此,MCP Plugin 的意义并不只是新增了一种工具接入方式,而是在整个系统层面完成了一次从 “封闭能力集合” 到 “开放能力生态” 的跃迁。而这一步,也正是下一节 Comprehensive Agent 将所有能力整合到统一平台之前,最重要的一块拼图。

下篇文章我们将来学习新版教程最后一个章节 s20 Comprehensive Agent 的内容,敬请期待🤗

参考

更多推荐