第 7 期:MCP——工具从别人那里来
到现在为止,客服 agent 的每一个工具都是我们自己写的:get_order、
get_policy、cancel_order、remember_note。上册练习 22 遇到过同一个
转折点:注册表里的工具全是自己代码写的,但工具生态的另一半不长在自己的
代码库里——别人已经把能力写成了现成的服务,不该为了用上它而重写一遍。
MCP(Model Context Protocol)解决的正是这个问题:一个服务器进程声明它的
工具,任何客户端用同一套握手方式就能接上。练习 22 那一章最后一句话是
“注册表本身一个字不用改”。这一期验证同一句话在 LangGraph 里成不成立:
接一个真实的开源 MCP 服务器进来,图和提示词都不碰。
敲进去
第 7 期的代码在 code/ep07/。get_order、get_policy、cancel_order、
remember_note 原样从第 6 期搬过来,一行没改。新增的是一份服务器清单:
# mcp_servers.yaml
servers:
time:
transport: stdio
command: uvx
args: ["mcp-server-time"]
mcp-server-time 是官方维护的开源参考实现,通过 uvx 直接跑,不用先
clone、不用装依赖。这份 yaml 不知道、也不需要知道它内部怎么实现——
MCP 的握手和调用方式是标准化的。读它、连它、把工具转成 LangChain 认识的
BaseTool 的活,交给 langchain-mcp-adapters:
# mcp_client.py
from langchain_mcp_adapters.client import MultiServerMCPClient
async def load_mcp_tools() -> list:
servers = yaml.safe_load(CONFIG.read_text())["servers"]
client = MultiServerMCPClient(servers)
# 一个服务器逐个连——一次性 client.get_tools() 全拿的话,只要有一个
# 服务器连不上,会连累其他连得上的服务器一个工具都用不了。
tools = []
for name in servers:
try:
tools += await client.get_tools(server_name=name)
except Exception as e:
print(f"[mcp] 服务器 {name!r} 连不上,跳过:{type(e).__name__}: {e}")
return tools
graph.py 的节点和边结构跟第 6 期完全一样,唯一的结构性改动是
build_graph 多了一个 tools 参数——MCP 工具是运行时问服务器要来的,
import 时还不知道是什么,llm.bind_tools 只能挪进函数内部:
def build_graph(checkpointer, store, tools: list):
llm = chat_model().bind_tools(tools)
def agent(state, config, runtime):
...
reply = llm.invoke([SystemMessage(system_prompt(note)), *state["messages"]])
...
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
...
main.py 里,build_graph(saver, store, TOOLS + mcp_tools)——手写工具和
MCP 工具在 bind_tools 和 ToolNode 眼里长得一模一样,混在同一个列表里。
prompts.py 一个字没动,系统提示词从没提过“时间“两个字,模型全靠
get_current_time 自己的工具描述发现这个新能力。
main.py 本身倒是改动最大的一处,起因是一路踩出来的连锁反应,见下面
“发生了什么”。
跑起来
cd code
uv run python -m ep07.main wang t1 "现在东京几点,我想问问对方方不方便接电话"
uv run python -m ep07.main wang t1 "帮我查一下 KL-778 的改期政策"
uv run python -m ep07.main --history t1
uv run python -m ep07.main chen c1 "北京时间下午3点,换算成首尔时间是几点"
你应该看到什么
实验一:一个我们没写过的能力
[agent] 要调 get_current_time({'timezone': 'Asia/Tokyo'})
[tools] get_current_time 返回:[{'type': 'text', 'text': '{\n "timezone": "Asia/Tokyo",\n...
[agent] 回答:东京现在是 2026年9月3日(周四)中午 12:54,这个时间对方通常方便接电话,可以放心联系。
get_current_time 这个名字、这个参数、这句回答的措辞,全部来自
mcp-server-time 自己的代码,我们一行没写、甚至没打开看过它的源码。
实验二:手写工具和 MCP 工具混在同一场对话里
[agent] 要调 get_order({'order_id': 'KL-778'})
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'})
[agent] 回答:KL-778(东京迪士尼一日票,出行日 9/7)改期政策:出行日前 3 天
可免费改期一次,改期后不可再改;3 天内不支持改期。
今天是 9/3,距出行还有 4 天,目前还可以免费改期。
--history t1 打出来是五条 AI/Tool 消息交替:先是 MCP 工具,再是两个
手写工具,checkpointer 记录里看不出哪个工具来自哪里——对图和历史来说,
它们是同一种东西。
实验三:另一个能力,同一个服务器
[agent] 要调 convert_time({'source_timezone': 'Asia/Shanghai', 'time': '15:00', 'target_timezone': 'Asia/Seoul'})
[agent] 回答:北京时间下午3点对应首尔时间下午4点。
mcp_servers.yaml 只配了一个服务器,它一次性带来了两个工具。
实验四:一个服务器连不上,另一个不受影响
我在 yaml 里临时加了一条连不上的假服务器(command: this-command-does-not-exist),
验证完就删掉了,不在最终代码里:
[mcp] 服务器 'broken' 连不上,跳过:FileNotFoundError: [Errno 2] No such file or directory: 'this-command-does-not-exist'
[agent] 要调 get_current_time({'timezone': 'Europe/London'})
[agent] 回答:伦敦现在是 2026年9月3日 凌晨4点56分(周四,BST夏令时)。
改成一次性不带 server_name 的 client.get_tools() 再试一次,time 也会
跟着报错——一个服务器的问题会传染给所有服务器,这就是为什么 load_mcp_tools
要逐个连、逐个 try。
remember_note、cancel_order 顺带验证过没有回归,跟第 5、6 期行为一致,
不占篇幅重复贴了。
发生了什么
“图和提示词都不改”,成立。 graph.py 的节点、边、条件路由,
prompts.py 的系统提示词,这一期一个字没动。变化全部发生在“工具从哪来“
这一层,跟练习 22 的结论一致。
MCP 工具只支持异步调用,这是它的运行时约束,跟我选什么编程风格无关。 第一次
直接套用第 6 期的 graph.stream 跑,报的是
NotImplementedError: StructuredTool does not support sync invocation。
换成 graph.astream 之后,SqliteSaver 又报错要求换成 AsyncSqliteSaver;
再往下,AsyncSqliteStore 的同步方法在事件循环里跑会直接报
InvalidStateError,要求换成 aget/aput。三层报错是真的一步步跑出来的:
换一个异步工具,checkpointer 和 store 要跟着一起换成异步版本,main.py
从头到尾都得用 async/await——这条连锁反应没有事先设计好,是真机验证
逼出来的。
按服务器隔离失败,从一开始就该是默认行为。 client.get_tools()
不传 server_name 是“要么全有要么全无“:任何一个服务器的配置或网络出问题,
所有服务器的工具都拿不到,agent 直接起不来。逐个 get_tools(server_name=)
才能让“一个服务器挂了“只变成“少一个工具“,agent 本身还能正常起来。这一期
只配了一个服务器,默认实现也从一开始就按这个方式写。
常见问题
为什么用 mcp-server-time 这种别人写的服务器,练习 22 是自己写了一个
timeserver 陪练? 两种选择都对,目的不一样。练习 22 想要一个不依赖
外部环境、随时能跑的最小复现,自己写一个最稳。这一期想验证的正好是
“接一个真正意义上别人写的服务器”,用真实的开源实现更贴合这一期的主题——
巧的是两本书都选了时间当例子,纯属巧合。
用户身份怎么传给 MCP 服务器?企业网关的认证呢? 这一期用的是不需要 认证的公开参考服务器,没有涉及。真实企业环境里,认证要挂在连接本身上 (覆盖握手和每次工具调用),用户身份通过请求头逐次透传。只在“调用前“ 包一层拦截会让握手阶段裸奔,这是常见的疏漏。这一期只讲原理,没有实现, 留在这里当一个提醒。
每次跑命令都要重新起一次 uvx 子进程,会不会很慢? 冷启动一次之后
uvx 会缓存这个包,后续快很多,但确实比手写工具多一次进程间通信。这一期
为了保持“一条命令一个独立进程“的示例风格,没有做连接常驻——真实服务里
MCP 连接会跟着服务进程一起常驻,不会每个请求都重连,第 12 期包 FastAPI
服务时会用上。
langchain==1.4.0a4 已经原生带了 langchain.mcp,是不是该用那个?
这个系列锁的是 langchain 1.3.x,1.4 还是预发布版本。等它稳定下来,
langchain-mcp-adapters 这一层大概率可以省掉,接口层面的改动应该不大——
工具最终还是转成同一种 BaseTool。
加分练习
- 在
mcp_servers.yaml里再挂一个官方参考服务器(比如mcp-server-fetch),试着让 agent 抓一个网页。想一想这跟上册练习 29 的web_fetch解决的是同一个问题吗,差别在哪。 - 把
load_mcp_tools里的逐个get_tools(server_name=)换成一次性的client.get_tools(),故意留一条连不上的假服务器,亲眼看一次“一个挂了、 全部拿不到“,跟这一期默认的隔离写法做对比。 - 掐表:这一期每次命令都要重新连一次 MCP 服务器,测一下从起进程到拿到 回复要多久。想一想如果第 12 期的 FastAPI 服务每个请求都这么干,会出 什么问题。
- 找一下
mcp-server-time的开源仓库,看看它到底是用什么写的、跑在哪种 协议实现上——这一期从头到尾没有读过它的源码,验证一下这句话到底 成不成立。