Featured image of post 第八话|MCP 的边界:工具发现与调用是协议,选哪个工具仍是模型的事

第八话|MCP 的边界:工具发现与调用是协议,选哪个工具仍是模型的事

前七话的工具都是写死在代码里的函数。MCP 把「工具怎么被发现、怎么被调用」做成标准协议,但到 Agent 为止——选哪个工具、给什么参数,仍是 LLM 在 function-calling 层做的。

系列 GYA(Get Your Agent) 第 8 / 13 篇
主题 AgentLLMMCP工具调用JSON-RPC教程

第七话结尾,留下了一个没解决的问题。

那一篇我们让 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 能跑」到「生产能用」,至少要补四块:

  1. 传输换成 Streamable HTTP。 stdio 只能本地子进程;生产里的 MCP Server 往往跑在远端、要鉴权、要并发。Streamable HTTP 是 stdio 的远程版,也支持无状态服务器的水平扩展。
  2. 工具白名单 / 覆写。 MCP Server 暴露的工具,你不一定全要、也不一定全信。生产里 Client 端通常会过滤、重命名、甚至覆写某个工具的 schema,而不是「Server 给什么就接什么」。
  3. 可观测。 一次 tools/call 从发起到返回,走了哪台 Server、耗时多少、失败在哪,需要 Trace 和日志。demo 里那些打到 stderr 的日志,就是这条链的雏形。
  4. 安全边界。 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 双跑)。

留言

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