第七话结尾,留下了一个没解决的问题。
那一篇我们让 Agent 记住了「你是谁、你要什么」——跨会话、跨进程地。但记忆要真正派上用场,还差一环:Agent 要去做事,就得调工具。而调工具这件事,前七话一直是「写死」的。
查天气,是代码里写死的一个函数;查库存,是代码里写死的另一个函数。每接一个新工具,就得写一套适配——把它的参数拼成 LLM 认识的 schema,把它的返回值包成 LLM 认识的 Observation。一个工具一套,N 个模型 × M 个工具,就是 N×M 套。
于是问题冒出来了:
工具能不能像 USB 外设一样,插上就能被 Agent 发现、调用,而不是每接一个都要手写一套适配?
「怎么把工具接进来」,就是 MCP(Model Context Protocol) 要解决的问题。
但讲 MCP 之前,得先立住一条边界——它是 MCP 最容易讲错、也最容易被误解的地方:
MCP 定义了「工具怎么被发现、怎么被调用」的格式,但「选哪个工具、给什么参数」这一步,MCP 一个字都不管。 前者是协议,后者是 LLM 在 function-calling 层做的决策。
这条边界落到代码上,就是一件事:把前七话那个 ReAct 循环里「写死的工具源」,换成「可发现、可调用」的 MCP Server——以及在这两者之间,你自己要写的那几行桥接。
一、内存注册表的集成成本:每接一个工具都是一份私有适配
先回到第五话。那一次我们手写了一个 ReAct 循环,模型「想」一步、「做」一步、「看」一步。其中「做」这一步,工具是从哪来的?看代码:
# C05 tools.py —— 工具注册表,写死在进程里
class ToolRegistry:
def __init__(self):
self._tools: dict = {} # 工具名 → {fn, schema}
def register(self, fn, name, description, parameters):
self._tools[name] = {"fn": fn, "schema": {...}}
def list_schemas(self):
return [t["schema"] for t in self._tools.values()] # 给 LLM 拼 tools 参数
def execute(self, name, arguments):
return self._tools[name]["fn"](**arguments) # 本地函数调用
而 ReAct 循环里,工具是这样被用到的:
# C05 react_loop.py —— 循环里对工具的两次触碰
msg = call_llm(state.messages, tools=self.registry.list_schemas()) # ① 工具列表来自注册表
...
resp = self.registry.execute(fn["name"], args) # ② 调用发给本地函数
两个动作,一个来源:工具定义和工具实现,都住在同一个进程的内存里。 list_schemas() 把工具描述拼给 LLM,execute() 直接调本地函数——工具「从哪来」和「调用发给谁」,是一件事,而且都写死。
这在只有一个 get_weather 的时候很顺手。但一旦要接别人家的工具,问题就现形了:
| 场景 | 写死的注册表 | 你实际要做的 |
|---|---|---|
| 接一个天气 API | 手写 get_weather 函数 | 读它家的文档,自己写适配 |
| 接一个股票数据源 | 手写 get_quote 函数 | 再读一套文档,再写一套适配 |
| 换一个 Agent 框架 | 注册表 API 全变 | 全重写 |
| 工具升级了 | 改函数体 | 改完还要同步改 schema |
问题不在「写函数」这件事本身,而在于:「工具怎么被描述」和「工具怎么被调用」,每个工具、每个框架、每个厂商都各有一套私有的约定。 你写的适配,绑死了工具、绑死了框架、绑死了厂商。
这就是 N×M 集成问题——N 个模型/框架 × M 个工具,组合爆炸。你要的是一套「工具如何被发现和调用」的公共约定,而不是第 M+1 套私有适配。 而这,正是 MCP 要解决的。
二、MCP 只解决一件事:把工具接口与实现解耦
MCP 干的事,一句话:把「工具怎么被发现、怎么被调用」做成一套公开标准,让「提供工具的一方」和「使用工具的一方」彻底解耦。
它像什么?像 USB-C。你插一个键盘、一个显示器、一个硬盘,主机不用为每个外设写驱动——因为「外设怎么自我介绍、数据怎么传」有一套公开协议。外设厂商照协议做设备,主机照协议读设备,谁也不用认识谁。
MCP 也是这么个东西,只是对象从「外设」换成了「工具」:
Server 端负责「我有这些工具,能这么调」;Client 端负责「我发现、我调用」。中间只有一套标准协议,两边各写各的,谁都不需要知道对方的实现。
这套协议有几个关键事实,先立住:
- 它借了 LSP 的消息流设计——LSP(Language Server Protocol)早就证明了「编辑器 ↔ 语言服务」可以用 JSON-RPC 解耦,MCP 把同样的思路搬到了「Agent ↔ 工具」。
- 它由 Anthropic 于 2024 年 11 月开源,之后 OpenAI(2025-03)、Google(2025-04)、微软(2025-05)陆续采纳,2025 年 12 月捐给 Linux Foundation 旗下的 Agentic AI Foundation(AAIF)——已经从一个厂商的项目,变成了跨厂商的事实标准。
- 底层是 JSON-RPC 2.0,消息走
stdio(子进程标准输入输出)或 Streamable HTTP 两种传输。 - 三个核心原语:Tools、Resources、Prompts(后来加了 Sampling)。这里只展开 Tools——因为它正是我们前七话一直在调、也最关心的一种。Resources(让模型读数据)和 Prompts(让模型拿模板)是另外两个维度,这里不展开。
但比这些事实更关键的,是下面这条边界:
MCP 只到「工具发现与调用」为止。它不替 LLM 决策。
MCP 的 tools/list 返回的,是「这些工具叫什么、能收什么参数」;MCP 的 tools/call 执行的,是「你(Agent)指名要调的那个工具」。至于「该调哪个工具、给什么参数」,MCP 从头到尾不参与——那是 LLM 在 function-calling 层干的活。
这条边界落到代码里,就是「发现」和「调用」两段——接下来动手把它实现出来。
三、真正要动的只有一处:把循环里的工具源换成 MCP Server
不背协议术语,就做一件事:把第五话那个 ReAct 循环里的「写死的工具源」,换成「MCP Server」,然后看中间多出来的那几行桥接。
3.1 Server 端:暴露工具,就一个装饰器
Server 端用官方 Python SDK v2,暴露三个工具——两个语义化工具(get_weather、get_time)给 LLM 去「选择」,一个最小确定性工具(add)用来验收协议链路本身:
# server.py —— 工具提供方,跑在独立进程里
from mcp.server import MCPServer
mcp = MCPServer("c08-mcp-demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return the result."""
return a + b
@mcp.tool()
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
fake = {"北京": "晴,24°C", "上海": "多云,27°C", "深圳": "阵雨,30°C"}
return fake.get(city, f"{city} 暂无数据")
@mcp.tool()
def get_time() -> str:
"""Get the current server time."""
return datetime.now(timezone.utc).isoformat()
if __name__ == "__main__":
mcp.run() # 默认走 stdio:从 stdin 读 JSON-RPC,往 stdout 写 JSON-RPC
有个细节现在就得记住,它是 MCP 最容易踩的坑之一:mcp.run() 走 stdio,意味着 stdout 就是协议通道。 任何业务日志 print 到 stdout,都会污染 JSON-RPC 消息流,导致客户端解析失败。所以日志只能写 stderr(代码里那条 sys.stderr.write(...) 就是为此)。
3.2 Client 和 Server,是两个进程在说话
看到这里你可能有个疑问:mcp.run() 之后,这个 Server「跑」在哪?谁来连它?
答案是:Server 不是起一个 HTTP 服务监听端口,而是作为一个独立的子进程被 Client 启动;两者之间没有网络、没有端口,就靠操作系统给父子进程天然配好的 stdin/stdout 两条管道通信。
对没有用过这种通信方式的人来说,这一步最反直觉的地方在于——Server 代码里写的明明是 print 和 sys.stdin,它以为自己在往终端打印、从键盘读输入。但它的 stdout 实际上不是终端,而是父进程(Client)手里的一条管道;它的 stdin 也不是键盘,而是同一条管道往回的另一半。
add 函数的实现只存在于进程 B;进程 A 手里根本没有它,只有「一条能跟进程 B 传 JSON-RPC 消息的管道」。所以一次 add(2,3) 的调用,实际是:进程 A 把这条请求写进管道 → 进程 B 从管道读出、执行 add → 把结果 5 再写回管道 → 进程 A 读回 5。
为什么 MCP 的入门传输选 stdio 而不是网络?因为它是成本最低的方式:启动子进程就能用,不需要配端口、IP 或网络协议;不管 Server 是 Python 还是 Java 还是 Node,只要「从 stdin 读、往 stdout 写 JSON」,Client 就能跟它说话,天然跨语言;而且子进程的生命周期跟 Client 绑定,Client 死了 Server 跟着结束,不用额外管理。
一句话:MCP 把工具从「进程内」搬到了「进程外」,stdio 是这两个进程之间最省事的一条消息管道。 理解了这一点,下面 Client 端那句 StdioServerParameters(command=..., args=["server.py"]) 就没什么神秘的了——它就是在说「启动 server.py 这个子进程,然后跟它用 stdin/stdout 通信」。
3.3 Client 端:发现和调用,两行就够
Client 端启动这个 Server 子进程,连上去,发现工具、调用工具:
# main.py —— 工具消费方
from mcp import Client, StdioServerParameters
async with Client(StdioServerParameters(
command=sys.executable, args=["server.py"])) as client:
listed = await client.list_tools() # 发现:tools/list
result = await client.call_tool("add", {"a": 2, "b": 3}) # 调用:tools/call
print(result.structured_content) # 5
跑起来,真实输出(这里只留关键行,完整输出见仓库该目录的 README):
[1] 纯 MCP 层
发现工具: ['add', 'get_weather', 'get_time']
调用 add(2, 3): 5
错误场景(未知工具): is_error=True; Unknown tool: missing_tool
注意最后一行。调用一个不存在的工具,MCP 没有抛异常,而是返回了一个 is_error=True 的结果,错误信息放在结果里。这是 MCP 的一个设计取向:错误也是协议的一部分,走的是返回值而不是异常栈——调用方拿到的是结构化的错误结果,而不是一个需要 try/catch 去猜的异常。这跟第四话 ToolRegistry 里「错误要结构化、可读」是同一个思路,只是这次被提到了协议层。
到这里,「发现」和「调用」这两条协议链路都通了。但——这还只是 MCP 自己。LLM 在哪?
3.4 桥接是唯一的脏活,也是唯一不能省的一步
回到第五话的 ReAct 循环,工具源是内存注册表,两处触碰:
msg = call_llm(messages, tools=self.registry.list_schemas()) # ① 工具列表 → LLM
resp = self.registry.execute(name, args) # ② LLM 的选择 → 本地函数
换成 MCP 之后,循环本身一行都不用改——模型照样「想」、照样「做」、照样「看」。变的只有这两处触碰的来源:
# 换成 MCP 之后
async with Client(stdio_server()) as client:
tools = await client.list_tools() # ① 工具列表来自 MCP
resp = call_llm(messages, tools=tools_to_llm_schema(tools))
...
result = await client.call_tool(name, args) # ② 调用发给 MCP
但注意中间那一步——tools_to_llm_schema(tools)。 这就是桥接,而且是 MCP 自己不会替你做的事。
client.list_tools() 返回的,是 MCP 的 Tool 对象(name / description / input_schema);而 LLM 的 function-calling 认的,是 OpenAI 风格的 tools 参数(type: "function" / function: {name, description, parameters})。这两个格式长得像,但不是一个东西。把它们对齐,是你(Agent 代码)要写的字段搬运:
def tools_to_llm_schema(mcp_tools):
"""把 MCP 的 tools/list 结果,转成 LLM function-calling 的 tools 参数。"""
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description or "",
"parameters": t.input_schema or {"type": "object", "properties": {}},
},
}
for t in mcp_tools
]
就是这十几行,没用什么高深技巧。但它恰恰是最容易被忽略、也最容易误解的一环:
MCP 到 LLM 之间,没有一步是 MCP 自己完成的。
tools/list返回 MCP 的工具描述,要变成 LLM 认识的tools参数,是你做的字段搬运;LLM 返回的tool_call,要变成 MCP 的tools/call,也是你做的。
很多人对 MCP 的误解,恰恰卡在这里:以为「装上 MCP 工具,就自动能被 LLM 用了」。不是的。MCP 把「工具怎么描述、怎么调用」标准化了,但「把 MCP 工具翻译给 LLM」和「把 LLM 的选择翻译回 MCP 调用」,这两步是 Agent 循环里你自己写的桥接代码。 框架(LangChain、SDK 的 agent 封装)会把这一步藏进 bind_tools()、create_mcp_client() 之类的黑盒里,让你看不见;手写循环反而把这一步摊开在眼前。
这条链,用一张图看更清楚:
看清这条链上,MCP 到底在哪、LLM 到底在哪:MCP 只出现在「工具发现」和「工具执行」两段;「选哪个工具、给什么参数」,是 LLM 从 tools 参数里自己决定的;「把 MCP 结果翻译给 LLM、把 LLM 的选择翻译成 MCP 调用」,是 Agent 循环做的。 三者各司其职,边界清晰。
3.5 串起来,让模型自己挑工具
把上面全部串起来,带真实 LLM 跑一遍。问题是「北京今天天气怎么样?顺便帮我算一下 12 加 30」——这句话故意一次要两个工具,好让「模型选择」这个关键点现形:
[2] LLM 完整闭环
模型选择 get_weather({'city': '北京'}) → Observation: "晴,24°C"
模型选择 add({'a': 12, 'b': 30}) → Observation: 42
最终回答: 北京今天晴,24°C;12 加 30 等于 42。
模型自己挑了 get_weather(不是 get_time,不是 add),给对了参数 city=北京;又挑了 add 去算 12+30。这两个「选择」全是 LLM 在 function-calling 层做的,MCP 全程没参与决策——它只负责把 get_weather 和 add 这两个工具「被发现」和「被调用」。
这正是那句话的活体证据:
MCP 标准化了「工具怎么被发现、怎么被调用」,但它不替 LLM 决策——选哪个工具、给什么参数,仍是 LLM 在 function-calling 层做的。
四、从 demo 到生产,还有四块要补
跑通了最小闭环,但你不会就这么上线。从「demo 能跑」到「生产能用」,至少要补四块:
- 传输换成 Streamable HTTP。 stdio 只能本地子进程;生产里的 MCP Server 往往跑在远端、要鉴权、要并发。Streamable HTTP 是 stdio 的远程版,也支持无状态服务器的水平扩展。
- 工具白名单 / 覆写。 MCP Server 暴露的工具,你不一定全要、也不一定全信。生产里 Client 端通常会过滤、重命名、甚至覆写某个工具的 schema,而不是「Server 给什么就接什么」。
- 可观测。 一次
tools/call从发起到返回,走了哪台 Server、耗时多少、失败在哪,需要 Trace 和日志。demo 里那些打到 stderr 的日志,就是这条链的雏形。 - 安全边界。 MCP 的
tools/call本质是「让 Agent 替你执行一个远端函数」。这个函数能不能写文件、能不能发请求、能不能碰敏感数据,是 Server 端要想清楚并显式约束的,不能把「协议通了」当成「安全没问题」。
这四块,把 MCP 从「能跑」推进到「敢用」。它们属于「工程化」的延伸,不是「MCP 是什么」本身,所以这里只列出来,不展开。
五、工具能标准化接入了,下一个没解决的问题是什么
把这条数据流从头到尾捋一遍,答案就完整了:
前七话:工具写死在内存注册表里,list_schemas() 拼给 LLM,execute() 调本地函数
→ 痛点:每接一个工具/框架/厂商,都要手写一套适配,N×M 组合爆炸
→ MCP:把「工具怎么被发现、怎么被调用」做成标准协议,Server/Client 解耦
→ 桥接:tools/list 结果 → tools_to_llm_schema() → LLM 的 function-calling 参数
→ 决策:LLM 从 tools 里自己选工具、给参数(MCP 不参与)
→ 执行:Agent 把 LLM 的选择,翻译成 MCP 的 tools/call,拿回 Observation
→ 回答:Observation 回填 messages,LLM 生成最终答案
MCP 是「工具注册表」和「工具执行器」的替代品,不是「Agent 循环」的替代品。它把原来「本地字典 + 本地函数」这两件事,换成了「跨进程问一个 Server」;而循环、状态、记忆、决策,全都还在你手里。那几行 tools_to_llm_schema() 桥接,就是 MCP 和 LLM 之间「你」的存在——框架会把它藏起来,但理解了它,你才真正理解了 MCP 是怎么进 Agent 的。
工具能标准化地接入了,但还有一类东西没法靠 MCP 解决:「怎么做一件事的方法」。 查天气是工具,但「处理订单要先查库存、再扣库存、扣完发通知、全程落盘」是一套方法——它不是「一个工具」,而是「一串工具的编排 + 判断」。工具能即插即用了,方法怎么复用?这就是第九话要讲的:Skill。
演示代码:Python 版在 GYA 仓库
c08-mcp,Java 21 等价实现在 GYA-Java 仓库c08-mcp。按各自 README 的命令跑,不要复制这篇文章里零散的代码块。运行环境:Python 版用官方 MCP Python SDK v2(
mcp[cli],实测 2.2.0);Java 版用官方 MCP Java SDK 2.0.1。注意:本文代码基于 SDK v2 的 API(from mcp import Client/from mcp.server import MCPServer)。 如果你手上还留着 v1 风格的旧代码(ClientSession、stdio_client上下文、client.initialize()、result.content[0].text),那在 v2 下是跑不通的——v2 把客户端统一成了Client、结果结构也从content[0].text改成了structured_content/is_error。参考:MCP 由 Anthropic 于 2024 年 11 月 25 日开源(Wikipedia「Model Context Protocol」条目),2025 年 12 月捐给 Linux Foundation 旗下的 Agentic AI Foundation(AAIF);底层为 JSON-RPC 2.0,传输支持 stdio 与 Streamable HTTP;核心原语为 Tools / Resources / Prompts(另加 Sampling)。借鉴 LSP 消息流设计一事,见 Anthropic 官方介绍与 MCP 规范文档。闭环 Trace 为 2026-09 本机实测(Python 与 Java 双跑)。

留言
欢迎分享你的想法。评论提交后会在审核通过后显示。