AI Agents in Action · 第三章

把工具搬出
你的进程

第二章的工具是 @function_tool,跟 agent 住在同一个 Python 进程里。 第三章用 MCP 把它们搬到另一个进程,中间用一条标准协议连起来。 搬完之后,同一套工具任何 agent 都能连、Claude 桌面端也能连, 而且换个语言写都行——这就是 MCP 存在的全部理由。

进程 A · 你写的
Agent
Agent / Runner
不知道工具是怎么实现的
进程 B · 谁写的都行
MCP Server
工具 / 资源 / 提示模板
不知道谁在用它

这张图会贯穿全章。每一节开头都会告诉你:现在有几个进程,它们靠什么连。

动手之前

环境和第二章一样(uv venv --python 3.11 → 激活 → uv pip install -r requirements.txt), .env 也照旧放那三行。这一章多两个要求:

# 1. mcp 命令必须在 PATH 上(mcp[cli] 装的,requirements.txt 里有)
#    激活了 .venv 就有;这一步很关键,下面会解释为什么
mcp version

# 2. 得有 Node,因为要用 npx 跑别人家的 MCP server
node -v
npx -v
这一章的代码不读 .env,得自己加

第二章每个文件都有 load_dotenv()chapter_03/ 里一个都没有。 仓库作者默认你把 key export 到了环境变量里。我们要接第三方模型, 所以下面每个 agent 文件我都补上了 load_dotenv() 和那两行配置—— 这是相对仓库原文的改动,不是照抄

另外注意:MCP server 文件不需要这些。它们只提供工具、自己不调大模型, 所以既不用 load_dotenv(),也不用管 chat_completions 和追踪。 代码块右上角标了 AGENT 还是 SERVER,一眼能分。

第一部分

写一个 MCP server

先造出那个「进程 B」。你会发现它短得离谱,而且——它自己跑不起来。

15 行,一个能用的 server

对应清单 3.1

把第二章那个 get_research_sources 原样搬过来,只换一个装饰器: @function_tool 变成 @mcp.tool()。就这一处区别。

from mcp.server.fastmcp import FastMCP

# 建一个 MCP 服务器
mcp = FastMCP("Research Tools")


@mcp.tool()
def get_research_sources() -> list[str]:
    """提供可用的研究来源列表。"""
    search_sources = [
        "Wikipedia",
        "Google",
        "YouTube",
    ]
    return search_sources

FastMCP 干的事和 @function_tool 一样:读函数名、读类型标注、 读 docstring,拼成一份「工具说明书」。区别只在于这份说明书不是直接塞给模型, 而是通过 MCP 协议隔着进程边界报给对面。

这个文件没有 main,它自己跑不起来

注意最后没有 if __name__ == "__main__",也没有 mcp.run()python 01_claude_mcp_server.py 跑它,什么都不会发生—— 它只是定义了一个模块级的 mcp 对象,等着别人来启动。

谁来启动?mcp 这个命令行工具。它会 import 你的文件、找到那个 mcp 对象、然后替你把服务跑起来。这也是为什么前面要你确认 mcp version 能通。 全章只有一个 server 文件是例外06_mcp_time_travel_tracker.py 自带 mcp.run()),到第三部分你会看到它带来的麻烦。

先别急着接 agent,拿 inspector 戳一戳

对应清单 3.2 / 3.3

这是本章我最想让你养成的习惯。MCP server 的问题,隔着 agent 排查是地狱—— agent 只会告诉你「我没找到合适的工具」,不告诉你是 server 没起来、工具没注册、 还是 docstring 写得模型看不懂。所以先单独验它。

cd chapter_03

# 打开 MCP Inspector:一个网页界面,能列出工具、手动调用、看原始 JSON 往返
mcp dev 01_claude_mcp_server.py
# 输出里会有:Starting MCP inspector... Proxy server listening on port 6277

浏览器里点开,你能看到 get_research_sources 躺在工具列表里,点一下就能调, 返回值和原始协议消息都摆在眼前。这一步通了,再去接 agent

书里还教了 mcp install,把 server 装进 Claude 桌面端:

cd chapter_03
mcp install 01_claude_mcp_server.py
# 输出:Added server 'Research Tools' to Claude config

这步跟后面的 agent 例子没有任何关系,纯粹是让你直观感受一下「同一个 server, Claude 能用、你的 agent 也能用」——这正是 MCP 的卖点。不装也不影响往下学。

第二部分

三种连法

server 有了,agent 怎么连上去?下面三节是三个并列的选项,不是三个步骤—— 你按场景挑一个用,不用挨个走一遍。区别全在「进程 B 是谁启动的、在哪」。

STDIO:让 SDK 替你把 server 当子进程拉起来

对应清单 3.4 · 最常用

你运行的
02_mcp_agent_stdio_server.py
跑起来会自己 fork 出右边那个
SDK 自动拉起
mcp run 01_claude_mcp_server.py
子进程,用标准输入输出通信

STDIO 的意思是:两个进程靠标准输入输出说话,像老式管道那样。 你不用手动开 server——SDK 会替你把它当子进程 fork 出来,用完自动收尸。

import asyncio
from pathlib import Path

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerStdio, MCPServerStdioParams
from dotenv import load_dotenv

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
# (仓库原文没有这三行,是为了接第三方模型加的)
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

SCRIPT = Path(__file__).with_name("01_claude_mcp_server.py").resolve()


async def main():
    async with MCPServerStdio(
        name="Research Tools",
        params=MCPServerStdioParams(
            command="mcp",
            args=["run", str(SCRIPT)],
        ),
    ) as research_server:
        agent = Agent(
            name="助手",
            instructions="用这些研究工具来做研究。",
            mcp_servers=[research_server],
        )

        print("运行中:获取可用的研究来源")
        result = await Runner.run(agent, "获取可用的研究来源")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

三个点值得说:

  • mcp_servers=[research_server] 取代了 tools=[...] agent 不再拿到一个个函数,而是拿到「一整个 server」。server 上有几个工具、 叫什么名字,是连上去之后问出来的,不是写死在代码里的。
  • async with 不是装饰。进入时 fork 子进程 + 握手 + 拉工具清单, 退出时关掉子进程。这就是为什么这一章突然全都变成 async 了—— 第二章的 Runner.run_sync() 在这儿用不了。
  • Path(__file__).with_name(...) 而不是相对路径。 所以这个文件从哪个目录跑都行,不像第 6 章那样必须在仓库根目录。
command 是 "mcp",不是 "python"

command="mcp" 意味着 SDK 会去 PATH 里找一个叫 mcp 的可执行文件忘了激活 .venv 的话,这里报 command not found, 而且错误是从 async with 内部抛出来的,堆栈一大坨,第一眼根本看不出是环境问题。

用 uv 的话有个更省事的写法:不激活也行,直接 uv run python chapter_03/02_mcp_agent_stdio_server.py—— uv run 会把 .venv/bin 塞进 PATH,子进程继承得到,mcp 就找得着了。

SSE:server 你自己开,agent 通过 HTTP 连过去

对应清单 3.5 / 3.6

终端 2
03_mcp_agent_sse_server.py
连 http://localhost:8000/sse
终端 1 · 你先手动开
mcp run -t sse 01_claude_mcp_server.py
常驻,监听 8000 端口

和 STDIO 最大的区别:server 不再是 agent 的子进程,而是一个独立常驻的 HTTP 服务。 所以你得先开两个终端。先在第一个终端把 server 跑起来:

cd chapter_03

# -t sse = 用 sse 这种传输方式跑,默认监听 8000 端口
mcp run -t sse 01_claude_mcp_server.py

然后另开一个终端跑 agent:

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerSse
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


async def main():
    async with MCPServerSse(
        name="SSE Python Server",
        params={
            "url": "http://localhost:8000/sse",
        },
    ) as research_server:
        agent = Agent(
            name="助手",
            instructions="用这些研究工具来做研究。",
            mcp_servers=[research_server],
        )

        print("运行中:获取可用的研究来源")
        result = await Runner.run(agent, "获取可用的研究来源")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

注意 agent 那部分和 STDIO 版一字不差,只有 MCPServerStdio 换成了 MCPServerSse、参数从「怎么启动」换成了「去哪儿连」。 这正是 MCP 的意义:传输方式是可换的,上层完全不用动。

那什么时候用 SSE?server 要被多个 agent 共用、或者根本不在你这台机器上的时候。 STDIO 是一对一的私有子进程,SSE 是大家都能连的公共服务。

8000 端口是写死的,而且会打架

"url": "http://localhost:8000/sse" 硬编码在代码里。8000 是个热门端口—— 第 8 章的 FastAPI 例子也用 8000,两边同时跑必冲突。而且多个 SSE 示例之间也不能同时开。 端口占了的话,mcp run -t sse 那边会起不来, 而 agent 这边的报错只会说连不上,看不出是谁占了。

连别人家的 server:一行 npx,白捡一堆工具

对应清单 3.7

你运行的
04_mcp_agent_local_server_files.py
Python
npx 拉起 · 别人写的
@modelcontextprotocol/server-filesystem
Node.js 写的,你一行代码没写

前面铺垫的所有东西,就为了这一下:command"mcp" 换成 "npx",你的 Python agent 就用上了一个 Node.js 写的文件系统工具箱—— 读文件、写文件、列目录,全都白来的。

import asyncio
import os

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerStdio
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


async def main():
    # 指定给 server 的沙箱目录:就是本文件所在的目录
    current_dir = os.path.dirname(os.path.abspath(__file__))

    # 用异步上下文管理器启动 server
    async with MCPServerStdio(
        name="Filesystem Server, via npx",
        params={
            "command": "npx",
            "args": ["-y",
                     "@modelcontextprotocol/server-filesystem",
                     current_dir],
        },
    ) as server:
        # 建一个使用这个 MCP server 的智能体
        agent = Agent(
            name="文件系统智能体",
            instructions="用这些文件系统工具帮用户完成任务。",
            mcp_servers=[server],
        )

        print("运行中:获取可用的文件")
        result = await Runner.run(agent, "列出当前目录下所有文件的名字")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

这才是 MCP 真正的价值主张:工具不用你写。社区已经有一堆现成的 server—— 文件系统、数据库、Git、搜索——你只要知道怎么把它拉起来。 第 4 到 6 章会反复用 npx -y @modelcontextprotocol/server-* 这个套路, 从这儿开始它就是家常便饭了。

最后那个参数是沙箱,不是摆设

current_dir 传给 server 当作它唯一能碰的目录。 filesystem server 会拒绝访问这个目录之外的任何路径——这是它自己的安全设计,不是 MCP 的。 这里给的是 chapter_03/ 本身,也就是说这个 agent 有权在你的代码目录里写文件。第 4 章里那几个凭空冒出来的 research_plan.txt,就是这么来的。

第一次跑会卡很久,而且离线必挂

npx -y-y 是「别问我,直接装」。第一次运行会从 npm 下载整个包,慢得像卡住了一样,其实在下载。没网的话直接失败。 另外这需要你装了 Node——书里专门有个附录 B 讲这个,可见坑过不少人。

第三部分

把你自己的工具搬进 MCP

全章的高潮。同一个「时光旅行日记」智能体写两遍: 先让工具住在 agent 进程里,再把它们搬到另一个进程去。 对比这两版,你就彻底明白 MCP 到底改变了什么

搬家前:工具住在 agent 进程里

对应清单 3.8 · 这一版没有 MCP

只有一个进程
05_time_travel_agent.py
agent + 工具 + _journal 全在这儿
没有第二个进程
这一版就是第二章的老套路

两个工具:记一笔、把日记读出来。日记就是个内存里的 _journal 列表。 纯第二章的写法,@function_tool 而已。

import asyncio

from agents import (Agent, Runner, function_tool,
                    set_default_openai_api, set_tracing_disabled)
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# 内存里的日记状态(一个列表)
_journal = []


@function_tool
def record_event(entry: str) -> dict:
    """把一条新的旅行事件记进日记。"""
    _journal.append(entry)
    print(f"已记录事件:{entry}")
    return {"status": "recorded", "entry": entry}


@function_tool
def load_journal() -> dict:
    """读出当前日记里的所有条目。"""
    print("正在读取日记条目……")
    return {"status": "loaded", "journal": "\n".join(_journal)}


agent = Agent(
    name="时光记录员",
    instructions="""你是一个时光旅行日记智能体。
每次开始时都要先用 'load_journal' 工具把过去的条目取出来。
遇到新事件,调用 'record_event' 把它存下来。
如果用户要看总结或日记,就把所有记录过的事件输出来。""",
    tools=[record_event, load_journal],
)

# 模拟一串历史旅行事件
travel_events = [
    "去古罗马看了一场角斗士比赛",
    "见证了 1776 年《独立宣言》的签署",
    "目睹了 1969 年的登月",
]


async def main():
    print("正在记录旅行:")
    for event in travel_events:
        await Runner.run(agent, event)
    # 让智能体总结一下这些冒险
    result = await Runner.run(agent, "把我的旅行历史给我看看")
    print("\n最终日记:")
    print(result.final_output)


asyncio.run(main())
instructions 里的工具名不能翻

提示词里那两个 'load_journal''record_event' 保持英文, 因为它们必须和函数名一字不差——模型是照着这个名字去调工具的。 翻成中文,模型就会去找一个根本不存在的工具。

搬家:同样的工具,换个装饰器就出了进程

对应清单 3.9

把上面两个工具原样搬进一个 MCP server。函数体一个字没改, 只是 @function_tool@mcp.tool()

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Time Travel Tracker")

# 内存里的日记状态(一个列表)
_journal = []


@mcp.tool()
def record_event(entry: str) -> dict:
    """把一条新的旅行事件记进日记。"""
    _journal.append(entry)
    print(f"已记录事件:{entry}")
    return {"status": "recorded", "entry": entry}


@mcp.tool()
def load_journal() -> dict:
    """读出当前日记里的所有条目。"""
    print("正在读取日记条目……")
    return {"status": "loaded", "journal": "\n".join(_journal)}


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

但是有件大事悄悄变了:_journal 现在住在别的进程里。 前一版里它是你 Python 脚本的一个局部变量;这一版它属于 server 进程。 这带来一个第二章不可能遇到的现象——状态是共享的。 两个 agent 同时连上同一个 SSE server,会往同一本日记里写。 书里也提到了这点:你可能会看到重复的日志,因为 server 进程不是每次都重建的。

工具里那两个 print,是个定时炸弹

print() 写的是标准输出,而 STDIO 传输的 JSON-RPC 走的也是标准输出—— 同一个 fd。我抓过原始字节,确认了 print 的内容真的会混进协议流

已记录事件:去古罗马  ← 不是 JSON
{"jsonrpc":"2.0","id":2,"result":…}

那为什么跑起来没事?因为现在的 Python MCP 客户端够宽容,遇到解析不了的行就跳过。 这是在赌运气,不是设计如此。我实测过:让工具狂 print 几百行, 管道被写满,整个调用直接死锁挂起,再也不返回。换个别的客户端实现 (Claude 桌面端、TypeScript SDK)也可能直接断连。

正确做法:MCP server 里要打日志,就往 stderr 打print(..., file=sys.stderr)),别占 stdout。 这一版是 SSE 跑的,stdout 空着,所以确实没事—— 可下一节那个 stdio 客户端,跑的正是这同一个文件。

搬家后:agent 这边几乎没变

对应清单 3.10 / 3.11

进程 A
06_time_travel_agent_mcp_stdio.py
只剩提示词,没有工具代码了
进程 B
06_mcp_time_travel_tracker.py
工具 + _journal 都在这边

对比一下上一版:工具函数没了,_journal 没了, 提示词一字未动,tools= 变成了 mcp_servers=。 agent 完全不知道日记存在哪、谁在管——它只知道对面有两个工具可以调。

import asyncio
from pathlib import Path

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerStdio, MCPServerStdioParams
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

SCRIPT = Path(__file__).with_name("06_mcp_time_travel_tracker.py").resolve()

# 模拟一串历史旅行事件
travel_events = [
    "去古罗马看了一场角斗士比赛",
    "见证了 1776 年《独立宣言》的签署",
    "目睹了 1969 年的登月",
]


async def main():
    async with MCPServerStdio(
        name="Time Tracker Server",
        params=MCPServerStdioParams(
            command="mcp",
            args=["run", str(SCRIPT)],
        ),
    ) as time_tracker_server:
        agent = Agent(
            name="助手",
            instructions="""
你是一个时光旅行日记智能体。
每次开始时都要先用 'load_journal' 工具把过去的条目取出来。
遇到新事件,调用 'record_event' 把它存下来。
如果用户要看总结或日记,就把所有记录过的事件输出来。
            """,
            mcp_servers=[time_tracker_server],
        )
        print("正在记录旅行:")
        for event in travel_events:
            await Runner.run(agent, event)
        # 让智能体总结一下这些冒险
        result = await Runner.run(agent, "把我的旅行历史给我看看")
        print("\n最终日记:")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

SSE 版就是把 MCPServerStdio 换成 MCPServerSse、 改成填 URL。但这一版你得先手动开 server——而书在这儿给错了命令, 详见下面「踩坑」第一条。

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerSse
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# 模拟一串历史旅行事件
travel_events = [
    "去古罗马看了一场角斗士比赛",
    "见证了 1776 年《独立宣言》的签署",
    "目睹了 1969 年的登月",
]


async def main():
    async with MCPServerSse(
        name="Time Tracker Server",
        params={
            "url": "http://localhost:8000/sse",
        },
    ) as time_tracker_server:
        agent = Agent(
            name="助手",
            instructions="""
你是一个时光旅行日记智能体。
每次开始时都要先用 'load_journal' 工具把过去的条目取出来。
遇到新事件,调用 'record_event' 把它存下来。
如果用户要看总结或日记,就把所有记录过的事件输出来。
            """,
            mcp_servers=[time_tracker_server],
        )
        print("正在记录旅行:")
        for event in travel_events:
            await Runner.run(agent, event)
        # 让智能体总结一下这些冒险
        result = await Runner.run(agent, "把我的旅行历史给我看看")
        print("\n最终日记:")
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())
这个文件我删掉了仓库里的一行

仓库原文第 8 行有这么一句: SCRIPT = Path(__file__).with_name("06_mcp_time_travel_tracker").resolve()。 它是死代码——SSE 版靠 URL 连接,根本用不到 SCRIPT。而且它还写错了, 少了 .py 后缀。大概是从 stdio 版复制过来忘了删。上面这版我去掉了。

这一节真正想让你看见的

0506_..._stdio 并排放,agent 那部分的差别只有两行tools=[...]mcp_servers=[...],以及外面套了个 async with。 提示词、事件列表、调用方式,全都没动。

代价是多了一个进程、多了一层协议、调试难度上了一个台阶。 换来的是:这套工具现在能被任何 agent 复用、能被 Claude 桌面端直接用、 能换成任何语言重写而调用方毫无感知。这笔账划不划算,取决于这些工具会不会被第二个人用。 只有你自己用,@function_tool 挺好。

跟着书走之前,先看这几条

对着 PDF 和仓库逐条核对出来的出入。第一条尤其能坑掉你半小时。