首页/Agent 核心/MCP 全景
Agent 核心 · 第 11 章

MCP 全景

第 10 章的 Skill 证明了一件事:一份写得清楚的说明书,就能让模型获得一项新技能。但文档型能力有自己的边界——它教模型「怎么把一件事做好」,模型的手还是原来那双。想让任意 Agent 都能直接查天气、读数据库、操…

约 49 分钟19,560 字28 个代码示例

第 10 章的 Skill 证明了一件事:一份写得清楚的说明书,就能让模型获得一项新技能。但文档型能力有自己的边界——它教模型「怎么把一件事做好」,模型的手还是原来那双。想让任意 Agent 都能直接查天气、读数据库、操作 GitHub,靠一家家对接是不现实的:每家服务方一套私有接口,每个客户端一套适配代码,全行业都在重复劳动。MCP(Model Context Protocol)解决的就是「工具的标准化互通」——让工具像 USB-C 一样,任何 Server 接任何 Client。这一章是一条四段的长路:先解剖协议本身(M×N 问题、三个角色、报文格式、三大原语),然后换到提供方视角写一个自己的 Server,再换到消费方视角手写一个 Client,最后总装——让 Agent 不改一行代码就动态获得整个生态的工具。

第一部分:协议本身

1.1 M×N 问题

MCP 诞生(2024 年 11 月,Anthropic 发起)前的世界是这样的:

8 个模型客户端(Claude、Trae、Cursor、自研 Agent……)
× 10 个服务提供方(GitHub、数据库、天气、Slack……)
= 80 份适配代码

每家客户端一套私有工具格式,每个服务方一份接入文档。谁都没错,但全行业加起来在做大量重复劳动。

USB-C 的类比很好用:以前每个手机品牌一个充电口,现在一个标准接口,任何线插任何设备。MCP 就是"Agent 界的 USB-C":

M 个客户端 × N 个服务 → M + N 份实现(各自面向协议编程)

这个模式的先例是 LSP(Language Server Protocol,编辑器语言服务协议):VS Code 定标准后,所有编辑器和所有语言共享生态。MCP 走的是同一条路,所以常被称作"AI 应用的 LSP 时刻"。

1.2 协议内核:三个角色

MCP 世界只有两种程序(第 7 章的 Agent 在这里正式获得协议身份):

角色 是谁 职责
Host 宿主应用 用户直接面对的产品(Trae、Claude Desktop、你的程序)
Client 宿主内的连接器 与单个 Server 维持一对一连接,负责协议翻译
Server 工具提供方 暴露能力:查天气、读数据库、操作 GitHub

Host 里可以同时跑多个 Client(一个对应一个 Server)。比如 Trae 同时连着 GitHub server 和数据库 server。

1.3 两种传输

传输方式 机制 适用
stdio 子进程的 stdin/stdout 本地运行:同机启动,随宿主启停,零网络开销
Streamable HTTP HTTP(可升级 SSE) 远程服务:跨机器、可鉴权、可多路复用

本地场景默认 stdio:宿主把 server 当子进程拉起(uvx mcp-server-time 这类命令),用管道通信——部署最简单,没有端口管理问题。

1.4 报文格式:JSON-RPC 2.0

所有交互都是 JSON-RPC 2.0 报文(所以第 7 章说 tool calling 的 arguments 是 JSON——整个生态的底座都是可序列化结构):

// 请求(客户端 → 服务端)
{
  "jsonrpc": "2.0",
  "id": "req-42",
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "北京" }
  }
}

// 响应(服务端 → 客户端)
{
  "jsonrpc": "2.0",
  "id": "req-42",
  "result": {
    "content": [
      { "type": "text", "text": "北京 12°C 晴" }
    ]
  }
}

三种报文类型:

  • Request:带 id,等回复(如 tools/call)
  • Notification:不带 id,不等回复(如 notifications/initialized)
  • Response:对 Request 的答复,result 或 error 二选一

1.5 会话生命周期

一次完整的连接:

Client                          Server
  │ ── initialize ──────────────→ │   ① 握手:交换版本、能力清单
  │ ←─ initialize result ────── │
  │ ── initialized ────────────→ │   ② 确认(notification)
  │                              │
  │ ── tools/list ─────────────→ │   ③ 发现工具
  │ ←─ 工具清单 + schema ────── │
  │                              │
  │ ── tools/call ────────────→ │   ④ 调用(可重复任意次)
  │ ←─ 执行结果 ──────────────── │
  │                              │
  │ ── (循环:list/call) ──────→ │

①② 是所有连接的固定开场——先协商协议版本、互报能力(支持哪些可选特性),避免版本不匹配。这是所有 RPC 协议的标准动作。

1.6 三大原语

MCP 不只暴露工具,它有三种能力类型:

原语 语义 谁控制 典型用途
Tools 可执行的动作 模型决定调用 查天气、写数据库
Resources 可读取的数据 应用程序决定读取 文件内容、数据库 schema
Prompts 预置的提示词模板 用户主动触发 "/review 代码评审" 斜杠命令

区分的关键是控制权(对齐第 7 章"模型只表达意向"的原则):

  • Tool:模型在推理时自主决定要不要调
  • Resource:宿主应用在构建上下文时决定要不要塞(第 13 章 Context Engineering 的外挂数据源)
  • Prompt:用户显式调用(通常是 UI 上的斜杠命令)

这三种控制权分别对应 LLM 应用的三个输入来源——模型、应用、用户。一个成熟的协议设计必然把三者分开。

进阶特性了解即可:sampling(server 反向请求客户端的模型补全,形成双向能力协商)、roots(客户端告知可访问的文件系统范围)、elicitation(server 反向请求用户输入)。

1.7 生态现状

server 靠聚合站检索(找工具就像找 npm 包):

  • 官方参考实现:modelcontextprotocol/servers——filesystem、git、time 等教科书级示例
  • 聚合站:mcp.so、smithery.ai、glama.ai、awesome-mcp-servers

客户端支持情况:Trae、Cursor、Claude Desktop、Zed 等均已原生支持;协议规范与 SDK(Python/TypeScript 等)在 modelcontextprotocol.io 开放维护。

本地快速体验一个 server(配合官方调试器):

# 时间服务(官方示例,uvx 直接拉起)
uvx mcp-server-time

# MCP Inspector:官方调试客户端,图形化查看通信全过程
npx -y @modelcontextprotocol/inspector

Inspector 的价值值得强调:它把 initialize、tools/list、tools/call 的每一步报文都摊开给你看——学协议最有效的方式就是亲眼看着报文往返(第 8 章"文本协议摊开看"的同一哲学)。

1.8 一个澄清:MCP 不是什么

三个常见误解,提前打掉:

① MCP 不是工具调用协议的替代品。它不替代第 7 章的 tool calling——模型那侧的协议完全不变。MCP 解决的是"工具从哪来":客户端从 server 动态拉取工具清单,转换成第 7 章的 tools 参数喂给模型。模型对此毫无感知。

② MCP 不是 Agent 框架。它不提供循环、记忆、规划(第 8 章的那些)——它只管"工具的分发与调用"这一层。你的 Agent 该怎么写还怎么写,MCP 只是让 Registry 里多了来自外部的工具。

③ MCP 不解决安全问题。协议提供了能力协商的结构(比如工具可声明只读/破坏性),但"允许这个 server 干什么"的权限决策留给宿主。第 15 章会看到这带来的攻击面。

协议图纸看完了。接下来动手——但先选一条更划算的路线:先当提供方,写一个自己的 Server。理由很实际:官方 SDK 把 Server 侧的脏活全包了,几十行代码就能把能力暴露给全生态,是体验"M+N 效益"最快的路径。写完 Server 再手写 Client,两边对上,协议就真正长在身上了。

第二部分:先当提供方——写一个自己的 Server

把手里的能力(第 7 章的工具、第 10 章的 Skill)封装成标准 MCP Server,一次实现,全生态可用——Trae、Claude Desktop、以及你自己的 Agent 都能直接连。最后再补齐配置文件这一环,打通"接入"的全部细节。

2.1 最小 Server:三个方法

MCP Server 的最小实现要响应三类请求(本章第三部分实现 Client 时,我们会从另一侧亲手发出这些报文):

方法 干什么
initialize 握手:报版本和能力
tools/list 返回工具清单(名称+描述+schema)
tools/call 执行指定工具,返回结果

对照第 7 章的 Tool/Registry:Server 本质就是把 Registry 从"进程内调用"改造成"跨进程服务"。逻辑一行不变,交互从函数调用变成 JSON-RPC。

用官方 Python SDK(FastMCP 风格)实现,先做一个最简单的回声服务感受全貌:

# uv add "mcp[cli]"
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo-server")          # 参数是 server 名称

@mcp.tool()
def add(a: int, b: int) -> int:
    """计算两个整数之和"""
    return a + b

if __name__ == "__main__":
    mcp.run()                          # 默认 stdio 传输

就这么多。@mcp.tool() 装饰器做了第 6、7 章我们手写的全部事情:

  • 从类型注解生成 inputSchema(Pydantic 同源)
  • 从 docstring 生成 description(第 7 章的"说明书"原则)
  • 注册到工具表,响应 tools/list 和 tools/call

可以用本章第三部分将要手写的 Client 验证(或直接用 Inspector):

npx -y @modelcontextprotocol/inspector uv run server.py
# Inspector 界面里:Connect → Tools → List Tools → 就能看到 add 了

2.2 做一个有用的:文件助手 Server

把第 7 章的 read_file/write_file 工具产品化——做成一个可复用的 MCP Server(这就是"把能力变成服务"的完整练习):

# file_server.py
from pathlib import Path
from mcp.server.fastmcp import FastMCP

# 允许访问的根目录:把权限写死在 server 侧(第 15 章的最小权限原则预演)
ALLOWED_ROOT = Path("/Users/you/sandbox").resolve()

mcp = FastMCP("file-assistant")

def _safe_path(path: str) -> Path:
    """路径安全检查:禁止逃逸出允许的目录"""
    p = (ALLOWED_ROOT / path).resolve()
    if not str(p).startswith(str(ALLOWED_ROOT)):
        raise ValueError(f"禁止访问沙箱外的路径:{path}")
    return p

@mcp.tool()
def read_file(path: str, max_chars: int = 4000) -> str:
    """读取沙箱内文本文件的内容。文件过大时自动截断。

    Args:
        path: 相对沙箱根目录的文件路径
        max_chars: 最多返回的字符数
    """
    p = _safe_path(path)
    if not p.is_file():
        return f"文件不存在:{path}"
    content = p.read_text(encoding="utf-8")
    if len(content) > max_chars:
        return content[:max_chars] + f"\n...(已截断,共 {len(content)} 字符)"
    return content

@mcp.tool()
def write_file(path: str, content: str) -> str:
    """将文本内容写入沙箱内的文件(覆盖写入)。

    Args:
        path: 相对沙箱根目录的目标路径
        content: 要写入的完整内容
    """
    p = _safe_path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content, encoding="utf-8")
    return f"已写入 {path}({len(content)} 字符)"

@mcp.tool()
def list_dir(path: str = ".") -> str:
    """列出沙箱内指定目录的文件和子目录。"""
    p = _safe_path(path)
    if not p.is_dir():
        return f"目录不存在:{path}"
    entries = sorted(p.iterdir(), key=lambda x: x.name)
    lines = [f"{'[dir] ' if e.is_dir() else '      '}{e.name}" for e in entries]
    return "\n".join(lines) or "(空目录)"

if __name__ == "__main__":
    mcp.run()

对比第 7 章的裸工具,注意三个升级:

  1. docstring 写得更满——Args 段落会被提取进工具描述。description 现在不只是"给模型看",是"给全生态所有潜在客户端的模型看"
  2. _safe_path 沙箱检查——工具一旦通过 MCP 暴露,调用方就不只是你自己的代码了,防御从"可选"变成"必选"
  3. 错误仍是返回字符串——第 7 章"run 永不抛异常"的原则跨进程依然成立:给模型的错误必须可读、可行动

2.3 Resources 和 Prompts 也能暴露

FastMCP 支持三原语(本章第一部分的控制权分类):

@mcp.resource("config://app")
def get_config() -> str:
    """应用配置(应用程序控制何时读取)"""
    return "当前环境: 生产\n版本: 2.3.1"

@mcp.prompt()
def review_code(code: str) -> str:
    """代码评审提示词模板(用户通过斜杠命令触发)"""
    return f"请评审以下代码,关注安全性和可读性:\n\n{code}"

选择原则回顾本章第一部分的表格:模型自主决定的做成 tool,应用决定的做成 resource,用户触发的做成 prompt。一个能力该走哪条路,取决于"谁在什么时候需要它"。

2.4 配置文件:接入的最后一公里

Server 写好后,客户端怎么连?各家宿主的配置格式略有差异但结构同源。Trae 的 mcp.json:

{
  "mcpServers": {
    "file-assistant": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/project", "file_server.py"]
    },
    "time": {
      "command": "uvx",
      "args": ["mcp-server-time"]
    }
  }
}

三个工程要点:

  • command 必须绝对可执行:写 uv/uvx 的绝对路径最稳(/Users/you/.local/bin/uv),否则宿主启动子进程时可能 PATH 不一致
  • stdio server 由宿主管理生命周期:配置里的 command 就是本章第三部分 StdioTransport 将要拉起的子进程命令——宿主启动时 spawn、退出时 kill
  • 远程 server 用 url 字段:{"mcpServers": {"name": {"url": "https://..."}}},走 Streamable HTTP

配置生效后,宿主的 Agent 就拥有了你的工具——没有改一行 Agent 代码。这就是第一部分 M+N 效益的亲身体验。

2.5 分发形态

Server 写完怎么给别人用?三种交付形态:

形态 做法 适合
源码仓库 clone 后配 command 内部团队
PyPI 包 uvx your-package 一键拉起 公开生态(官方 servers 都这样)
Docker 镜像 docker run 作为 command 带重依赖(数据库驱动等)

PyPI 形态的工程要求:pyproject.toml 里声明入口([project.scripts]),保证 uvx 能找到启动函数。命名建议 mcp-server-xxx(生态惯例,聚合站靠前缀识别)。

提供方这条路走通了。现在换到桌子的另一侧:当消费方,实现一个 Client。这次反着来——不用官方 SDK,从子进程管道开始手搓。你会发现所谓协议,不过是按顺序发对几条 JSON;而且手写完再看第二部分的 Server,两侧的报文刚好对上。

第三部分:再当消费方——实现一个能发现和调用工具的 Client

不用官方 SDK,直接基于子进程管道手写一个 MCP Client——initialize 握手、工具发现、工具调用逐级实现。你会获得两样东西:对协议的肌肉记忆,以及一个能塞进第 8 章 Agent 的"外挂工具源"。

3.1 为什么不用 SDK 先手写

官方 SDK(mcp Python 包)几行代码就能连上 server,但黑盒会让你错过三个关键理解:

  1. 协议的顺序性:不握手就 list、不 list 就 call,server 直接拒绝——生命周期不是建议是硬约束
  2. 能力的载体是消息:所谓"发现工具",就是收到了一条含 JSON Schema 数组的响应(第 6 章的知识在协议层的复现)
  3. 转换层的位置:MCP 工具 → OpenAI tools 格式,中间就是一段普通的字典变换(第一部分"模型对 MCP 无感知"的证明)

所以这一节是第 8 章"手搓文本协议"传统的延续:先用裸消息看清骨架,再用 SDK 提效。

3.2 底座:进程管理

stdio 传输的 MCP Client 本质是:拉起一个子进程,往 stdin 写 JSON,从 stdout 读 JSON。先解决进程管理:

import asyncio
import json

class StdioTransport:
    """MCP stdio 传输层:管理子进程和 JSON-RPC 报文读写"""

    def __init__(self, command: list[str]):
        self.command = command
        self.process: asyncio.subprocess.Process | None = None
        self._next_id = 0

    async def start(self):
        self.process = await asyncio.create_subprocess_exec(
            *self.command,
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.DEVNULL,   # server 的日志不混入协议流
        )

    async def send(self, method: str, params: dict | None = None) -> dict:
        """发一条 Request 并等待对应 id 的 Response"""
        self._next_id += 1
        req_id = self._next_id
        message = {"jsonrpc": "2.0", "id": req_id, "method": method,
                   "params": params or {}}
        # 按行分帧:官方 SDK 默认用 Content-Length 头分帧(同 LSP);
        # 自实现按行分帧更直观,但要求 server 也支持——官方 Python SDK
        # 的 stdio server 同时兼容两种,所以能跑通。若连接其他实现,
        # 以对方文档为准(用官方 SDK 客户端则完全无需关心)
        line = json.dumps(message, ensure_ascii=False) + "\n"
        self.process.stdin.write(line.encode())
        await self.process.stdin.drain()
        return await self._read_response(req_id)

    async def notify(self, method: str, params: dict | None = None) -> None:
        """发一条 Notification(不等待回复)"""
        message = {"jsonrpc": "2.0", "method": method, "params": params or {}}
        line = json.dumps(message, ensure_ascii=False) + "\n"
        self.process.stdin.write(line.encode())
        await self.process.stdin.drain()

    async def _read_response(self, req_id: int) -> dict:
        while True:
            raw = await self.process.stdout.readline()
            if not raw:
                raise ConnectionError("server 进程已退出")
            data = json.loads(raw)
            if data.get("id") == req_id:        # 跳过通知等无关消息
                if "error" in data:
                    raise RuntimeError(f"MCP 错误:{data['error']}")
                return data["result"]

    async def close(self):
        if self.process:
            self.process.terminate()
            await self.process.wait()

三个实现细节:

  • stderr 丢弃:server 进程的日志走 stderr,和协议流(stdout)分离——混在一起 JSON 解析必炸
  • id 匹配:响应可能乱序到达,按 id 配对而不是"读到就算"
  • 按行分帧:官方 SDK 用 Content-Length 头分帧(和 LSP 一致);自实现用换行分帧,要求 server 配合(Python SDK 默认支持按行)。真实项目用 SDK 就不用操心这个

3.3 协议层:Client

在传输层上实现本章第一部分的生命周期:

class McpClient:
    def __init__(self, command: list[str]):
        self.transport = StdioTransport(command)

    async def connect(self):
        """① initialize 握手 ② initialized 确认"""
        await self.transport.start()
        result = await self.transport.send("initialize", {
            "protocolVersion": "2025-06-18",
            "capabilities": {},                  # 客户端能力:不声明任何高级特性
            "clientInfo": {"name": "handmade-client", "version": "0.1.0"},
        })
        # result 里有 server 的能力声明,可按需保存
        await self.transport.notify("notifications/initialized")

    async def list_tools(self) -> list[dict]:
        """③ 发现工具"""
        result = await self.transport.send("tools/list", {})
        return result.get("tools", [])

    async def call_tool(self, name: str, arguments: dict) -> str:
        """④ 调用工具"""
        result = await self.transport.send("tools/call", {
            "name": name, "arguments": arguments,
        })
        # 结果是 content 数组,取全部 text 拼接
        parts = [c.get("text", "") for c in result.get("content", [])
                 if c.get("type") == "text"]
        return "\n".join(parts) or str(result)

    async def close(self):
        await self.transport.close()

连官方 time server 实测:

async def main():
    client = McpClient(["uvx", "mcp-server-time"])

    await client.connect()
    tools = await client.list_tools()
    for t in tools:
        print(f"- {t['name']}: {t['description']}")

    result = await client.call_tool("get_current_time", {"timezone": "Asia/Shanghai"})
    print(result)

    await client.close()

asyncio.run(main())

输出:

- get_current_time: Get current time in a specific timezone
- convert_time: Convert time between timezones
当前时间: 2026-08-26T15:30:00+08:00 (Asia/Shanghai)

四条报文,一个完整的协议会话。你现在可以对着本章第一部分的生命周期图逐条打勾了。

3.4 关键一步:MCP 工具转 OpenAI 格式

list_tools 返回的工具描述长这样:

{
    "name": "get_current_time",
    "description": "Get current time in a specific timezone",
    "inputSchema": {"type": "object",
                    "properties": {"timezone": {"type": "string"}},
                    "required": ["timezone"]},
}

而第 7 章喂给模型的 tools 参数长这样:

{
    "type": "function",
    "function": {
        "name": "get_current_time",
        "description": "Get current time in a specific timezone",
        "parameters": {"type": "object",
                       "properties": {"timezone": {"type": "string"}},
                       "required": ["timezone"]},
    },
}

转换函数一层窗户纸:

def mcp_to_openai(mcp_tool: dict, server_name: str) -> dict:
    """MCP 工具 → OpenAI tools 格式;加 server 前缀防重名"""
    return {
        "type": "function",
        "function": {
            "name": f"{server_name}__{mcp_tool['name']}",   # 前缀:多个 server 同名工具不冲突
            "description": mcp_tool.get("description", ""),
            "parameters": mcp_tool.get("inputSchema", {}),
        },
    }

这个转换层就是第一部分"模型对 MCP 无感知"的最终证明——模型看到的只是普通的 tools 列表,调用了也只是一个普通函数调用,由你的代码把调用转发到 MCP 管道。

3.5 塞进 Agent:外部工具源

把 McpClient 接到第 7 章的 Registry(对 Agent 完全透明):

class McpRegistry:
    """聚合多个 MCP server 的工具注册器(实现 Registry 同构接口)"""

    def __init__(self):
        self.servers: dict[str, McpClient] = {}
        self.tools: dict[str, tuple[str, str]] = {}   # 工具全名 → (server名, 原名)

    async def add_server(self, name: str, command: list[str]):
        client = McpClient(command)
        await client.connect()
        self.servers[name] = client
        for t in await client.list_tools():
            full_name = f"{name}__{t['name']}"
            self.tools[full_name] = (name, t["name"])

    def schemas(self) -> list[dict]:
        # 把所有 server 的工具转成 OpenAI 格式(此处省略 inputSchema 缓存细节)
        return [mcp_to_openai_cached(t) for t in ...]

    def dispatch(self, full_name: str, arguments_json: str) -> str:
        server_name, original = self.tools[full_name]
        client = self.servers[server_name]
        return asyncio.run(client.call_tool(
            original, json.loads(arguments_json)))

使用(第 8 章的 Agent 原样复用):

registry = McpRegistry()
asyncio.run(registry.add_server("time", ["uvx", "mcp-server-time"]))
# 现在模型可以用 time__get_current_time 等工具了
agent = Agent(system="你可以查询各时区的时间。", registry=registry)
print(agent.run("现在上海几点?"))

模型不知道也不需要知道 MCP 的存在——它以为自己只是在调用一个名字带下划线的普通工具。架构分层就此完成:

模型 ←(OpenAI tools 格式)→ Agent 循环 ←(JSON-RPC)→ MCP servers
        ↑ 第 7 章的协议            ↑ MCP 协议(本章第一部分)

3.6 用官方 SDK 重写(生产做法)

理解原理后,生产直接用 SDK(价值对比手写版:健壮的分帧、错误重试、HTTP 传输、生态兼容):

# uv add mcp
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def with_sdk():
    params = StdioServerParameters(command="uvx", args=["mcp-server-time"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            result = await session.call_tool(
                "get_current_time", {"timezone": "Asia/Shanghai"})
            print(result.content[0].text)

asyncio.run(with_sdk())

同样的四步,SDK 把传输细节、版本协商、分帧全接管了。先手写后 SDK 的路线在此闭环——你现在知道每个抽象背后是什么了。

现在两个方向都通了:能写 Server 把自己的能力贡献出去(第二部分),能写 Client 接别人的工具(本部分)。最后一步是总装——回到我们的主场,让 Agent 同时用上本地工具和远程 MCP 工具,在一条流水线上并行。

第四部分:整合——让 Agent 动态获得生态工具

各零件齐了:第 8 章的 Agent 引擎、本章第二部分的 Server、第三部分的 Client。这一部分做总装——让 Agent 同时使用本地工具(进程内)和 MCP 工具(跨进程),用一份 mcp.json 管理配置。完成后你会看清一个重要观点:MCP 只是 Registry 的一个工具来源,Agent 的架构不需要为它改变。

4.1 目标形态

总装后的结构:

              ┌────────────────────────────┐
              │      Agent 主循环(第8章)     │
              │   tools = local + remote   │
              └──────────┬─────────────────┘
                         │ dispatch(名字, 参数)
              ┌──────────▼─────────────────┐
              │      混合 Registry          │
              │  ┌─────────┐ ┌──────────┐ │      ┌──────────────┐
              │  │本地工具表│ │MCP 工具表 │ │ stdio│ mcp-server-A │
              │  └─────────┘ └────┬─────┘ │─────→└──────────────┘
              └───────────────────┼───────┘ stdio┌──────────────┐
                                  │─────────────→│ mcp-server-B │
                                                 └──────────────┘

关键设计决策:Agent 循环一行不改。MCP 的接入发生在 Registry 层——对上(模型)提供统一的 schemas 和 dispatch,对下(工具)不管来源。这就是分层的价值:第 8 章写循环时没考虑过 MCP,但接入不需要动它。

4.2 配置加载与连接管理

配置文件沿用生态惯例(第二部分的 mcp.json 格式):

{
  "mcpServers": {
    "time": {
      "command": "uvx",
      "args": ["mcp-server-time"]
    },
    "file-assistant": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/project", "file_server.py"]
    }
  }
}

加载与连接管理:

import asyncio
import json
from pathlib import Path

class McpManager:
    """管理所有 MCP server 的连接生命周期"""

    def __init__(self, config_path: str):
        config = json.loads(Path(config_path).read_text(encoding="utf-8"))
        self.servers_cfg: dict[str, dict] = config.get("mcpServers", {})
        self.sessions: dict[str, "ClientSession"] = {}
        self.tool_index: dict[str, dict] = {}    # 全名 → 工具元数据

    async def connect_all(self):
        from mcp import ClientSession, StdioServerParameters
        from mcp.client.stdio import stdio_client

        for name, cfg in self.servers_cfg.items():
            params = StdioServerParameters(
                command=cfg["command"], args=cfg.get("args", []),
                env=cfg.get("env"),
            )
            # 注意:stdio_client 返回的异步上下文管理器必须保持存活
            # (存住 CM 对象本身,防止流被提前关闭)。这里写法偏底层,
            # 生产建议把整个 Agent 跑在一个事件循环里用 async with。
            cm = stdio_client(params)
            read, write = await cm.__aenter__()
            session_cm = ClientSession(read, write)
            session = await session_cm.__aenter__()
            await session.initialize()

            tools = await session.list_tools()
            for t in tools.tools:
                full_name = f"{name}__{t.name}"
                self.tool_index[full_name] = {
                    "server": name,
                    "original": t.name,
                    "schema": t.inputSchema,
                    "description": t.description or "",
                }
            self.sessions[name] = session
            # 生产代码还应保存 cm / session_cm 以便退出时 __aexit__

    def schemas(self) -> list[dict]:
        """全部 MCP 工具 → OpenAI tools 格式(第三部分的转换思路)"""
        result = []
        for full_name, meta in self.tool_index.items():
            result.append({
                "type": "function",
                "function": {
                    "name": full_name,
                    "description": meta["description"],
                    "parameters": meta["schema"],
                },
            })
        return result

    def dispatch(self, full_name: str, arguments_json: str) -> str:
        """按前缀路由到对应 server 执行"""
        meta = self.tool_index.get(full_name)
        if not meta:
            return f"错误:不存在工具 {full_name}"
        session = self.sessions[meta["server"]]
        # 注意:同一线程内 asyncio.run 不可重入(嵌套调用会报错)。
        # 示例为求简洁;生产做法见 4.5 节的说明。
        result = asyncio.run(session.call_tool(
            meta["original"], json.loads(arguments_json)))
        return "\n".join(
            c.text for c in result.content if hasattr(c, "text"))

4.3 混合 Registry:两个工具源合一

Agent 需要同时看到本地工具(第 7 章的进程内函数)和 MCP 工具。合体的方式朴素得动人——把两个来源的接口对齐:

class HybridRegistry:
    """本地工具 + MCP 工具的统一注册器"""

    def __init__(self, local: Registry, mcp: McpManager | None):
        self.local = local
        self.mcp = mcp

    def schemas(self) -> list[dict]:
        result = self.local.schemas() if self.local else []
        if self.mcp:
            result += self.mcp.schemas()
        return result

    def dispatch(self, name: str, arguments_json: str) -> str:
        # MCP 工具名带 server 前缀(time__get_current_time)
        if self.mcp and name in self.mcp.tool_index:
            return self.mcp.dispatch(name, arguments_json)
        # 本地工具原样分发(第 7 章的 Registry 接口)
        return self.local.dispatch(name, arguments_json)

路由规则一目了然:名字带双下划线前缀的走 MCP,其余走本地。名字前缀(第三部分引入的)在这里从"防重名"升级为"路由键"。

4.4 启动并运行

async def main():
    # 本地工具(第 7 章)
    local = Registry()
    local.register(Tool(calculate, CalculateArgs,
        "精确计算算术表达式。任何数学计算都必须用本工具。"))

    # MCP 工具
    mcp = McpManager("mcp.json")
    await mcp.connect_all()

    # 合体
    registry = HybridRegistry(local=local, mcp=mcp)

    agent = Agent(
        system="你是一个实用助手,能精确计算、查询全球时间。",
        registry=registry,
        max_rounds=6,
    )
    agent.on(logger)
    print(agent.run("帮我算 987 * 654,再告诉我现在纽约几点"))

    # 关闭连接(真实代码应处理优雅退出,这里示意)

asyncio.run(main())

一次运行轨迹(dispatch 的同步桥接做了简化,按前文说明理解):

  ⚙ calculate({"expression": "987*654"})
  ↳ 645498
  ⚙ time__get_current_time({"timezone": "America/New_York"})
  ↳ 当前时间: 2026-08-26T03:30:00-04:00 (America/New_York)
  ✔ 2 轮完成

模型在同一轮里分别调用了进程内函数和另一个进程里的 MCP 工具——它对两者的差异毫无感知,因为工具定义的格式完全一致(第三部分转换层的功劳)。

4.5 接入后的工程问题

真实跑起来会遇到的事,按发生频率排序:

① 生命周期管理。stdio server 是子进程:Agent 启动时要拉起、退出时要收割。asyncio.run 每次调用创建新事件循环的设计在长驻进程里会出问题(本章第三部分和第四部分的示例代码都做了此简化,生产应让 Agent 整体跑在一个事件循环里,或用专用线程管理 MCP 会话)。

② 失败隔离。某个 server 挂了不该拖死整个 Agent:

def dispatch(self, full_name, arguments_json):
    try:
        return self.mcp.dispatch(full_name, arguments_json)
    except Exception as e:
        return f"MCP 工具调用失败({e})。可告知用户该能力暂不可用。"

第 7 章"错误是给模型的信息"原则的又一次应用——模型收到失败信息后,通常会自己决定换路(告诉用户、改用别的工具)。

③ 工具数量失控。三个 server 各带 10 个工具,加上本地工具,30+ 个工具的 schema 又回到了第 7 章的困境(选择困难 + 窗口税)。对策照旧:按 Agent 职责控制接入面;或给 Agent 一个"连接管理"工具让它按需启停 server(进阶玩法,注意安全面)。

④ 同步异步的边界。Agent 循环是同步的(简单),MCP 是异步的(协议特性)。这里用 asyncio.run 做桥接适合示例和脚本;长驻服务建议整体异步化。

4.6 站远一点看

回看这个架构,一个值得内化的观点:

MCP 没有改变 Agent 的形状,它只是丰富了 Registry 的进货渠道。

第 8 章的循环、第 7 章的协议、第 5 章的提示词工程——这些 Agent 的核心构件对 MCP 一无所知。协议解决的是供应链问题(工具从哪来),不是构造问题(Agent 怎么运作)。所以学习顺序也应该如此:先懂 Agent 怎么运转(第 7、8 章),再学工具生态怎么组织(第 10-12 章)。反过来先学 MCP,容易把协议当地基,实际它只是插座。

本章小结

  • 协议层:MCP 解决 M×N 适配问题,客户端和服务方各自面向协议编程(USB-C/LSP 类比);三角色 Host(产品)/Client(连接器)/Server(工具方);两种传输 stdio(本地)/ Streamable HTTP(远程);报文是 JSON-RPC 2.0,生命周期为 initialize 握手 → 工具发现 → 调用循环;三原语按控制权分:Tools(模型控)/Resources(应用控)/Prompts(用户控);三个"不是":不替代 tool calling、不是框架、不管安全
  • Server 侧:FastMCP 三件套——@mcp.tool() 从注解生成 schema、docstring 生成 description、装饰器自动注册;第 7 章的 Tool/Registry 到 MCP Server 是"进程内调用 → 跨进程服务"的形态变化,逻辑不变;暴露即防御——沙箱路径检查从可选变必选,错误仍要"可读可行动";配置文件是最后一公里——绝对路径 command、宿主管理生命周期、远程用 url
  • Client 侧:手写路径 StdioTransport(进程+分帧+id 配对)→ McpClient(握手/发现/调用)→ 转换层(MCP→OpenAI 格式)→ Registry 接入;关键理解——模型对 MCP 无感知,转换层只是字典变换 + 名字加前缀;生命周期是硬约束——不握手不能 list,顺序不可乱;stdio 的工程细节——stderr 分离、id 配对、分帧方式;生产用官方 SDK——同样的流程,健壮性全托管
  • 总装侧:Agent 循环零改动,MCP 接入全部发生在 Registry 层;HybridRegistry 用名字前缀做路由——双下划线走 MCP,其余走本地;McpManager 管生命周期——配置加载、批量连接、索引构建;工程四件事——生命周期管理、失败隔离(错误信息回传)、工具数量控制、同步异步桥接

工具生态的两大机制(Skill、MCP)都讲完了。它们是竞争关系吗?各管什么?怎么混合使用?下一章正面对决。

下一篇:12 · 多 Agent 与选型策略