【案例】Qwen 微调实战 · MCP 协议与工具接入
给每件工具单配一根转换头,接十件就得带十个头。MCP 做的事只有一件:把插头统一成同一种规格。
30″30 秒看懂 MCP
想象你搬进一间插座规格全乱的老房子:热水壶是圆孔的,投影仪是扁孔的,音响是三相的,每买一件电器就得再配一个专用转换头。抽屉里塞了十几个头,换个房间还得全部搬过去——一把锁单独配一把钥匙。
MCP 干的事情朴素到有点无聊:把插头形状统一成同一种规格。电器厂商按这个规格出厂(这一端叫服务器),墙上装一排同规格的插座(这一端叫客户端),从此谁插谁都通,换房间也不用重新配转换头。
要紧的是别误会了这件事的性质:统一插头没有改变电从哪来。插座背后还是那根火线,电压电流一点没变。MCP 也是——它底层仍然依托模型原生的 Function Call 能力,只是在上面做了一层标准化封装。

| 配电房里的角色 | 对应的技术概念 | 它到底是什么 |
|---|---|---|
| 各式各样的电器 | 外部工具(天气接口、文件系统、git 仓库) | 模型自己做不到、必须借外力完成的事 |
| 一抽屉异形转换头 | 传统的定制集成 | 围绕每个 API 单独写一个外部函数,工具一多开发量暴涨 |
| 统一插头规格 | MCP 协议 | 约定通信格式与调度规范,双方照着做就能互通 |
| 电器上的标准插头 | MCP 服务器(server) | 外部工具那一端,把能力按标准格式暴露出来 |
| 墙上那排插座 | MCP 客户端(client) | 接入这些工具的大模型运行环境,一个客户端可以接多个服务器 |
| 配电箱的接线清单 | servers_config.json | 写明每个服务器叫什么名字、用什么命令拉起来 |
| 同一屋的墙插 / 跨楼的供电线 | stdio / SSE + HTTP | 两种通信机制:本机进程间,或跨网络分布式 |
| 插座背后那根火线 | Function Call | 真正让模型调起函数的底层机制,MCP 没有替换它 |
| 电器上的铭牌 | 工具描述(format_for_llm) | 写清这件工具叫什么、要哪些参数,模型据此判断用不用 |
| 会自己决定用哪件电器的管家 | Agent | 自主运行的智能系统,利用 Function Call 和 MCP 分析并执行任务 |
tool_calls、tool_call_id、两次调用模型,在这一讲里一条都没作废。
01概念:一个协议解决的是什么问题
定位、由来、客户端与服务器的划分,以及它和 Function Call、Agent 的关系
1.1 MCP 是什么
MCP(Model Context Protocol,模型上下文协议),2024 年 11 月底由 Anthropic 推出的一种开放标准,旨在统一大模型与外部数据源和工具之间的通信协议。它要解决的是当前 AI 模型因数据孤岛限制而无法充分发挥潜力的难题——让 AI 应用能够安全地访问和操作本地及远程数据,为 AI 应用提供连接万物的接口。
对开发者而言,它最直接的价值是消除了为每个数据源或工具进行定制集成的需要,减少开发时间和维护成本。本质上,MCP 是一种技术协议,一种 Agent 开发过程中共同约定的规范;在统一的规范下,协作效率大幅提高,最终提升 Agent 的开发效率。
为什么需要一个协议?看看没有它的时候是什么样:
| 对比项 | 传统定制集成 | 有了 MCP 之后 |
|---|---|---|
| 接一个新工具 | 围绕这个 API 单独写一个外部函数,连同参数描述、错误处理一起手写 | 在配置文件里加一段,声明用什么命令把它拉起来 |
| 接十个工具 | 写十套,一把锁单独配一把钥匙;一个智能体往往涉及多个外部工具,开发工作量很大 | 配置文件写十段,客户端代码一个字不用改 |
| 换一个项目复用 | 把那十套函数原样搬过去,还要重接一遍 | 把配置文件抄过去 |
| 别人写好的工具 | 得先读懂他的函数签名,再包一层 | 只要遵循 MCP,直接挂上去就能用 |
官方给出的类比是 USB-C:如同 USB-C 通过统一接口连接多种设备,MCP 旨在为 AI 应用提供一个「即插即用」的上下文管理框架。核心思想是把模型与外部系统之间的通信抽象为一个客户端—服务器架构,通过标准化的接口(如基于 JSON-RPC 的通信)实现上下文的动态传递和工具的灵活调用。Anthropic 在发布时提供了初步的规范和 SDK(Python、TypeScript 等),并开源了多个预构建的 MCP 服务器(如 Google Drive、GitHub 集成),以加速该协议的推广。
1.2 服务器与客户端:谁是谁
这是初学 MCP 最容易绕晕的地方,因为它的命名和直觉相反。记住这一句就不会错:
也就是说:提供能力的一端是服务器(电器),消费能力的一端是客户端(插座排)——哪怕那个「服务器」只是你本机上的一个几十行的 Python 脚本。
| MCP 服务器 | MCP 客户端 | |
|---|---|---|
| 是什么 | 外部工具 | 接入这些外部工具的大模型运行环境 |
| 数量关系 | 可以有很多个 | 一个客户端可以接入多个不同类型的服务器,但要求都遵循 MCP 通信协议 |
| 输出 | 一种标准格式的内容,只能被 MCP 客户端所识别 | 把工具结果交给模型,最终产出自然语言回答 |
| 本讲的实例 | weather_server.py(查天气)、write_server.py(写文件) | mcp_client.py(连服务器、跑对话循环) |
在客户端和服务器都遵循 MCP 协议的时候,客户端就能够像 Function Call 中大模型调用外部工具一样,调用 MCP 服务器里面的工具。
在 MCP 技术爆发的这几个月,市面上已经诞生了成百上千的 MCP 服务器,甚至出现了大量的 MCP 服务器集合网站——官方服务器合集、GitHub 热门导航、各类 MCP 导航站,以及百度智能体平台、阿里云百炼平台这类把 MCP 服务集成进去的平台。实际开发时可以参考这些站点,有选择地调用现成工具,不必什么都自己写。
1.3 和 Function Call 的真实关系
网上常见一种说法:「有了 MCP 就不需要 Function Call 了」。这句话是错的,而且错得很具体。
通过在 MCP 运行过程中进行数据包捕获与分析可知,MCP 的底层实现机制本质上仍是依托于大模型原生自带的 Function Call 能力,以完成对外部工具的调用操作。只不过,MCP 在此基础上对这一过程进行了更高层次的封装与优化,从而构建起更为完善的交互与功能体系。
模型层(Function Call)负责:模型判断要不要调、调哪个、参数填什么,产出
tool_calls。换插头不改变供电原理——所以
tool_calls 要不要回填、tool_call_id 怎么配对、为什么要调两次模型,这些规则在 MCP 场景里原封不动地继续成立。本讲的客户端代码里能一行行看到它们。
那 MCP 究竟改善了什么?对照着看最清楚:
| 环节 | 裸写 Function Call | 走 MCP |
|---|---|---|
| 工具怎么被模型知道 | 手写 tools 的 JSON Schema,每个参数自己描述 | 向服务器 查询工具,拿回名称、描述、输入模式,客户端自动转成模型要的格式 |
| 工具怎么被执行 | 自己写派发表,if name == "xxx" 或字典映射 | 按 服务名_工具名 路由到对应服务器,由服务器执行 |
| 工具住在哪 | 和主程序同一个进程,改工具要动主程序 | 独立进程甚至独立机器,改工具不碰客户端 |
| 换一批工具 | 改代码 | 改配置文件 |
谁产出 tool_calls | 模型 | 还是模型,这一层没变 |
1.4 Function Call、MCP、Agent 三者的分工
这三个词经常被混着用,但它们处在完全不同的层面:
是 AI 大模型调用函数的机制。模型只负责判断要不要调、调哪个、参数填什么,它自己从不执行。对应火线——电从哪来。
是一个标准协议,使大模型与 API 无缝交互。它规定插头形状、铭牌怎么写、线怎么接。对应插座规格——怎么接得上。
是一个自主运行的智能系统,利用 Function Call 和 MCP 来分析和执行任务,实现特定目标。对应管家——决定这会儿该用哪件电器。
所以三者是叠起来的,不是并列的三选一:Agent 站在最上面做决策,MCP 在中间把工具接进来,Function Call 在最底下真正把「调用」这件事发生出来。
02原理:一次工具调用是怎么走完的
四步流程、两种通信机制、三层技术生态,以及标准化换来的具体好处

2.1 客户端调用服务器工具的四步
从「用户问了一句话」到「工具真的被执行」,中间是固定的四步。前三步是 MCP 做的事,第四步才轮到 Function Call 登场:
与 MCP 服务器搭建通信链路。本机场景下,客户端把服务器脚本作为子进程拉起来,用标准输入输出接上。相当于把插头插进插座。
获取服务器上所有外部工具的数量信息——每个工具叫什么、干什么、需要哪些参数。相当于读电器铭牌。
将查询到的外部工具整理成列表,并融入当前对话场景,转换成模型能读懂的工具描述格式。相当于把这排插座上接了什么,告诉管家。
通过 Function Call 技术调用所需的外部工具。模型返回工具名和参数,客户端据此路由到对应服务器执行。这一步走的是火线。

把这四步和 Function Call 那一讲的流程叠在一起看,会发现第 4 步之后的一切都没变:模型返回 tool_calls → 客户端执行 → 把结果作为 role 为 tool 的消息回填 → 再调一次模型拿自然语言答复。变的只是工具描述从哪来(原来手写,现在向服务器查)和工具在哪执行(原来同进程,现在独立进程)。
2.2 两种通信机制:同屋墙插,还是跨楼供电
MCP 协议支持两种主要的通信机制,选哪种取决于工具和模型跑在不在同一台机器上:
| 机制 | 传输方式 | 适用场景 | 比喻 |
|---|---|---|---|
| 本地通信 | 通过 stdio 传输数据 | 在同一台机器上运行的客户端和服务器之间的通信 | 同一间屋子的墙插,一插就通,没有布线问题 |
| 远程通信 | 利用 SSE 与 HTTP 结合,实现跨网络的实时数据传输 | 需要访问远程资源或分布式部署的场景 | 跨楼的供电线路,要考虑线路、权限和距离 |
本讲的天气服务器用的是 stdio:mcp.run(transport='stdio')。这意味着:
- 客户端用配置里的
command和args把服务器当子进程启动,两者靠管道通信; - 服务器进程的生命周期完全由客户端掌管——客户端退出,服务器也跟着结束;
- 服务器不能往标准输出打印任何调试信息,因为那条通道被协议占用了。这是新手最容易踩的一个坑,06 节会专门讲。
2.3 技术生态的三层
随着技术迭代加速,MCP 在发展进程中实现了重大跨越。如今它已远非简单的「协议」概念所能涵盖,而是构建起一个完整且自成体系的技术生态。三层协同:
| 层 | 内容 | 作用 |
|---|---|---|
| MCP 协议 | 一套抽象的规范集合,涵盖大模型与工具的调度规范、服务器与客户端之间的通信规范等 | 底层支撑,确保数据传输与交互的规范性和稳定性。遵循这些协议标准的对象,即被认定为 MCP 服务器或客户端 |
| MCP 开发工具 | 多种编程语言版本的 SDK | 开发者借助这些 SDK 可高效完成 MCP 服务器和客户端的开发,大大缩短开发周期 |
| MCP 服务器生态 | 以开源的 MCP 服务器为核心构建起的庞大生态 | 智能体开发人员可直接利用生态中的开源工具,加速自身项目的开发进程,降低技术应用门槛 |
对照插座的比喻:协议是国标文件,SDK 是厂家拿到的模具和检测工具,服务器生态是市面上已经按标准出厂的那一大堆电器。三者缺一不可——只有标准没有电器,插座排上空空如也;只有电器没有标准,又回到一抽屉转换头。
2.4 标准化到底换来了什么
这一节值得单独拎出来,因为它是 MCP 这一讲区别于 Function Call 那一讲的全部意义所在。同样是「让模型用上外部工具」,标准化带来了四件具体的事:
裸 Function Call 要为每个函数手写一整段 JSON Schema。走 MCP 时,工具自己带铭牌:服务器返回名称、描述、输入模式,客户端自动转成模型要的格式。写错参数名这类低级错误从源头消失了。
工具跑在独立进程里。改天气工具不必重启主应用,主应用换个模型也不影响工具。团队协作时,写工具的人和写 Agent 的人可以完全并行。
加一个服务器就是在 servers_config.json 里加一段。客户端代码一个字不用动——这就是本讲 4.6 节能在五分钟内挂上第二个服务器的原因。
市面上已有成百上千的开源 MCP 服务器。只要遵循同一协议,拿来即用,不需要读源码再包一层。这是「一把锁一把钥匙」时代不可能有的效率。
AsyncExitStack、asyncio.Lock、retries=2 这几处都是在处理这些代价——它们不是炫技,是协议化之后必须自己扛的工程责任。
03最小代码:三十行写一个 MCP 服务器
先把最短的一条路走完,再去看完整的天气服务器和多服务器客户端
写一个 MCP 服务器,剥掉业务逻辑之后只剩三件事:建一个实例、给函数挂个装饰器、跑起来。
最短的服务器
from mcp.server.fastmcp import FastMCP
# 服务器名字会随工具列表一起暴露给客户端
mcp = FastMCP("WriteServer")
@mcp.tool()
async def write_file(content: str) -> str:
"""将指定内容写入本地文件。
:param content: 必要参数,字符串类型,表示需要写入文档的具体内容。
:return: 是否成功写入
"""
try:
with open("tmp.txt", "w", encoding="utf-8") as file:
file.write(content)
return "已成功写入本地文件。"
except Exception:
return "未成功写入."
if __name__ == "__main__":
# 以标准 I/O 方式运行 MCP 服务器
mcp.run(transport='stdio')
三处细节值得停一下:
| 写法 | 为什么这么写 |
|---|---|
FastMCP("WriteServer") | 这个名字会和工具列表一起暴露给客户端。它不是配置文件里的服务名——那个由客户端决定,两者可以不同 |
@mcp.tool() | 把一个普通函数注册成 MCP 工具。函数的类型标注和 docstring 会被自动提取成工具描述,直接影响模型判断用不用它 |
mcp.run(transport='stdio') | 以标准输入输出为传输通道启动,等待客户端的请求。进程会一直阻塞在这里 |
description 是同一件事,只是位置换了。
最短的客户端连接
客户端这边,把服务器拉起来并问它有哪些工具,核心也就这几行:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from contextlib import AsyncExitStack
exit_stack = AsyncExitStack()
# ① 建立连接:把 weather_server.py 作为子进程拉起来
params = StdioServerParameters(command="python", args=["weather_server.py"], env=None)
read_stream, write_stream = await exit_stack.enter_async_context(stdio_client(params))
session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize() # 握手:交换协议版本与能力
# ② 查询工具:拿回这个服务器提供的全部工具
tools_response = await session.list_tools()
# ④ 调用工具:模型给出名字和参数之后,由客户端发起
result = await session.call_tool("query_weather", {"city": "Beijing"})
编号对应 2.1 节的四步,第 ③ 步「生成列表并融入对话」不在这段代码里——它是把 list_tools() 的结果转成模型要的格式,属于客户端自己的加工,4.3 节会展开。
pip install "mcp>=1.6.0" "openai>=1.76.2",加上天气服务器要用的 httpx 和 python-dotenv。另外需要两个 key:调用大模型的 key(本讲用兼容 OpenAI 接口的服务)和 OpenWeather 的 key。两个都写进 .env,代码里一律 os.environ.get(...) 读取。
04完整案例:手写一套天气问答系统
一个 MCP 服务器 + 一个多服务器客户端,从连接到释放全部走一遍
前面讲的是通用方法。这一节动手做:先手动搭建一个 Qwen 客户端,并接入本地的 MCP 工具。要强调一句——以后无论使用哪种 Agent 开发框架,搭建大模型 + MCP 的智能体,本质上都是这个手动实现流程的更高层封装与更便捷的实现形式。所以这一遍手写不是绕远路,是把后面所有框架的底掀开看一次。
4.1 服务器端:把天气接口包成标准插头
服务器端要做的事:基于 OpenWeather 接口实现城市天气查询,并通过 MCP 框架构建成服务工具,供大模型使用。三个函数分工明确:
| 函数 | 职责 | 细节 |
|---|---|---|
fetch_weather(city) | 获取天气数据 | 通过 OpenWeather 接口请求指定城市的实时天气;内置参数配置(单位制 / 语言 / 密钥);处理网络异常和接口错误,返回含 error 的字典 |
format_weather(data) | 数据格式化 | 兼容原始数据或 JSON 字符串输入;提取关键指标(温度 / 湿度 / 风速等);自动处理接口返回的错误信息 |
query_weather(city) | 服务入口 | 通过 @mcp.tool() 注册为 MCP 服务工具;协调数据获取与格式化;返回最终用户可读的天气报告 |
"""一个最小可用的 MCP 服务器:查城市实时天气。
三个函数分工明确:
fetch_weather(city) —— 发 HTTP 请求,拿原始 JSON
format_weather(data) —— 把 JSON 揉成人读得懂的一段话
query_weather(city) —— 用 @mcp.tool() 注册出去,串起上面两步
API key 走环境变量,源码里一个字符都不留:
export OPENWEATHER_API_KEY="你的 key"
依赖:pip install "mcp>=1.6.0" httpx python-dotenv
运行:python weather_server.py # 以 stdio 方式等待客户端连接
"""
import json
import os
from typing import Any
import httpx
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
# 服务器名字会随工具列表一起暴露给客户端
mcp = FastMCP("WeatherServer")
load_dotenv()
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
USER_AGENT = "weather-app/1.0"
def _api_key() -> str:
"""每次调用时现取 key,取不到就直接报错,绝不带着空 key 去发请求。"""
key = os.environ.get("OPENWEATHER_API_KEY")
if not key:
raise RuntimeError("未找到 OPENWEATHER_API_KEY,请在 .env 或环境变量中配置")
return key
async def fetch_weather(city: str) -> dict[str, Any]:
"""异步请求 OpenWeather,返回原始字典;出错时返回带 error 键的字典。"""
params = {
"q": city, # 城市英文名,例如 Beijing
"appid": _api_key(),
"units": "metric", # 公制,温度用摄氏度
"lang": "zh_cn", # 天气描述返回中文
}
headers = {"User-Agent": USER_AGENT}
async with httpx.AsyncClient() as client:
try:
response = await client.get(
OPENWEATHER_API_BASE,
params=params,
headers=headers,
timeout=30.0,
)
# 非 2xx 直接抛出,交给下面的 except 收口
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
return {"error": f"HTTP 错误: {e.response.status_code}"}
except Exception as e: # 网络超时、DNS 失败等
return {"error": f"请求失败: {e}"}
def format_weather(data: dict[str, Any] | str) -> str:
"""把原始数据格式化成一段可读文本;字段缺失一律给默认值,不抛 KeyError。"""
if isinstance(data, str):
try:
data = json.loads(data)
except Exception as e:
return f"无法解析天气数据: {e}"
# 上游返回的错误原样转述,让模型知道这次查询失败了
if "error" in data:
return f"查询失败:{data['error']}"
# 全部用 .get() 逐层取,任何一层缺失都退化成默认值
city = data.get("name", "未知")
country = data.get("sys", {}).get("country", "未知")
temp = data.get("main", {}).get("temp", "N/A")
humidity = data.get("main", {}).get("humidity", "N/A")
wind_speed = data.get("wind", {}).get("speed", "N/A")
weather_list = data.get("weather", [{}])
description = weather_list[0].get("description", "未知")
return (
f"{city}, {country}\n"
f"温度: {temp}°C\n"
f"湿度: {humidity}%\n"
f"风速: {wind_speed} m/s\n"
f"天气: {description}\n"
)
@mcp.tool()
async def query_weather(city: str) -> str:
"""查询指定城市的实时天气。
:param city: 城市英文名称,例如 Beijing、Shanghai
:return: 格式化后的天气信息
"""
# 这段 docstring 会被 MCP 转成工具描述交给模型,写清楚用途和参数
data = await fetch_weather(city)
return format_weather(data)
if __name__ == "__main__":
# stdio:客户端把本进程作为子进程拉起,用标准输入输出通信
mcp.run(transport="stdio")
逐段看它在防什么
这段代码真正的信息量不在「怎么发请求」,而在每一处容错都在防一个具体的崩法:
| 写法 | 防的是什么 | 不这么写会怎样 |
|---|---|---|
_api_key() 里取不到就抛错 | key 缺失 | 带着 None 去发请求,拿回 401,还以为是网络问题 |
timeout=30.0 | 请求悬挂 | 上游不响应时,整个 MCP 服务器卡死,客户端也跟着挂 |
raise_for_status() + 两个 except | HTTP 错误与未知异常 | 异常直接穿透到 MCP 框架,工具调用失败且没有可读原因 |
出错返回 {"error": ...} 而不是抛出 | 错误信息丢失 | 模型只知道「调用失败」,没法把原因转述给用户 |
format_weather 先查 error 键 | 拿错误字典当正常数据解析 | 下面一串 .get() 全取到默认值,输出一条「未知 未知 N/A」的假天气 |
全程 .get() 逐层取值 | KeyError | 接口返回结构有一点变化(某个城市没有风速字段),服务器直接崩 |
data.get("weather", [{}]) | 空数组下标越界 | weather_list[0] 抛 IndexError |
API_KEY = "XXXXX" 这种写法——这是反面教材,一个字都不要抄。它的问题不只是「这次是占位符」,而是这个位置一旦存在,真实 key 迟早会被填进去,然后随代码一起提交。本讲的写法是
os.environ.get("OPENWEATHER_API_KEY"),取不到直接抛错;.env 写进 .gitignore,仓库里只留 .env.template(只有变量名,没有值)。
mcp.run(transport='stdio') 把标准输入输出整条占用作为协议通道。这时候在服务器里写一句 print("debug"),就等于往协议流里插了一段垃圾,客户端会解析失败、连接直接断。要调试就写文件,或者输出到标准错误。
4.2 接入配置文件:配电箱的接线清单
MCP 服务器的标准接入流程是通过写入一个配置文件来完成的,格式很固定:
{
"mcpServers": {
"服务名称": { // 自定义服务标识,如 filesystem / git
"command": "执行命令",
"args": ["服务模块名", // 如 @modelcontextprotocol/server-filesystem
"--flag", // 命令行标志
"参数值"] // 如 --repository 后的路径
}
}
}
三个典型例子放在一起看,就明白这份清单在描述什么了——「用什么命令、带什么参数,把这个服务器进程拉起来」:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["weather_server.py"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "TODO-允许访问的目录"]
},
"git": {
"command": "uvx",
"args": ["mcp-server-git", "--repository", "TODO-本地仓库路径"]
}
}
}
| 服务名 | command | 说明 |
|---|---|---|
weather | python | 本讲手写的服务器,参数就是脚本文件名 |
filesystem | npx | 官方预构建的文件系统服务器,-y 免确认安装,最后一个参数是允许访问的目录 |
git | uvx | git 服务器,--repository 后跟本地仓库路径 |
本讲最初只挂一个天气服务器,配置就是最短的那一段:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["weather_server.py"]
}
}
}
服务名_工具名,所以上面这份配置挂出来的工具叫 weather_query_weather。这是为了避免不同服务器提供同名工具时打架——两个服务器都有 search 很正常,加上前缀就分得开了。
4.3 客户端(一):配置加载与工具封装
客户端系统的主要功能有四条:管理多个 MCP 服务器连接;将 MCP 工具无缝接入 OpenAI 的 Function Calling 体系,实现自然语言驱动的自动化操作;将 MCP 返回的工具信息封装为 Tool 对象,生成 LLM 可理解的描述;实现交互式聊天循环。
"""MCP 客户端:连多个 MCP 服务器,把它们的工具交给大模型,用 Function Call 调起来。
四个类各管一段:
Configuration —— 读 .env 与 servers_config.json
Server —— 单个 MCP 服务器的全生命周期
Tool —— 把 MCP 工具描述转成模型读得懂的格式
MultiServerMCPClient —— 汇总所有工具、跑对话循环
凭据全部来自环境变量,源码里不出现任何 key:
LLM_API_KEY / BASE_URL / MODEL
依赖:pip install "mcp>=1.6.0" "openai>=1.76.2" python-dotenv
运行:python mcp_client.py
"""
import asyncio
import json
import logging
import os
from contextlib import AsyncExitStack
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
from openai import OpenAI
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
class Configuration:
"""管理环境变量与服务器配置文件。"""
def __init__(self) -> None:
load_dotenv()
self.api_key = os.environ.get("LLM_API_KEY")
self.base_url = os.environ.get("BASE_URL")
self.model = os.environ.get("MODEL")
# 缺 key 直接拦在启动阶段,别等到第一次请求才 401
if not self.api_key:
raise ValueError("未找到 LLM_API_KEY,请在 .env 文件中配置")
@staticmethod
def load_config(file_path: str) -> Dict[str, Any]:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
class Tool:
"""封装 MCP 返回的工具信息,并生成给模型看的描述。"""
def __init__(self, name: str, description: str,
input_schema: Dict[str, Any]) -> None:
self.name = name
self.description = description
self.input_schema = input_schema
def format_for_llm(self) -> str:
args_desc = []
for param_name, param_info in self.input_schema.get("properties", {}).items():
line = f"- {param_name}: {param_info.get('description', 'No description')}"
if param_name in self.input_schema.get("required", []):
line += " (required)"
args_desc.append(line)
joined = "\n".join(args_desc)
return f"Tool: {self.name}\nDescription: {self.description}\nArguments:\n{joined}"
class Server:
"""管理单个 MCP 服务器连接与工具调用。"""
def __init__(self, name: str, config: Dict[str, Any]) -> None:
self.name = name
self.config = config
self.session: Optional[ClientSession] = None
# 所有异步资源压进同一个栈,退出时按相反顺序释放
self.exit_stack = AsyncExitStack()
# 防止并发清理同一批资源
self._cleanup_lock = asyncio.Lock()
async def initialize(self) -> None:
command = self.config["command"]
if not command:
raise ValueError("command 不能为空")
server_params = StdioServerParameters(
command=command,
args=self.config["args"],
# 自定义 env 叠在系统环境变量之上,子进程才读得到 API key
env={**os.environ, **self.config["env"]} if self.config.get("env") else None,
)
try:
read_stream, write_stream = await self.exit_stack.enter_async_context(
stdio_client(server_params))
session = await self.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream))
# 握手:交换协议版本与能力
await session.initialize()
self.session = session
except Exception as e:
logging.error("初始化服务器 %s 失败: %s", self.name, e)
await self.cleanup()
raise
async def list_tools(self) -> List[Tool]:
if not self.session:
raise RuntimeError(f"服务器 {self.name} 尚未初始化")
tools_response = await self.session.list_tools()
tools: List[Tool] = []
# 返回的是若干 (字段名, 值) 元组,只取 tools 那一条
for item in tools_response:
if isinstance(item, tuple) and item[0] == "tools":
for tool in item[1]:
tools.append(Tool(tool.name, tool.description, tool.inputSchema))
return tools
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any],
retries: int = 2, delay: float = 1.0) -> Any:
"""执行工具,失败自动重试,重试耗尽才抛出。"""
if not self.session:
raise RuntimeError(f"服务器 {self.name} 尚未初始化")
attempt = 0
while attempt < retries:
try:
logging.info("在 %s 上执行 %s ...", self.name, tool_name)
return await self.session.call_tool(tool_name, arguments)
except Exception as e:
attempt += 1
logging.warning("执行工具出错: %s(第 %d/%d 次)", e, attempt, retries)
if attempt < retries:
await asyncio.sleep(delay)
else:
logging.error("重试次数用尽")
raise
async def cleanup(self) -> None:
async with self._cleanup_lock:
try:
await self.exit_stack.aclose()
self.session = None
except Exception as e:
logging.error("清理服务器 %s 时出错: %s", self.name, e)
class MultiServerMCPClient:
"""连接多个 MCP 服务器,汇总工具,跑对话循环。"""
def __init__(self) -> None:
config = Configuration()
self.client = OpenAI(api_key=config.api_key, base_url=config.base_url)
self.model = config.model
self.servers: Dict[str, Server] = {}
self.all_tools: List[Dict[str, Any]] = []
async def connect_to_servers(self, servers_config: Dict[str, Any]) -> None:
for server_name, srv_config in servers_config.get("mcpServers", {}).items():
server = Server(server_name, srv_config)
await server.initialize()
self.servers[server_name] = server
for tool in await server.list_tools():
# 统一重命名成 服务名_工具名,避免不同服务器的同名工具打架
self.all_tools.append({
"type": "function",
"function": {
"name": f"{server_name}_{tool.name}",
"description": tool.description,
"parameters": {
"type": tool.input_schema.get("type", "object"),
"properties": tool.input_schema.get("properties", {}),
"required": tool.input_schema.get("required", []),
},
},
})
logging.info("已连接服务器: %s", ", ".join(self.servers) or "无")
logging.info("可用工具: %s",
", ".join(t["function"]["name"] for t in self.all_tools) or "无")
async def _call_mcp_tool(self, full_name: str, args: Dict[str, Any]) -> str:
server_name, _, tool_name = full_name.partition("_")
server = self.servers.get(server_name)
if not server:
return f"找不到服务器: {server_name}"
resp = await server.execute_tool(tool_name, args)
return str(resp.content) if resp.content else "工具执行无输出"
async def chat_base(self, messages: List[Dict[str, Any]]) -> Any:
"""一直循环到模型不再要求调用工具为止。"""
response = self.client.chat.completions.create(
model=self.model, messages=messages, tools=self.all_tools)
while response.choices[0].finish_reason == "tool_calls":
# 把这轮 assistant 消息原样回填,再逐个补上工具回执
messages.append(response.choices[0].message.model_dump())
for call in response.choices[0].message.tool_calls:
result = await self._call_mcp_tool(
call.function.name, json.loads(call.function.arguments))
messages.append({
"role": "tool",
"content": result,
"tool_call_id": call.id,
})
response = self.client.chat.completions.create(
model=self.model, messages=messages, tools=self.all_tools)
return response
async def chat_loop(self) -> None:
print("MCP 客户端已启动,输入 quit 退出。")
messages: List[Dict[str, Any]] = []
while True:
query = input("\n你: ").strip()
if query.lower() == "quit":
break
try:
messages.append({"role": "user", "content": query})
messages = messages[-20:] # 只留最近 20 条,防止上下文撑爆
response = await self.chat_base(messages)
messages.append(response.choices[0].message.model_dump())
print(f"\nAI: {response.choices[0].message.content}")
except Exception as e:
print(f"\n调用过程出错: {e}")
async def cleanup(self) -> None:
for server in self.servers.values():
await server.cleanup()
async def main() -> None:
config = Configuration()
servers_config = config.load_config("servers_config.json")
client = MultiServerMCPClient()
try:
await client.connect_to_servers(servers_config)
await client.chat_loop()
finally:
# 给子进程一点时间收尾,再统一释放
await asyncio.sleep(0.1)
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main())
Configuration:把配置和凭据集中到一处
它做三件事:环境变量管理(从 .env 加载敏感配置)、配置校验(强制校验必需参数的存在性)、文件配置加载(读取 JSON 格式的服务器配置文件)。
关键是这一句:
if not self.api_key:
raise ValueError("未找到 LLM_API_KEY,请在 .env 文件中配置")
缺 key 就在启动阶段直接拦住,而不是等到第一次请求模型时拿一个 401 再去猜。三个环境变量各管一段:LLM_API_KEY 是模型服务的凭据,BASE_URL 是接口地址(指向兼容 OpenAI 协议的服务即可),MODEL 是模型名。
Tool:把工具信息翻译成模型读得懂的话
Tool 类用于标准化封装 MCP 工具的描述信息,并生成适合大语言模型理解的提示文本,实现自然语言到工具调用的桥梁。format_for_llm() 产出的文本长这样:
Tool: query_weather
Description: 查询指定城市的实时天气。
Arguments:
- city: 城市英文名称,例如 Beijing、Shanghai (required)
注意 (required) 这个后缀——它来自 input_schema 里的 required 数组。这就是 2.4 节说的「工具描述不用手写」:服务器端函数的类型标注和 docstring,经由协议传过来,最后自动变成模型眼前的这段文字。
4.4 客户端(二):Server 的全生命周期
Server 类管理单个 MCP 服务器的全生命周期,包括连接初始化、工具发现、工具调用和资源清理。它是与 MCP 服务器交互的核心代理,支持异步操作和错误恢复。

| 方法 | 功能 | 做了什么 |
|---|---|---|
__init__ | 初始化服务器实例 | 存储服务器名称与配置字典;初始化异步资源栈 AsyncExitStack 管理连接;创建清理锁 asyncio.Lock 防止并发资源释放冲突 |
initialize | 建立与服务器的连接 | 从配置中解析启动命令和参数;合并系统环境变量与自定义环境变量;通过 stdio_client 创建标准输入输出通道;初始化 ClientSession 会话对象 |
list_tools | 获取工具列表 | 解析服务器返回的元组结构 ("tools", [tool1, tool2...]);将每个工具封装为 Tool 对象(含名称、描述、输入模式) |
execute_tool | 执行工具调用并支持自动重试 | retries=2 最大重试次数,delay=1.0 重试间隔秒数;重试耗尽才抛出 |
cleanup | 安全释放所有连接资源 | 使用 _cleanup_lock 锁防止并发清理;通过 AsyncExitStack.aclose() 关闭所有异步上下文 |
三处工程细节,每一处都对应一个真实事故
env={**os.environ, **self.config["env"]}。子进程默认继承不到你在配置里单独写的变量,而只传自定义变量又会把 PATH 之类的系统变量全丢掉,导致 command 根本找不到。两者必须合并。
initialize 的 except 里先 await self.cleanup() 再 raise。连接建了一半失败,子进程可能已经起来了——不清理就会留下一个孤儿进程,反复调试几次机器上全是僵尸。
异步场景里 cleanup 可能被主动调用一次、又被异常路径调用一次。AsyncExitStack 重复关闭会抛错,asyncio.Lock 保证同一时刻只有一个清理在跑。
retries=2 就是「接触不良再插一次」
工具调用失败未必是工具坏了——网络抖动、上游限流、子进程刚起来还没就绪,都会导致偶发失败。重试两次、间隔一秒,能挡掉相当一部分。但重试不能无限:真的坏了就要让错误浮上来,而不是让用户等着。
4.5 客户端(三):多轮工具调用怎么转起来
MultiServerMCPClient 是系统的核心控制器:管理多个 MCP 服务器,整合它们的工具,并通过自然语言交互驱动工具调用。它把工具格式转换为 OpenAI 兼容格式,维护对话上下文,处理多轮工具调用。
连接阶段:把所有服务器的工具汇成一张表
connect_to_servers 遍历配置里的每个服务器,逐个 initialize → list_tools,然后把工具重命名为 服务名_工具名,并把 MCP 的 input_schema 转换成 OpenAI Function Calling 所需的 parameters 格式:
"parameters": {
"type": tool.input_schema.get("type", "object"),
"properties": tool.input_schema.get("properties", {}),
"required": tool.input_schema.get("required", []),
}
这一步就是 2.1 节的第 ③ 步——生成列表并融入当前对话。转换完成后,self.all_tools 这张表就可以直接作为 tools 参数传给模型了。
对话阶段:循环到模型不再要求调工具
chat_base 的逻辑只有一句话:如果返回的 finish_reason 为 tool_calls,就执行工具、回填结果、再发一次请求,直到不是为止。
回填的两条消息一条都不能少、顺序也不能反:
messages.append(response.choices[0].message.model_dump()) # 先把 assistant 原样回填
for call in response.choices[0].message.tool_calls:
result = await self._call_mcp_tool(
call.function.name, json.loads(call.function.arguments))
messages.append({
"role": "tool",
"content": result,
"tool_call_id": call.id, # 靠它认领「这是哪张委托单的回执」
})
看到这里应该很明确了:这就是 Function Call,一行都没变。 json.loads 解析字符串形式的参数、tool_call_id 配对、assistant 在前 tool 在后——MCP 唯一插手的地方是 _call_mcp_tool:它按 服务名_工具名 把请求路由到对应的 Server,其余全是原来那一套。
聊天循环与退出
chat_loop 读用户输入、调 chat_base、打印回答,输入 quit 退出。中间有一行容易被忽略:
messages = messages[-20:] # 保持最新 20 条上下文
只保留最近 20 条消息,防止长对话把上下文撑爆。注意这是个朴素策略——它可能把一条 assistant 的 tool_calls 和它对应的 tool 回执从中间截断,真实项目里要按「成对保留」的规则裁剪。
主函数负责协调整个系统的启动、运行和关闭:加载配置 → 初始化客户端 → 连接服务器并收集工具 → 启动交互循环 → 清理资源,最后一步放在 finally 里,无论成功与否都关闭连接。
4.6 再挂一个服务器:改配置就够了
现在验证 2.4 节那句话。新写一个文件写入服务器,逻辑极简——接收文本内容并写入本地文件:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("WriteServer")
@mcp.tool()
async def write_file(content: str) -> str:
"""将指定内容写入本地文件。
:param content: 必要参数,字符串类型,用于表示需要写入文档的具体内容。
:return: 是否成功写入
"""
try:
with open("tmp.txt", "w", encoding="utf-8") as file:
file.write(content)
return "已成功写入本地文件。"
except Exception:
return "未成功写入."
if __name__ == "__main__":
mcp.run(transport='stdio')
然后在配置文件里加一段:
{
"mcpServers": {
"weather": { "command": "python", "args": ["weather_server.py"] },
"write": { "command": "python", "args": ["write_server.py"] }
}
}
weather_query_weather 与 write_write_file 两个工具交给模型。这时候说「今天北京天气怎么样,把结果存下来」,模型会连着调两次工具——先查天气,再写文件,中间由 chat_base 的循环自动串起来。这就是插座统一规格之后的日常:新买一件电器,插上就能用,不用重新布线。
05骨架模板:四个文件起一套 MCP 应用
改完 TODO 就能跑,凭据一律走环境变量
| 文件 | 角色 | 改哪里 |
|---|---|---|
weather_server.py | MCP 服务器 | 换成你自己的工具函数;密钥读环境变量,不要动成字面量 |
mcp_client.py | MCP 客户端 | 不用改,行为全部由 .env 与配置文件决定 |
servers_config.template.json | 接线清单 | 删掉用不上的服务,补上 TODO 路径 |
.env.template | 凭据模板 | 复制成 .env 后填值;.env 必须进 .gitignore |
5.1 服务器模板
换工具时只动三处:FastMCP("...") 的名字、@mcp.tool() 下面的函数、以及函数的 docstring。docstring 决定模型认不认得这件工具,别省。
"""一个最小可用的 MCP 服务器:查城市实时天气。
三个函数分工明确:
fetch_weather(city) —— 发 HTTP 请求,拿原始 JSON
format_weather(data) —— 把 JSON 揉成人读得懂的一段话
query_weather(city) —— 用 @mcp.tool() 注册出去,串起上面两步
API key 走环境变量,源码里一个字符都不留:
export OPENWEATHER_API_KEY="你的 key"
依赖:pip install "mcp>=1.6.0" httpx python-dotenv
运行:python weather_server.py # 以 stdio 方式等待客户端连接
"""
import json
import os
from typing import Any
import httpx
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
# 服务器名字会随工具列表一起暴露给客户端
mcp = FastMCP("WeatherServer")
load_dotenv()
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
USER_AGENT = "weather-app/1.0"
def _api_key() -> str:
"""每次调用时现取 key,取不到就直接报错,绝不带着空 key 去发请求。"""
key = os.environ.get("OPENWEATHER_API_KEY")
if not key:
raise RuntimeError("未找到 OPENWEATHER_API_KEY,请在 .env 或环境变量中配置")
return key
async def fetch_weather(city: str) -> dict[str, Any]:
"""异步请求 OpenWeather,返回原始字典;出错时返回带 error 键的字典。"""
params = {
"q": city, # 城市英文名,例如 Beijing
"appid": _api_key(),
"units": "metric", # 公制,温度用摄氏度
"lang": "zh_cn", # 天气描述返回中文
}
headers = {"User-Agent": USER_AGENT}
async with httpx.AsyncClient() as client:
try:
response = await client.get(
OPENWEATHER_API_BASE,
params=params,
headers=headers,
timeout=30.0,
)
# 非 2xx 直接抛出,交给下面的 except 收口
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
return {"error": f"HTTP 错误: {e.response.status_code}"}
except Exception as e: # 网络超时、DNS 失败等
return {"error": f"请求失败: {e}"}
def format_weather(data: dict[str, Any] | str) -> str:
"""把原始数据格式化成一段可读文本;字段缺失一律给默认值,不抛 KeyError。"""
if isinstance(data, str):
try:
data = json.loads(data)
except Exception as e:
return f"无法解析天气数据: {e}"
# 上游返回的错误原样转述,让模型知道这次查询失败了
if "error" in data:
return f"查询失败:{data['error']}"
# 全部用 .get() 逐层取,任何一层缺失都退化成默认值
city = data.get("name", "未知")
country = data.get("sys", {}).get("country", "未知")
temp = data.get("main", {}).get("temp", "N/A")
humidity = data.get("main", {}).get("humidity", "N/A")
wind_speed = data.get("wind", {}).get("speed", "N/A")
weather_list = data.get("weather", [{}])
description = weather_list[0].get("description", "未知")
return (
f"{city}, {country}\n"
f"温度: {temp}°C\n"
f"湿度: {humidity}%\n"
f"风速: {wind_speed} m/s\n"
f"天气: {description}\n"
)
@mcp.tool()
async def query_weather(city: str) -> str:
"""查询指定城市的实时天气。
:param city: 城市英文名称,例如 Beijing、Shanghai
:return: 格式化后的天气信息
"""
# 这段 docstring 会被 MCP 转成工具描述交给模型,写清楚用途和参数
data = await fetch_weather(city)
return format_weather(data)
if __name__ == "__main__":
# stdio:客户端把本进程作为子进程拉起,用标准输入输出通信
mcp.run(transport="stdio")
5.2 客户端模板
这一份不需要改。它已经支持任意数量的服务器、自动多轮工具调用、失败重试和资源清理——加工具是改配置文件的事。
"""MCP 客户端:连多个 MCP 服务器,把它们的工具交给大模型,用 Function Call 调起来。
四个类各管一段:
Configuration —— 读 .env 与 servers_config.json
Server —— 单个 MCP 服务器的全生命周期
Tool —— 把 MCP 工具描述转成模型读得懂的格式
MultiServerMCPClient —— 汇总所有工具、跑对话循环
凭据全部来自环境变量,源码里不出现任何 key:
LLM_API_KEY / BASE_URL / MODEL
依赖:pip install "mcp>=1.6.0" "openai>=1.76.2" python-dotenv
运行:python mcp_client.py
"""
import asyncio
import json
import logging
import os
from contextlib import AsyncExitStack
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
from openai import OpenAI
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
class Configuration:
"""管理环境变量与服务器配置文件。"""
def __init__(self) -> None:
load_dotenv()
self.api_key = os.environ.get("LLM_API_KEY")
self.base_url = os.environ.get("BASE_URL")
self.model = os.environ.get("MODEL")
# 缺 key 直接拦在启动阶段,别等到第一次请求才 401
if not self.api_key:
raise ValueError("未找到 LLM_API_KEY,请在 .env 文件中配置")
@staticmethod
def load_config(file_path: str) -> Dict[str, Any]:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
class Tool:
"""封装 MCP 返回的工具信息,并生成给模型看的描述。"""
def __init__(self, name: str, description: str,
input_schema: Dict[str, Any]) -> None:
self.name = name
self.description = description
self.input_schema = input_schema
def format_for_llm(self) -> str:
args_desc = []
for param_name, param_info in self.input_schema.get("properties", {}).items():
line = f"- {param_name}: {param_info.get('description', 'No description')}"
if param_name in self.input_schema.get("required", []):
line += " (required)"
args_desc.append(line)
joined = "\n".join(args_desc)
return f"Tool: {self.name}\nDescription: {self.description}\nArguments:\n{joined}"
class Server:
"""管理单个 MCP 服务器连接与工具调用。"""
def __init__(self, name: str, config: Dict[str, Any]) -> None:
self.name = name
self.config = config
self.session: Optional[ClientSession] = None
# 所有异步资源压进同一个栈,退出时按相反顺序释放
self.exit_stack = AsyncExitStack()
# 防止并发清理同一批资源
self._cleanup_lock = asyncio.Lock()
async def initialize(self) -> None:
command = self.config["command"]
if not command:
raise ValueError("command 不能为空")
server_params = StdioServerParameters(
command=command,
args=self.config["args"],
# 自定义 env 叠在系统环境变量之上,子进程才读得到 API key
env={**os.environ, **self.config["env"]} if self.config.get("env") else None,
)
try:
read_stream, write_stream = await self.exit_stack.enter_async_context(
stdio_client(server_params))
session = await self.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream))
# 握手:交换协议版本与能力
await session.initialize()
self.session = session
except Exception as e:
logging.error("初始化服务器 %s 失败: %s", self.name, e)
await self.cleanup()
raise
async def list_tools(self) -> List[Tool]:
if not self.session:
raise RuntimeError(f"服务器 {self.name} 尚未初始化")
tools_response = await self.session.list_tools()
tools: List[Tool] = []
# 返回的是若干 (字段名, 值) 元组,只取 tools 那一条
for item in tools_response:
if isinstance(item, tuple) and item[0] == "tools":
for tool in item[1]:
tools.append(Tool(tool.name, tool.description, tool.inputSchema))
return tools
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any],
retries: int = 2, delay: float = 1.0) -> Any:
"""执行工具,失败自动重试,重试耗尽才抛出。"""
if not self.session:
raise RuntimeError(f"服务器 {self.name} 尚未初始化")
attempt = 0
while attempt < retries:
try:
logging.info("在 %s 上执行 %s ...", self.name, tool_name)
return await self.session.call_tool(tool_name, arguments)
except Exception as e:
attempt += 1
logging.warning("执行工具出错: %s(第 %d/%d 次)", e, attempt, retries)
if attempt < retries:
await asyncio.sleep(delay)
else:
logging.error("重试次数用尽")
raise
async def cleanup(self) -> None:
async with self._cleanup_lock:
try:
await self.exit_stack.aclose()
self.session = None
except Exception as e:
logging.error("清理服务器 %s 时出错: %s", self.name, e)
class MultiServerMCPClient:
"""连接多个 MCP 服务器,汇总工具,跑对话循环。"""
def __init__(self) -> None:
config = Configuration()
self.client = OpenAI(api_key=config.api_key, base_url=config.base_url)
self.model = config.model
self.servers: Dict[str, Server] = {}
self.all_tools: List[Dict[str, Any]] = []
async def connect_to_servers(self, servers_config: Dict[str, Any]) -> None:
for server_name, srv_config in servers_config.get("mcpServers", {}).items():
server = Server(server_name, srv_config)
await server.initialize()
self.servers[server_name] = server
for tool in await server.list_tools():
# 统一重命名成 服务名_工具名,避免不同服务器的同名工具打架
self.all_tools.append({
"type": "function",
"function": {
"name": f"{server_name}_{tool.name}",
"description": tool.description,
"parameters": {
"type": tool.input_schema.get("type", "object"),
"properties": tool.input_schema.get("properties", {}),
"required": tool.input_schema.get("required", []),
},
},
})
logging.info("已连接服务器: %s", ", ".join(self.servers) or "无")
logging.info("可用工具: %s",
", ".join(t["function"]["name"] for t in self.all_tools) or "无")
async def _call_mcp_tool(self, full_name: str, args: Dict[str, Any]) -> str:
server_name, _, tool_name = full_name.partition("_")
server = self.servers.get(server_name)
if not server:
return f"找不到服务器: {server_name}"
resp = await server.execute_tool(tool_name, args)
return str(resp.content) if resp.content else "工具执行无输出"
async def chat_base(self, messages: List[Dict[str, Any]]) -> Any:
"""一直循环到模型不再要求调用工具为止。"""
response = self.client.chat.completions.create(
model=self.model, messages=messages, tools=self.all_tools)
while response.choices[0].finish_reason == "tool_calls":
# 把这轮 assistant 消息原样回填,再逐个补上工具回执
messages.append(response.choices[0].message.model_dump())
for call in response.choices[0].message.tool_calls:
result = await self._call_mcp_tool(
call.function.name, json.loads(call.function.arguments))
messages.append({
"role": "tool",
"content": result,
"tool_call_id": call.id,
})
response = self.client.chat.completions.create(
model=self.model, messages=messages, tools=self.all_tools)
return response
async def chat_loop(self) -> None:
print("MCP 客户端已启动,输入 quit 退出。")
messages: List[Dict[str, Any]] = []
while True:
query = input("\n你: ").strip()
if query.lower() == "quit":
break
try:
messages.append({"role": "user", "content": query})
messages = messages[-20:] # 只留最近 20 条,防止上下文撑爆
response = await self.chat_base(messages)
messages.append(response.choices[0].message.model_dump())
print(f"\nAI: {response.choices[0].message.content}")
except Exception as e:
print(f"\n调用过程出错: {e}")
async def cleanup(self) -> None:
for server in self.servers.values():
await server.cleanup()
async def main() -> None:
config = Configuration()
servers_config = config.load_config("servers_config.json")
client = MultiServerMCPClient()
try:
await client.connect_to_servers(servers_config)
await client.chat_loop()
finally:
# 给子进程一点时间收尾,再统一释放
await asyncio.sleep(0.1)
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main())
5.3 接线清单模板
三个例子涵盖了三种拉起方式:本地 Python 脚本、npx 拉起的 Node 包、uvx 拉起的 Python 包。用不上的直接删。
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["weather_server.py"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "TODO-允许访问的目录"]
},
"git": {
"command": "uvx",
"args": ["mcp-server-git", "--repository", "TODO-本地仓库路径"]
}
}
}
5.4 凭据模板
只写变量名,一个值都不写。这份文件是可以提交进仓库的,真正的 .env 不行。
# 复制为 .env 后填值,.env 必须加进 .gitignore
# 大模型服务的 API key
LLM_API_KEY=
# 大模型服务的兼容 OpenAI 接口地址
BASE_URL=
# 使用的模型名
MODEL=
# OpenWeather 的 API key,weather_server.py 读它
OPENWEATHER_API_KEY=
| 变量 | 被谁读 | 说明 |
|---|---|---|
LLM_API_KEY | mcp_client.py | 大模型服务的凭据,缺失时客户端启动阶段就报错 |
BASE_URL | mcp_client.py | 兼容 OpenAI 协议的接口地址 |
MODEL | mcp_client.py | 模型名 |
OPENWEATHER_API_KEY | weather_server.py | 天气接口凭据;服务器是子进程,所以客户端传 env 时必须合并系统环境变量 |
cp .env.template .env 填好四个值 → 按需裁剪 servers_config.json → python mcp_client.py。不用单独去启动服务器——客户端会照着配置把它们作为子进程拉起来。
06易错点汇总
按「概念 / 服务器 / 配置 / 客户端 / 凭据」五类归并,每条给现象和修法
⚠️ 一、概念层面
- 认为「有了 MCP 就不用 Function Call 了」。 抓包分析表明,MCP 的底层实现机制本质上仍是依托于大模型原生自带的 Function Call 能力,MCP 只是在此基础上做了更高层次的封装与优化。插头统一了,电还是从那根火线来。
- 把服务器和客户端的角色搞反。 MCP 体系里外部工具叫服务器,接入这些工具的大模型运行环境叫客户端。哪怕那个「服务器」只是本机上一个几十行的脚本,它也是服务器。
- 以为一个客户端只能接一个服务器。 一个客户端可以接入多个不同类型的服务器,唯一要求是都遵循 MCP 通信协议。本讲 4.6 节就挂了两个。
- 把 MCP 服务器的输出当普通 JSON 到处传。 MCP 服务器的输出内容是一种标准格式的内容,只能被 MCP 客户端所识别,不要指望绕过客户端直接消费。
- 记错时间和出处。 是 2024 年 11 月底由 Anthropic 推出的开放标准,不是 OpenAI,也不是 2023 年。
- 把 Function Call、MCP、Agent 当成三选一。 三者是叠起来的:Function Call 是模型调函数的机制,MCP 是让大模型与 API 无缝交互的标准协议,Agent 是利用二者分析并执行任务的自主系统。
⚠️ 二、服务器端
- 在 stdio 模式的服务器里
print()调试信息。 现象:客户端连接后立刻解析失败或断开。原因:transport='stdio'把标准输入输出整条占用作为协议通道,往里插任何文本都是污染。修法:调试信息写文件,或输出到标准错误。 - docstring 随手写或干脆不写。 现象:模型该调工具时不调,或者参数填错。原因:工具名、描述、参数说明是模型唯一的判断依据,它看不到函数体。修法:把用途、每个参数的类型和含义、返回什么写清楚。
- 直接用
data["main"]["temp"]取值。 现象:某些城市或异常响应下抛KeyError,整个服务器崩。修法:全程.get()逐层取,并给默认值;数组要写成data.get("weather", [{}])防下标越界。 - 拿到含
error的字典还当正常数据解析。 现象:输出一条「未知 未知 N/A」的假天气,用户完全看不出查询失败了。修法:format_weather开头先判error键,有就直接把错误转述出去。 - 请求不设超时。 现象:上游不响应时服务器卡死,客户端跟着挂。修法:
timeout=30.0。 - 异常直接往外抛。 现象:工具调用失败但没有可读原因,模型只能说「查询失败了」。修法:捕获后返回
{"error": "..."},让错误信息成为可以转述的内容。
⚠️ 三、配置文件
command在子进程环境里找不到。 现象:初始化报找不到命令。原因:传env时只传了自定义变量,把PATH等系统变量全丢了。修法:env={**os.environ, **self.config["env"]},合并而不是替换。args里的相对路径找不到文件。 现象:子进程起来就退出。原因:"args": ["weather_server.py"]是相对于客户端启动时的工作目录解析的。修法:在脚本所在目录启动,或改成绝对路径。- 服务名里带下划线。 现象:工具路由到不存在的服务器。原因:客户端把工具重命名成
服务名_工具名,再按第一个下划线切回去。服务名自带下划线会把切分点带偏。修法:服务名别用下划线。 - 两个服务器提供同名工具就以为会冲突。 其实不会——统一重命名成
服务名_工具名就是为了这个。反过来,别自己去掉前缀,去掉就真冲突了。 - JSON 里写注释。 现象:
json.load直接报错。原因:标准 JSON 不支持//注释——格式说明里那些注释只是讲解用的。修法:真实配置文件里删干净。
⚠️ 四、客户端
- 忘了回填 assistant 消息,或颠倒它和 tool 消息的顺序。 现象:接口报错,回执成了孤儿。修法:先
messages.append(assistant 消息),再逐条 appendrole为tool的回执,并带上tool_call_id。这条规则在 MCP 场景里原样成立。 - 把
arguments当字典直接用。 它是字符串形式的 JSON,必须json.loads()。 - 只处理一轮工具调用。 现象:需要连着调两个工具的请求(先查天气再写文件)半途而废。修法:用
while循环到finish_reason不再是tool_calls为止。 messages[-20:]把成对的消息截断了。 现象:长对话中途报「找不到对应的 tool_call」。原因:朴素的按条数裁剪可能把 assistant 的tool_calls留下、对应的tool回执切掉,或者反过来。修法:真实项目按「成对保留」裁剪。- 初始化失败时不清理。 现象:反复调试后机器上一堆孤儿子进程。原因:连接建到一半失败,子进程可能已经起来了。修法:
except里先await self.cleanup()再raise。 cleanup不加锁。 现象:退出时报AsyncExitStack重复关闭的异常。修法:用asyncio.Lock保证同一时刻只有一个清理在跑。- 清理没放在
finally里。 现象:程序异常退出后子进程还活着。修法:try/finally,无论成功与否都关闭连接。 - 重试次数设得过大。 现象:工具真坏了,用户干等半分钟。修法:
retries=2、delay=1.0这个量级足够挡住抖动,再多就该让错误浮上来。
⚠️ 五、凭据与密钥
- 把密钥写成
API_KEY = "XXXXX"这种源码常量。 这是本讲点名的反面写法。问题不在于这次填的是占位符,而在于这个位置一旦存在,真实 key 迟早被填进去并随代码提交。修法:一律os.environ.get("OPENWEATHER_API_KEY"),取不到直接抛错。 .env跟着代码一起提交。 修法:.env写进.gitignore,仓库里只留.env.template(只有变量名,没有值)。- 服务器读不到 key。 现象:客户端环境里明明配好了,服务器仍报缺 key。原因:服务器是子进程,环境变量没传下去。修法:合并系统环境变量后传给
StdioServerParameters,或让服务器自己load_dotenv()读同目录的.env。 - 刚注册完 OpenWeather 就调用,拿到 401 以为代码错了。 API Key 大概需要一小时生效,等等再试。
- 缺 key 时不报错,带着
None发请求。 现象:401 被当成网络问题排查半天。修法:启动阶段就校验,缺了直接抛ValueError——客户端和服务器两边都这么做。
07自测题
点击题目展开答案;这 12 题答得上来,这一讲就通了
MCP 是什么?什么时候、由谁推出?要解决什么问题?
MCP(Model Context Protocol,模型上下文协议),2024 年 11 月底由 Anthropic 推出的一种开放标准,旨在统一大模型与外部数据源和工具之间的通信协议。它要解决的是 AI 模型因数据孤岛限制而无法充分发挥潜力的难题,让 AI 应用能安全地访问和操作本地及远程数据。对开发者而言,它消除了为每个数据源或工具进行定制集成的需要,减少开发时间和维护成本。
官方常用的 USB-C 类比,到底在类比什么?
如同 USB-C 通过统一接口连接多种设备,MCP 旨在为 AI 应用提供一个「即插即用」的上下文管理框架。类比的是接口规格的统一,不是传输速度或能力的提升——统一插头之后,谁插谁都通,不必再为每件设备单配转换头。
在 MCP 体系里,谁是服务器、谁是客户端?一个客户端能接几个服务器?
外部工具称作服务器,接入这些外部工具的大模型运行环境称作客户端。 一个客户端可以接入多个不同类型的服务器,但要求它们都遵循 MCP 通信协议。另外,MCP 服务器的输出内容是一种标准格式的内容,只能被 MCP 客户端所识别。
MCP 和 Function Call 是什么关系?「有了 MCP 就不需要 Function Call 了」对吗?
不对。通过在 MCP 运行过程中进行数据包捕获与分析可知,MCP 的底层实现机制本质上仍是依托于大模型原生自带的 Function Call 能力,以完成对外部工具的调用;MCP 只是在此基础上做了更高层次的封装与优化。所以 tool_calls 的回填、tool_call_id 的配对、多轮调用这些规则在 MCP 场景里原样成立。
Function Call、MCP、Agent 三者各是什么?
Function Calling 是 AI 大模型调用函数的机制;MCP 是一个标准协议,使大模型与 API 无缝交互;AI Agent 是一个自主运行的智能系统,利用 Function Calling 和 MCP 来分析和执行任务,实现特定目标。三者是叠起来的层次关系,不是三选一。
MCP 客户端调用服务器工具的流程是哪四步?Function Call 出现在第几步?
① 建立连接:与 MCP 服务器搭建通信链路;② 查询工具:获取服务器上所有外部工具的数量信息;③ 生成列表:将查询到的外部工具整理成列表,并融入当前对话场景;④ 调用工具:通过 Function Calling 技术调用所需的外部工具。
Function Call 出现在第四步,前三步是协议层的事。
MCP 支持哪两种通信机制?各自适用什么场景?
本地通信:通过 stdio 传输数据,适用于同一台机器上运行的客户端和服务器之间的通信;远程通信:利用 SSE 与 HTTP 结合,实现跨网络的实时数据传输,适用于需要访问远程资源或分布式部署的场景。
MCP 技术生态由哪三层构成?
① MCP 协议:一套抽象的规范集合,涵盖大模型与工具的调度规范、服务器与客户端之间的通信规范等,遵循这些协议标准的对象即被认定为 MCP 服务器或客户端;② MCP 开发工具:多种编程语言版本的 SDK,缩短开发周期;③ MCP 服务器生态:以开源 MCP 服务器为核心构建的庞大生态,可直接取用加速开发。
相比手写 Function Call,走 MCP 之后哪些事变了、哪些没变?
变了:工具描述不用手写(向服务器查询后自动转换)、工具跑在独立进程里与应用解耦、接新工具从改代码变成改配置、别人写的开源工具可以直接挂上用。
没变:产出 tool_calls 的仍然是模型,真正执行调用的仍然是客户端代码,回填规则与多轮流程一模一样。
写一个 MCP 服务器最少需要哪三步?@mcp.tool() 把什么交给了模型?
三步:mcp = FastMCP("服务名") 建实例 → 给函数加 @mcp.tool() 注册 → mcp.run(transport='stdio') 启动。@mcp.tool() 会把函数的名称、类型标注和 docstring 自动提取成工具描述交给模型。模型看不到函数体,docstring 就是它唯一的判断依据,必须写清用途、参数类型与含义。
format_weather 为什么全程用 .get(),又为什么开头要先判 error 键?
用 .get() 逐层取值是为了避免 KeyError:接口返回结构有一点变化(某个城市缺风速字段)就会让服务器直接崩;data.get("weather", [{}]) 还额外防住了空数组下标越界。
开头先判 error 键,是因为 fetch_weather 出错时返回的是 {"error": ...}。不判就会让下面一串 .get() 全取到默认值,输出一条「未知 未知 N/A」的假天气,用户完全看不出查询其实失败了。
servers_config.json 的标准格式是什么?服务名会影响什么?
格式是 mcpServers → 服务名称 → command + args。例如 filesystem 用 npx 拉起 @modelcontextprotocol/server-filesystem,git 用 uvx 拉起 mcp-server-git 并带 --repository,本地天气服务器就是 {"command": "python", "args": ["weather_server.py"]}。
服务名会成为工具名的前缀(服务名_工具名),用来避免不同服务器的同名工具冲突,所以服务名本身不要带下划线。
Server 类的五个方法各做什么?AsyncExitStack 和 asyncio.Lock 分别防什么?
__init__ 初始化实例(存名称与配置,准备资源栈和清理锁);initialize 解析命令与参数、合并系统环境变量与自定义环境变量、通过 stdio_client 建通道、初始化 ClientSession;list_tools 解析 ("tools", [...]) 元组并封装成 Tool 对象;execute_tool 执行调用并支持重试(retries=2、delay=1.0);cleanup 安全释放所有连接资源。AsyncExitStack 统一管理多个异步资源,退出时按相反顺序全部关闭;asyncio.Lock 用来防止并发资源释放冲突——重复关闭会抛异常。
环境变量为什么要写成 {**os.environ, **config["env"]} 而不是只传自定义的?
因为 MCP 服务器是被客户端作为子进程拉起来的。只传自定义变量会把 PATH 之类的系统变量全部丢掉,子进程连 command 指定的命令都找不到。必须合并而不是替换。同理,服务器要用的 OPENWEATHER_API_KEY 也得通过这个合并的环境传下去,或者让服务器自己 load_dotenv()。
素材里常见的 API_KEY = "XXXXX" 写法错在哪?正确写法是什么?
错在这个位置的存在本身。这次填的是占位符,但只要源码里留着这么一行,真实 key 迟早会被填进去,然后随代码一起提交出去。
正确写法:os.environ.get("OPENWEATHER_API_KEY"),取不到就直接抛错,不要带着 None 去发请求;.env 写进 .gitignore,仓库里只留 .env.template(只有变量名,没有值)。
为什么不能在 stdio 模式的服务器里 print()?要调试怎么办?
因为 transport='stdio' 把标准输入输出整条占用作为协议通道,print() 等于往协议流里插了一段垃圾,客户端会解析失败、连接直接断。调试信息应当写文件或输出到标准错误。
附术语表
这一讲出现过的英文原形与专有名词
| 术语 | 中文 | 一句话解释 |
|---|---|---|
MCP | 模型上下文协议 | Model Context Protocol,2024 年 11 月底由 Anthropic 推出的开放标准,统一大模型与外部数据源和工具的通信 |
| MCP server | MCP 服务器 | 外部工具那一端,把能力按标准格式暴露出来;输出只能被 MCP 客户端识别 |
| MCP client | MCP 客户端 | 接入这些外部工具的大模型运行环境,一个客户端可接多个服务器 |
| Function Call | 函数调用 | 模型调用函数的机制,只产出函数名和参数;MCP 的底层仍依托它 |
| Agent | 智能体 | 自主运行的智能系统,利用 Function Call 与 MCP 分析并执行任务 |
stdio | 标准输入输出 | 本地通信机制,适用于同一台机器上的客户端与服务器 |
SSE | 服务器推送事件 | 与 HTTP 结合实现跨网络实时数据传输,用于远程或分布式场景 |
| JSON-RPC | — | MCP 标准化接口所基于的通信形式,实现上下文传递与工具调用 |
FastMCP | — | Python SDK 里用来快速搭建 MCP 服务器的类 |
@mcp.tool() | 工具注册装饰器 | 把普通函数注册成 MCP 工具,自动提取名称、类型标注与 docstring 作为描述 |
ClientSession | 客户端会话 | 客户端侧的会话对象,负责握手、列工具、发起调用 |
stdio_client | — | 按配置把服务器脚本作为子进程拉起,并建立标准输入输出通道 |
StdioServerParameters | — | 描述「用什么命令、带什么参数、带什么环境变量」拉起服务器 |
AsyncExitStack | 异步资源栈 | 统一管理多个异步上下文,退出时按相反顺序全部关闭 |
asyncio.Lock | 异步锁 | 本讲用来防止并发资源释放冲突,避免重复关闭抛异常 |
servers_config.json | 服务器配置文件 | 标准接入格式:mcpServers → 服务名 → command + args |
tool_calls | 工具调用 | 模型产出的函数名与参数,参数是字符串形式的 JSON |
tool_call_id | 调用标识 | 把工具执行结果与对应那次调用配对,漏了或对不上会报错 |
finish_reason | 结束原因 | 取值为 tool_calls 时表示模型要求调用工具,需要再走一轮 |
| OpenWeather | — | 本讲天气服务器使用的接口服务,免费额度 100 万次/月,key 约需一小时生效 |