苦猿的大模型日记 · Day64 · 从0学习Claude Code(十四)MCP工具插座-帮普通人把AI学进简历系列
前言:Agent 的手,全是焊死在身上的
本篇干一件事:给 Agent 装一个"工具插座"------运行时插上外部服务,工具自动到位,而 Agent 循环一行代码都不用改。
先说现状。到目前为止咱们造的 Agent,手里的工具全写死在代码里:bash、读文件、写文件、改文件、找文件,五个。够用吗?写代码够用。但你想让它接文档系统,就得手写一个 search_docs;接部署平台,就得手写 deploy_status 和 trigger_deploy;接公司知识库、监控平台、工单系统......每接一个服务,三样东西全得自己扛一遍:
- 工具定义------名字、描述、参数 schema,少一项模型就用不明白
- 调用代码------怎么连上服务、怎么传参、怎么解析返回
- 错误处理------服务挂了怎么办、参数错了怎么办、超时了怎么办
一个服务三样,十个服务三十样。而且每家服务的参数格式、返回结构、报错习惯都不一样,你写的不是"接工具",是给每家服务定制一根专用线缆。
如果全天下只有一个 Agent,忍忍也就算了。问题是外部服务有 N 家,Agent 实现有 M 种,每种组合都手写一遍,全行业加起来就是 N × M 份重复劳动。这份乘法,就是 MCP(Model Context Protocol)要消灭的东西。
读完这篇,你会拿到三样东西:
- 协议思维 :MCP 剥掉所有包装,核心就两个动词------
tools/list(你有什么工具)和tools/call(帮我调一个) - 动态工具池:工具从一份写死的常量,变成每轮现场组装的池子------插上就有,拔了就没
- 安全边界:外部工具的名字怎么防撞车,权限为什么不能信 server 的"自我介绍"
门槛不变:会 Python 基础语法、手上有一份能跑的 Agent 循环。直接开始。
PART 01:案发现场------每接一个服务,就手写一遍胶水
先把手写胶水的样子摆出来看。接一个文档系统,你的代码里会多出这么一块:
def search_docs(query: str) -> str:
"""Search the documentation."""
try:
results = docs_api.search(query) # 服务专属 SDK
return format_results(results) # 服务专属返回格式
except DocsApiError as exc:
return f"Error: {exc}" # 服务专属报错习惯
一个工具一份,看起来还好。接着接部署平台:
def deploy_status(service: str) -> str:
"""Check deployment status."""
return deploy_sdk.get(service).to_string() # 又一套 SDK、又一套格式
def trigger_deploy(service: str) -> str:
"""Trigger a deployment."""
return deploy_sdk.trigger(service) # 这个还危险,得加确认
三个工具,两套 SDK,两套返回格式,两种错误习惯。写到第五个服务的时候你会发现自己成了全职胶水工------而且这些胶水换一个 Agent 框架就全部作废,因为工具定义、参数格式、调用入口全都跟你这份代码绑死了。
换个角度想这件事。你家的冰箱、洗衣机、台灯,电器厂商有谁是跟你家电路定制接线的吗?没有。电器厂不需要知道你家电怎么布线,你家也不需要为每个新电器砸一次墙------因为墙上有一个统一插座,插头有一个统一标准。
MCP 就是 AI 工具圈的插座标准。它把"提供工具"和"使用工具"拆成两个角色:
- server(服务方):对外报清单------我有哪些工具、每个工具的参数是什么;并接受调用。文档系统做一个 docs server,部署平台做一个 deploy server,各管各的。
- 宿主(使用方,也就是 Harness):负责连接 server、给发现的工具起名字、做权限检查,最后把它们塞进模型的工具列表。
两边在协议上只需要聊两句:
宿主 → server:tools/list (你有什么工具?)
宿主 → server:tools/call (帮我调这个工具,参数在这儿)
没有第三句。server 不需要知道宿主内部怎么跑循环,宿主不需要知道 server 背后是数据库还是 HTTP 接口。
协议的意义从来不是加功能,是画边界------两个互不了解的系统,靠两条消息就能合作。

PART 02:连接即发现------工具池是每轮现场组装的
插座怎么装进咱们现有的 Agent?三个新零件:MCPClient、connect_mcp、assemble_tool_pool。一个一个看。
零件一:MCPClient,server 的账本
每个连上的 server 在宿主这边对应一个 MCPClient,它记两样东西:这个 server 报来了哪些工具定义,以及每个工具怎么调:
class MCPClient:
def __init__(self, name: str):
self.name = name
self.tools: list[dict] = [] # tools/list 的结果
self._handlers: dict[str, callable] = {} # 工具名 → 调用入口
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}"
重点看 call_tool 的错误处理,这里藏着一个设计决定:工具调不明白,返回错误字符串,不抛异常 。工具名不认识、参数不对、handler 内部炸了,全部变成一条 MCP error: ... 文本返回给模型。
为什么?因为这个错误是给模型看的。模型看到 "missing 1 required argument: 'query'",下一轮自己就把参数补上了。你要是直接 raise 让脚本崩掉,模型连补救的机会都没有。错误是喂给模型的数据,不是打断程序的事故。
零件二:connect_mcp,只管插上
connect_mcp 是宿主自己暴露给模型的一个工具------注意,这个是插座的总开关,让模型(或者说你,通过对话)来决定插哪个 server:
def connect_mcp(name: str) -> str:
if name in mcp_clients:
return f"MCP server '{name}' already connected"
factory = MOCK_SERVERS.get(name)
if not factory:
return f"Unknown server '{name}'. Available: {', '.join(MOCK_SERVERS)}"
server = factory()
mcp_clients[name] = server
names = ", ".join(tool["name"] for tool in server.tools)
return (f"Connected to MCP server '{name}'. "
f"Discovered {len(server.tools)} tools: {names}")
它只做三件事:防重复插、认不出插头就明说、插上之后报清单------"连上了 docs server,发现 2 个工具:search、get_version"。这段回话会作为 tool_result 进入对话,模型当场就知道自己多了什么新家伙。
零件三:assemble_tool_pool,每轮现场拼盘
这是整个设计的腰眼。以前工具列表是一份写死的常量;现在它变成一个每轮重新组装的函数:
def agent_loop(messages: list):
while True:
tools, handlers = assemble_tool_pool() # ← 每轮都重新组装
response = client.messages.create(
model=MODEL,
system=assemble_system_prompt(),
messages=messages,
tools=tools,
...
)
assemble_tool_pool() 干的活:先放进内置的五个基础工具加 connect_mcp,再把所有已连接 server 的工具逐个追加进去。于是时间线变成这样:
第 1 轮:模型看到 6 个工具(5 基础 + connect_mcp)
↓ 模型调用 connect_mcp(name="docs")
第 2 轮:模型看到 8 个工具
(多的两个:mcp__docs__search、mcp__docs__get_version)
Agent Loop 一行没改。模型那边也完全无感------它不"知道"自己被接了插座,只是第二轮醒来发现手里多了两个工具,顺手就用上了。这就是插座设计的精髓:加东西的人不用改结构,用东西的人不需要知情。
最后交代一个诚实边界:本篇的 docs 和 deploy 是进程内模拟的 server ------它俩用 Python 函数模拟了 tools/list 和 tools/call 的协议行为,真实的网络传输(stdio、HTTP)不在这一章范围。但协议边界是一样的:发现和调用的逻辑一行不用变,变的只是传输层。把模拟 server 换成真 server,宿主这边无感。

PART 03:名字的工程学------mcp__{server}__{tool} 不是装饰
工具从外部涌进来,第一个撞上的问题就是重名 。docs server 有个 search,你说知识库 server 会不会也有个 search?deploy server 有 status,监控 server 一定也有 status。直接裸名字扔进工具池,模型点菜的时候点的是哪家的?
解法是给每个外部工具戴上门牌:
mcp__{server}__{tool}
mcp__docs__search、mcp__deploy__status------server 名当前缀,工具名当后缀,中间双下划线隔开,一眼知道这工具是哪家的。模型看到的是带前缀的名字;而 handler 内部调用时,用的还是 server 的原始工具名。门牌是给模型看的,门内还是原样------server 不需要知道自己会被起什么外号。
归一化:替换字符,但拒绝悄悄撞车
server 的工具名是外部输入,什么妖魔鬼怪都可能有:带点的 get.version、带斜杠的 docs/search,而模型工具名只认字母数字下划线连字符。所以要先清洗:
def normalize_mcp_name(name: str) -> str:
normalized = _DISALLOWED_CHARS.sub("_", name)
if not normalized:
raise ValueError("MCP names cannot normalize to an empty string")
return normalized
把非法字符统一换成下划线。但这里有个阴险的坑:清洗本身会制造撞车 。docs.one/get.version 清洗完是 docs_one/get_version,而另一个 server 可能真有一个就叫 docs_one/get_version 的工具------清洗后俩名字一模一样。如果悄悄合并,其中一个工具就成了鬼影:调用永远只到得了其中一个,另一个无论模型怎么点都点不亮,而且不报错,只出错------这种 bug 能让你查一晚上。
所以组装工具池时必须显式查重,撞了就当场炸:
if prefixed in origins:
raise ValueError(
"MCP tool name collision after normalization: "
f"{prefixed!r} maps both {origins[prefixed]} and {origin}"
)
宁可当场报错让人改名字,绝不悄悄合并赌运气。顺带还有一道 64 字符上限------模型工具名太长会踩 API 限制,超了同样当场报错。
lambda 陷阱:注册的工具全指向最后一个
组装时还有个 Python 经典坑,值得单独拎出来。把 server 工具塞进 handlers 字典,最直觉的写法:
for server_name, server in mcp_clients.items():
for tool_def in server.tools:
# ❌ 错误写法
handlers[prefixed] = lambda **kwargs: server.call_tool(raw_name, kwargs)
这段代码能跑,注册也能注册上,但所有 lambda 都指向最后一个 server 的最后一个工具 。因为 lambda 是延迟求值的------调用的时候才去看 server 和 raw_name 这两个变量,那时循环早结束了,两个变量停留在线最后一轮的值。
这个坑在工具注册场景里形态特别隐蔽:工具全部能调通,不抛异常,只是全调到同一个。你要接六个 server,结果六个工具全打到第六家头上,还觉得是那家服务的 bug。
解法是老配方,用默认参数把当前值钉住:
handlers[prefixed] = (
lambda *, client=server, tool=raw_name, **kwargs:
client.call_tool(tool, kwargs)
)
默认参数在定义时求值,每次循环产生一个新 lambda,各自记住自己的 client 和 tool。一行改动,六个幽灵变六个活人。

PART 04:权限------server 的自我介绍不算数
外部工具涌进来,第二个问题是谁说了算。
MCP 的工具定义里允许 server 给自己贴标签:readOnlyHint(我只读不写,放心调)、destructiveHint(我很危险,想清楚)。看起来很贴心------只读的自动放行,危险的弹确认,齐活?
慢着。这些标签是 server 的自我介绍,不是授权 。一个被入侵的、或者干脆就是恶意的 server,完全可以把删库工具标成 readOnlyHint: true,宿主一看"哦它自己说是只读的",直接放行------权限体系就成了摆设。
所以本篇的规则是:授权的章,必须握在宿主自己手里。宿主维护一份策略表:
# Authorization comes from host configuration, never server descriptions.
MCP_HOST_POLICY = {
("docs", "search"): "allow", # 文档搜索:放行
("docs", "get_version"): "allow", # 查版本号:放行
("deploy", "status"): "allow", # 看部署状态:放行
("deploy", "trigger"): "confirm", # 触发部署:必须过我这一关
}
注意代码里那行注释,它就是整个权限设计的立场:授权来自宿主配置,永远不来自 server 描述。落到执行层,权限钩子对所有 mcp__ 开头的工具查这份表:
if block.name.startswith("mcp__"):
policy = mcp_tool_policies.get(block.name, "confirm")
if policy != "allow":
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
return "Permission denied by user"
看那个默认值:没配置的外部工具,一律 confirm。宁可多问用户一句,不可放过一个。这跟前面的命名查重是同一种工程品味:对内部错误,当场炸;对外部风险,默认拦。
最后一块拼图,还是错误处理。外部工具的调用错误------模型漏传参数、传了 server 不认识的字段------全部被拦在工具边界内,变成 tool_result 里的错误文本:
MCP error: TypeError: <lambda>() missing 1 required argument: 'query'
模型下一轮看到这条报错,自己补上参数重调。整个会话不崩、不停、不需要人工重启。跟 MCPClient.call_tool 的设计一脉相承:错误留在边界内,变成喂给模型的养料。

结尾:从带工具,到长工具
回顾这一篇的五块砖:角色拆分 (server 报工具,宿主管连接和权限,两句协议画清边界)、动态工具池 (每轮现场组装,插上就有)、命名空间 (前缀防重名,归一化撞车当场炸,绝悄悄合并)、宿主授权 (自我介绍不算数,未配置默认拦)、错误留边界(报错回喂模型,脚本永不崩)。
再往大看一眼。到今天,这个 Agent 已经有了记忆、会压缩上下文、能管任务清单、会把耗时活丢后台、能定时开工、能带团队干活------这一篇又给了它一个可插拔的工具世界。前面那些能力让它越来越像一个能干的员工,这一篇让它的能力上限不再取决于你写了多少代码,而取决于世界上有多少 server。
而回头看会发现,MCP 全篇没有发明任何高深东西:报清单、接调用、起名字、查权限------全是工程里最朴素的动作。它解决的问题也朴素:别让 N 个服务和 M 个框架,写 N 乘 M 遍胶水。
插座的意义从来不是供电,是让电器和墙互不认识也能合作。
互动时间:如果明天就能给你的 Agent 插上任何一个服务,你先插哪个?Jira、飞书文档、公司知识库、监控平台、数据库......评论区说说,说不定下一篇就拿它当例子。
下一篇预告:「从0学习 Claude Code」第十五篇------集成 Harness。前十四篇攒了一屋子零件:工具、Hooks、技能、上下文管理、记忆、任务、后台、调度、团队、MCP------下一篇把它们全部装进同一个运行时,拼出一台完整的 Claude Code。也是这个系列的收官之战。
--- END ---
苦猿 · 帮普通人把 AI 学进简历