MCP 全景
第 10 章的 Skill 证明了一件事:一份写得清楚的说明书,就能让模型获得一项新技能。但文档型能力有自己的边界——它教模型「怎么把一件事做好」,模型的手还是原来那双。想让任意 Agent 都能直接查天气、读数据库、操…
第 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 章的裸工具,注意三个升级:
- docstring 写得更满——Args 段落会被提取进工具描述。description 现在不只是"给模型看",是"给全生态所有潜在客户端的模型看"
_safe_path沙箱检查——工具一旦通过 MCP 暴露,调用方就不只是你自己的代码了,防御从"可选"变成"必选"- 错误仍是返回字符串——第 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,但黑盒会让你错过三个关键理解:
- 协议的顺序性:不握手就 list、不 list 就 call,server 直接拒绝——生命周期不是建议是硬约束
- 能力的载体是消息:所谓"发现工具",就是收到了一条含 JSON Schema 数组的响应(第 6 章的知识在协议层的复现)
- 转换层的位置: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)都讲完了。它们是竞争关系吗?各管什么?怎么混合使用?下一章正面对决。