s16:MCP Plugin(Model Context Protocol 工具插件)

核心问题


Lead 和队友目前只能调用直接写在 code.py 里的内置工具。要接 Jira、部署平台或知识库,Harness 得为每个外部系统单独写工具定义和调用逻辑——外部系统一改,课程代码也得跟着改

MCP(Model Context Protocol)解决的就是这个问题:用统一的发现与调用协议,在运行时连接外部服务,把它们提供的工具动态加进工具池。


1. MCP 协议基础:tools/listtools/call


MCP 协议规范定义了两个核心 RPC 方法(基于 JSON-RPC 2.0):

tools/list — 发现工具

客户端请求服务器列出所有可用工具:

// 请求
{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {}
}

// 响应
{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "tools": [
            {
                "name": "search",
                "description": "Search the documentation.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "query": {"type": "string"}
                    },
                    "required": ["query"]
                }
            }
        ]
    }
}

tools/call — 调用工具

客户端请求服务器执行某个工具:

// 请求
{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
        "name": "search",
        "arguments": {"query": "hello"}
    }
}

// 响应
{
    "jsonrpc": "2.0",
    "id": 2,
    "result": {
        "content": [{"type": "text", "text": "Found 3 results..."}]
    }
}

2. 真实 MCP 部署架构


本地部署(stdio)

┌─────────────────────────────────────────────────────┐
│ 你的电脑 (Host)                                     │
│                                                     │
│  ┌──────────────┐        ┌──────────────────┐      │
│  │ Agent Harness │        │ MCP Server       │      │
│  │              │        │ (docs)           │      │
│  │  MCPClient ──┼────────┤                  │      │
│  │              │ stdio/ │  search()        │      │
│  │              │ WebSocket                 │      │
│  └──────────────┘        └──────────────────┘      │
└─────────────────────────────────────────────────────┘

远程部署(Streamable HTTP / WebSocket)

┌─────────────────────────────────────────────────────┐
│ 你的电脑 (Host)                                     │
│                                                     │
│  ┌──────────────┐        ┌──────────────────┐      │
│  │ Agent Harness │        │ 远程服务器        │      │
│  │              │        │ (另一台电脑/云)   │      │
│  │  MCPClient ──┼────────┤                  │      │
│  │              │ HTTPS/ │  MCP Server      │      │
│  │              │ WebSocket                 │      │
│  └──────────────┘        └──────────────────┘      │
└─────────────────────────────────────────────────────┘

三种通信方式对比

方式 适用场景 传输协议 特点
stdio 本地进程通信(同一台电脑) 标准输入/输出管道 Server 作为子进程运行,适合本地工具(文件系统、数据库等);最简单、最低延迟
Streamable HTTP(新版推荐) 远程 HTTP 通信 HTTP POST + SSE Server 运行在远程服务器,客户端通过 HTTP 请求通信;支持流式响应
WebSocket 双向实时通信 WebSocket 协议 全双工通信,适合需要服务端主动推送的场景

SSE vs WebSocket 区别:SSE(Server-Sent Events)是单向的(服务器→客户端),适合服务器推送更新;WebSocket 是双向的,客户端和服务端都能随时发消息。MCP 的 Streamable HTTP 模式使用 SSE 来实现服务端的流式响应推送。


3. 真实 MCP Client 实现


原课程代码的问题

原课程中的 _mock_server_docs() 是一个模拟实现——它直接在进程内创建 MCPClient 并注册假的 handler(lambda 函数返回硬编码字符串)。这在教学中便于理解接口,但跳过了真实的通信过程

下面补充真实的 MCP 连接和通信代码。

真实的连接过程

# ============================================================
# 真实 MCP Client:建立连接、发现工具、调用工具
# ============================================================

import subprocess
import json
import asyncio
from typing import Any


class RealMCPClient:
    """
    真实的 MCP 客户端,负责与 MCP Server 建立通信连接。
    
    支持两种传输方式:
    - stdio:本地子进程通信(通过 stdin/stdout 传输 JSON-RPC 消息)
    - Streamable HTTP:远程网络通信(通过 HTTP POST + SSE)
    """

    def __init__(self, server_name: str, transport: str = "stdio",
                 command_or_url: str | list[str] | None = None):
        """
        初始化 MCP 客户端
        
        Args:
            server_name: 服务器名称(用于日志和标识)
            transport: 传输方式,"stdio" 或 "streamable-http"
            command_or_url:
                - stdio 模式:启动 server 子进程的命令(如 ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path"]
                - HTTP 模式:server 的 URL(如 "https://my-mcp-server.example.com/mcp")
        """
        self.server_name = server_name
        self.transport = transport
        self.command_or_url = command_or_url
        self.process: subprocess.Popen | None = None   # stdio 模式的子进程
        self.session_id: str | None = None             # HTTP 模式的会话 ID
        self._request_id = 0                            # JSON-RPC 请求 ID 计数器
        self.tools: list[dict] = []                     # 从 server 发现的工具列表

    # ---------- 连接管理 ----------

    async def connect(self) -> None:
        """建立与 MCP Server 的连接"""
        if self.transport == "stdio":
            await self._connect_stdio()
        elif self.transport == "streamable-http":
            await self._connect_http()
        else:
            raise ValueError(f"Unsupported transport: {self.transport}")

        # 连接成功后,自动发现可用工具
        await self._discover_tools()

    async def _connect_stdio(self) -> None:
        """
        stdio 模式连接:启动 server 子进程,通过 stdin/stdout 通信
        
        这是本地 MCP 最常用的方式。server 作为子进程运行,
        客户端通过标准输入写 JSON-RPC 请求,从标准输出读响应。
        """
        if isinstance(self.command_or_url, str):
            cmd = self.command_or_url.split()
        else:
            cmd = self.command_or_url or []

        # 启动子进程
        # stdin=PIPE: 我们要往 server 写请求数据
        # stdout=PIPE: 我们要从 server 读响应数据
        # stderr=PIPE: 方便调试时查看 server 日志
        self.process = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=False,  # 使用二进制模式,避免编码问题
            bufsize=0,    # 无缓冲,实时通信
        )
        print(f"[MCP] Connected to '{self.server_name}' via stdio (PID: {self.process.pid})")

    async def _connect_http(self) -> None:
        """
        Streamable HTTP 模式连接:通过网络请求与远程 server 通信
        
        流程:
        1. POST /mcp 发送 initialize 请求
        2. Server 返回 session ID 和能力声明
        3. 后续请求携带 session ID
        4. Server 通过 SSE 推送通知/流式响应
        """
        import aiohttp

        base_url = self.command_or_url
        async with aiohttp.ClientSession() as session:
            self._http_session = session

            # 1. 发送 initialize 握手
            init_response = await self._send_http_request(
                base_url, "initialize",
                {
                    "protocolVersion": "2024-11-05",
                    "capabilities": {},
                    "clientInfo": {"name": "harness-agent", "version": "1.0.0"},
                }
            )

            # 2. 获取 session ID(用于后续请求)
            self.session_id = init_response.get("result", {}).get("session_id")
            
            # 3. 发送 initialized 通知(握手完成)
            await self._send_http_notification(base_url, "notifications/initialized")

        print(f"[MCP] Connected to '{self.server_name}' via HTTP (session: {self.session_id})")

    # ---------- JSON-RPC 通信 ----------

    def _next_id(self) -> int:
        """生成下一个 JSON-RPC 请求 ID"""
        self._request_id += 1
        return self._request_id

    async def _send_request(self, method: str, params: dict | None = None) -> dict:
        """
        发送 JSON-RPC 请求并等待响应(stdio 模式)
        
        Args:
            method: RPC 方法名(如 "tools/list"、"tools/call")
            params: 方法参数
            
        Returns:
            服务器的 JSON-RPC 响应(解析后的 dict)
        """
        import asyncio

        request = {
            "jsonrpc": "2.0",
            "id": self._next_id(),
            "method": method,
            "params": params or {},
        }
        message = json.dumps(request) + "\n"

        # 写入 server 的 stdin
        assert self.process and self.process.stdin
        self.process.stdin.write(message.encode("utf-8"))
        await self.process.stdin.drain()

        # 从 server 的 stdout 读取响应
        assert self.process.stdout
        raw_response = await asyncio.get_event_loop().run_in_executor(
            None, self.process.stdout.readline
        )
        response = json.loads(raw_response.decode("utf-8"))

        if "error" in response:
            raise MCPError(response["error"])
        return response

    async def _send_http_request(self, base_url: str, method: str, 
                                  params: dict | None = None) -> dict:
        """发送 HTTP JSON-RPC 请求(Streamable HTTP 模式)"""
        url = f"{base_url.rstrip('/')}/"
        headers = {"Content-Type": "application/json"}
        if self.session_id:
            headers["Mcp-Session-Id"] = self.session_id

        body = {
            "jsonrpc": "2.0",
            "id": self._next_id(),
            "method": method,
            "params": params or {},
        }

        async with self._http_session.post(url, json=body, headers=headers) as resp:
            return await resp.json()

    async def _send_http_notification(self, base_url: str, method: str,
                                       params: dict | None = None) -> None:
        """发送 HTTP JSON-RPC 通知(不需要响应)"""
        url = f"{base_url.rstrip('/')}/"
        headers = {"Content-Type": "application/json"}
        if self.session_id:
            headers["Mcp-Session-Id"] = self.session_id

        body = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params or {},
        }

        async with self._http_session.post(url, json=body, headers=headers) as resp:
            pass  # 通知不需要等待响应

    # ---------- 工具发现 ----------

    async def _discover_tools(self) -> None:
        """连接后自动调用 tools/list 发现可用工具"""
        response = await self._send_request("tools/list")
        self.tools = response.get("result", {}).get("tools", [])
        tool_names = [t["name"] for t in self.tools]
        print(f"[MCP] Discovered {len(self.tools)} tools on '{self.server_name}': {tool_names}")

    def list_tools(self) -> list[dict]:
        """返回已发现的工具定义列表"""
        return self.tools

    # ---------- 工具调用 ----------

    async def call_tool(self, tool_name: str, arguments: dict) -> str:
        """
        调用 MCP Server 上的工具
        
        Args:
            tool_name: 工具名称(必须是 tools/list 返回过的名字)
            arguments: 工具参数(符合工具的 inputSchema 定义)
            
        Returns:
            工具执行的文本结果
        """
        response = await self._send_request("tools/call", {
            "name": tool_name,
            "arguments": arguments,
        })
        
        # 解析 content 数组,提取文本内容
        contents = response.get("result", {}).get("content", [])
        text_parts = []
        for item in contents:
            if item.get("type") == "text":
                text_parts.append(item.get("text", ""))
            elif item.get("type") == "image":
                text_parts.append("[image content]")
            else:
                text_parts.append(json.dumps(item))
        
        return "\n".join(text_parts)

    # ---------- 生命周期 ----------

    async def close(self) -> None:
        """关闭连接,清理资源"""
        if self.process:
            # stdio 模式:关闭子进程的 stdin(发 EOF),然后等待退出
            assert self.process.stdin
            self.process.stdin.close()
            try:
                self.process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.process.kill()
            print(f"[MCP] Disconnected from '{self.server_name}' (stdio)")
        
        if hasattr(self, '_http_session'):
            # HTTP 模式:关闭会话
            await self._http_session.close()
            print(f"[MCP] Disconnected from '{self.server_name}' (HTTP)")


class MCPError(Exception):
    """MCP 协议级错误"""
    pass


# ============================================================
# 使用示例
# ============================================================

async def connect_to_real_mcp_server():
    """
    真实使用示例:连接到一个 MCP 文件系统 server
    
    这个 server 由 @modelcontextprotocol/server-physics 或类似项目提供,
    通过 npx 启动,提供文件系统的读写能力。
    """
    
    # 示例 1:stdio 模式连接本地 server
    client_stdio = RealMCPClient(
        server_name="filesystem",
        transport="stdio",
        command_or_url=["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp/project"],
    )
    await client_stdio.connect()
    
    # 查看发现了哪些工具
    tools = client_stdio.list_tools()
    for t in tools:
        print(f"  - {t['name']}: {t.get('description', 'no description')}")
    
    # 调用工具
    result = await client_stdio.call_tool("read_file", {"path": "/tmp/project/README.md"})
    print(f"Result: {result[:200]}")
    
    await client_stdio.close()


# 同步包装器(供 harness 内部使用)
class SyncMCPClient:
    """
    同步封装的 MCP Client,适配 harness 的同步执行模型
    
    内部维护一个事件循环来运行异步的 RealMCPClient 操作。
    这使得 MCP 工具调用可以像普通函数一样同步使用。
    """

    def __init__(self, name: str):
        self.name = name
        self._async_client: RealMCPClient | None = None
        self.tools: list[dict] = []
        self._handlers: dict[str, callable] = {}  # 工具名 → 处理函数(同步闭包)

    def connect(self, command_or_url: str, transport: str = "stdio") -> list[dict]:
        """
        同步连接方法:内部启动事件循环执行异步连接
        
        Args:
            command_or_url: 启动命令或 URL
            transport: 传输方式
            
        Returns:
            发现的工具列表
        """
        import asyncio

        self._async_client = RealMCPClient(
            server_name=self.name,
            transport=transport,
            command_or_url=command_or_url,
        )

        # 在新的事件循环中运行异步连接
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            loop.run_until_complete(self._async_client.connect())
        finally:
            loop.close()

        self.tools = self._async_client.list_tools()
        return self.tools

    def register_handlers(self, handlers: dict[str, callable]) -> None:
        """
        注册工具处理器映射
        
        每个 handler 是一个同步函数,它内部会调用异步的 call_tool。
        这里用闭包把异步调用包装成同步接口。
        """
        self._handlers = handlers

    def call_tool(self, tool_name: str, args: dict) -> str:
        """
        同步调用工具
        
        内部通过新事件循环执行异步调用,对外暴露同步接口。
        所有异常都被捕获并转为错误字符串返回(不崩溃)。
        """
        if not self._async_client:
            return f"MCP error: client '{self.name}' not connected"

        import asyncio
        loop = asyncio.new_event_loop()
        try:
            result = loop.run_until_complete(
                self._async_client.call_tool(tool_name, args)
            )
            return result
        except Exception as exc:
            return f"MCP error: {type(exc).__name__}: {exc}"
        finally:
            loop.close()

真实 vs Mock 对比

维度 Mock(原课程代码) 真实实现(上方代码)
Server 进程 不存在,全是假数据 真实子进程(stdio)或远程服务(HTTP)
通信方式 直接函数调用(无通信) JSON-RPC over stdin/stdout 或 HTTP
工具发现 硬编码 tool_defs 列表 调用 tools/list RPC 动态获取
工具调用 lambda 返回假字符串 调用 tools/call RPC 真正执行
错误处理 无真实错误 网络超时、进程崩溃、协议错误等
资源管理 无需清理 需要正确关闭子进程/HTTP 会话

4. MCPClient 封装(Harness 内部使用的接口)


上面的 RealMCPClient / SyncMCPClient 负责底层通信。Harness 内部在此基础上再做一层封装,统一管理工具注册和调用:

MCPClient

代表"一个已连接的外部 server",保存 server 返回的工具定义列表和每个工具对应的 handler。

核心方法

register(tool_defs, handlers) — 注册工具

把 server 给的工具定义和 handler 收进来;并做三道校验

  1. 名字非空:每个工具必须有非空字符串名称
  2. 无重名:同一个 server 内工具名不能重复
  3. handler 完整每个工具定义都必须有对应的处理函数

任何一项不满足直接抛 ValueError,绝不静默忽略。

def register(self, tool_defs: list[dict], handlers: dict[str, callable]):
    names = [tool.get("name") for tool in tool_defs]

    # 校验 1:名称非空
    if any(not isinstance(name, str) or not name for name in names):
        raise ValueError("Every MCP tool needs a non-empty name")

    # 校验 2:无重复名称
    if len(set(names)) != len(names):
        raise ValueError(f"Duplicate MCP tool name on server {self.name!r}")

    # 校验 3:每个工具都有对应 handler
    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)

call_tool(tool_name, args) — 调用工具(统一异常处理)

按名字找到 handler 执行;找不到或执行报错,都返回错误字符串,而不是让整个 Agent Loop 崩掉。这正是**“错误留在工具边界内”**的关键设计。

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

5. 工具名规范化


为什么需要规范化?

  • Anthropic API 对工具名有限制:只允许字母、数字、下划线、连字符
  • MCP 服务器来自第三方:工具名可能包含特殊字符(如 /.、空格等)
  • 多 server 可能命名冲突:不同 server 可能有同名工具

规范化规则

原始工具名经过处理后变为:mcp__{server}__{tool}

例如:

  • server=docs, tool=searchmcp__docs__search
  • server=file-system, tool=read_filemcp__file-system__read_file
  • server=db, tool=query/tablemcp__db__query_table(特殊字符被替换)

工具定义结构

每个 MCP 工具的定义包含以下字段:

字段 类型 说明
name string 工具名(在 server 内唯一)
description string 工具描述(供 LLM 理解用途)
inputSchema JSON Schema 输入参数定义(类型、属性、必填项等)
annotations object 工具属性提示:readOnlyHint=只读,destructiveHint=有破坏性

6. connect_mcp — 连接并发现工具


连接流程(4 步判断):

步骤 条件 行为
1 已连接过 返回"已连接",不重复连
2 配置中找不到 server 名 报错:“未知 server”
3 找到了 调工厂函数造出 server client,存进全局 mcp_clients 字典
4 连接成功 打印发现的工具名,返回给模型
# 伪代码示意
def connect_mcp(server_name: str) -> list[dict]:
    """连接 MCP server 并发现工具"""
    # 1. 检查是否已连接
    if server_name in mcp_clients:
        print(f"[MCP] Already connected to '{server_name}'")
        return mcp_clients[server_name].tools

    # 2. 查找 server 配置
    if server_name not in SERVER_CONFIGS:
        raise ValueError(f"Unknown MCP server: {server_name}")

    # 3. 创建 client 并连接
    config = SERVER_CONFIGS[server_name]
    client = SyncMCPClient(name=server_name)
    tools = client.connect(
        command_or_url=config["command"],  # 或 config["url"]
        transport=config.get("transport", "stdio"),
    )

    # 4. 存入全局字典
    mcp_clients[server_name] = client
    print(f"[MCP] Connected to '{server_name}', found {len(tools)} tools")
    return tools

7. assemble_tool_pool — 工具池组装


做什么

每轮循环调用模型前,将内置工具和所有已连接的 MCP 工具合并,拼成一份全新的 (tools, handlers) 返回给模型。

组装过程中的校验链

遍历每个已连接的 MCP server
    │
    ├─→ 规范化 server 名和工具名
    │     (特殊字符替换、长度限制 64 字符)
    │
    ├─→ 拼出前缀名 mcp__{server}__{tool}
    │
    ├─→ 长度检查(>64 字符报错)
    │
    ├─→ 冲突检查(已有同名工具报错)
    │
    ├─→ Schema 验证(inputSchema 必须是合法的 object)
    │
    ├─→ 加入 tools 列表
    │
    ├─→ 闭包绑定 handler(延迟绑定 server 和 tool 引用)
    │
    └─→ 设置授权策略(默认 confirm,可按 server+tool 覆盖)

代码实现

def assemble_tool_pool() -> tuple[list[dict], dict[str, callable]]:
    """组装完整的工具池:内置工具 + 所有已连接的 MCP 工具"""

    global mcp_tool_policies

    # 1. 从内置工具开始
    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
    }

    # 2. 遍历每个已连接的 MCP server
    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}"

            # 长度检查(API 限制)
            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(
                    f"MCP tool name collision after normalization: "
                    f"{prefixed!r} maps both {origins[prefixed]} and {origin}"
                )

            # Schema 验证
            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,
            })

            # 闭包绑定:延迟绑定 server 和 tool 引用
            # (避免循环引用和 late binding 问题)
            handlers[prefixed] = (
                lambda *, client=server, tool=raw_name, **kwargs:
                client.call_tool(tool, kwargs)
            )

            # 授权策略:默认 confirm,可按 (server, tool) 覆盖
            policies[prefixed] = MCP_HOST_POLICY.get(
                (server_name, raw_name), "confirm"
            )

    mcp_tool_policies = policies
    return tools, handlers

8. 权限检查


做什么

决定外部工具能不能直接跑,还是先问用户。授权策略来自宿主配置

权限钩子

permission_hook 挂在 PreToolUse hook 上,在每次工具调用前执行:

def permission_hook(block):
    # ... 内置工具的检查(省略) ...

    # MCP 工具的检查:对名字以 "mcp__" 开头的工具
    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  # None 表示放行

策略配置

MCP_HOST_POLICY = {
    # 格式:(server_name, tool_name) → "allow" | "confirm" | "deny"
    ("docs", "search"): "allow",           # docs 的 search 工具:自动放行
    ("docs", "get_version"): "allow",      # docs 的 get_version:自动放行
    ("deploy", "status"): "allow",         # deploy 的 status:自动放行
    ("deploy", "trigger"): "confirm",      # deploy 的 trigger:需要确认
    # 未列出的默认为 "confirm"
}
策略值 行为
"allow" 直接放行,不询问用户
"confirm" 每次调用前询问用户确认
"deny" 直接拒绝(也可不配置,等效于不注册该工具)
(未配置) 默认 "confirm"

9. 完整执行流程(Agent Loop 集成)


Agent Loop 中 MCP 的位置

def agent_loop(messages: list):
    while True:
        try:
            # ① 每次循环重新组装工具池(MCP 工具可能变化)
            tools, handlers = assemble_tool_pool()

            # ② 调用 LLM(传入动态工具列表)
            response = client.messages.create(
                model=MODEL,
                system=assemble_system_prompt(),  # 动态系统提示
                messages=messages,
                tools=tools,                     # ← 包含内置 + MCP 工具
                max_tokens=8000,
            )
        except Exception as exc:
            # 错误处理...
            return

        messages.append({"role": "assistant", "content": response.content})

        # ③ 检查是否结束
        if response.stop_reason != "tool_use":
            trigger_hooks("Stop", messages)
            return

        # ④ 执行所有工具调用
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            print(f"> {block.name}")

            # ⑤ execute_tool 内部会触发 permission_hook
            output = execute_tool(block, handlers)  # 使用当前 handlers
            print(output[:300])
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })

        messages.append({"role": "user", "content": results})

完整调用链示意

① assemble_tool_pool()
   ├── 合并 BUILTIN_TOOLS + 所有 MCP server 的工具
   ├── 生成 mcp_tool_policies(全局变量)
   └── 返回 (tools, handlers)
   ↓
② LLM 返回 tool_use block(可能调用 MCP 工具)
   ↓
③ execute_tool(block, handlers) 被调用
   ↓
④ trigger_hooks("PreToolUse", block) 触发所有 PreToolUse 钩子
   ↓
⑤ permission_hook(block) 被调用
   │
   ├── block.name 以 "mcp__" 开头?
   │   ├── 否 → 走内置工具权限检查
   │   └── 是 → 从 mcp_tool_policies 查找策略
   │       ├── "allow" → 直接放行 ✓
   │       ├── "confirm" → 询问用户
   │       │   ├── 用户同意 → 放行 ✓
   │       │   └── 用户拒绝 → 返回 "Permission denied by user" ✗
   │       └── 其他 → 询问用户(默认行为)
   ↓
⑥ 返回结果给 execute_tool
   │
   ├── 被阻止 → 返回错误字符串(Agent 不崩溃)
   └── 放行 → 执行 handler(调用真实 MCP Server)
       ├── 成功 → 返回结果字符串
       └── 失败 → 捕获异常,返回错误字符串(Agent 不崩溃)
   ↓
⑦ tool_result 写入 messages → 下一轮 LLM 看到