【案例】Qwen 微调实战 · MCP 协议与工具接入

给每件工具单配一根转换头,接十件就得带十个头。MCP 做的事只有一件:把插头统一成同一种规格。

30″30 秒看懂 MCP

想象你搬进一间插座规格全乱的老房子:热水壶是圆孔的,投影仪是扁孔的,音响是三相的,每买一件电器就得再配一个专用转换头。抽屉里塞了十几个头,换个房间还得全部搬过去——一把锁单独配一把钥匙

MCP 干的事情朴素到有点无聊:把插头形状统一成同一种规格。电器厂商按这个规格出厂(这一端叫服务器),墙上装一排同规格的插座(这一端叫客户端),从此谁插谁都通,换房间也不用重新配转换头。

要紧的是别误会了这件事的性质:统一插头没有改变电从哪来。插座背后还是那根火线,电压电流一点没变。MCP 也是——它底层仍然依托模型原生的 Function Call 能力,只是在上面做了一层标准化封装。

图① 30 秒看懂:一堆异形转换头,还是一套统一插座
图① 30 秒看懂:一堆异形转换头,还是一套统一插座
配电房里的角色对应的技术概念它到底是什么
各式各样的电器外部工具(天气接口、文件系统、git 仓库)模型自己做不到、必须借外力完成的事
一抽屉异形转换头传统的定制集成围绕每个 API 单独写一个外部函数,工具一多开发量暴涨
统一插头规格MCP 协议约定通信格式与调度规范,双方照着做就能互通
电器上的标准插头MCP 服务器(server)外部工具那一端,把能力按标准格式暴露出来
墙上那排插座MCP 客户端(client)接入这些工具的大模型运行环境,一个客户端可以接多个服务器
配电箱的接线清单servers_config.json写明每个服务器叫什么名字、用什么命令拉起来
同一屋的墙插 / 跨楼的供电线stdio / SSE + HTTP两种通信机制:本机进程间,或跨网络分布式
插座背后那根火线Function Call真正让模型调起函数的底层机制,MCP 没有替换它
电器上的铭牌工具描述(format_for_llm写清这件工具叫什么、要哪些参数,模型据此判断用不用
会自己决定用哪件电器的管家Agent自主运行的智能系统,利用 Function Call 和 MCP 分析并执行任务
⛔ 整讲最容易记错的一条 MCP 不是 Function Call 的替代品,而是它之上的一层标准化封装。 通过在 MCP 运行过程中进行数据包捕获与分析可知,MCP 的底层实现机制本质上仍是依托于大模型原生自带的 Function Call 能力来完成对外部工具的调用。插头统一了,电还是从那根火线来——所以 Function Call 那一讲里学的 tool_callstool_call_id、两次调用模型,在这一讲里一条都没作废。
这一讲要动手做的两件东西 一个天气 MCP 服务器(把 OpenWeather 接口包成标准插头)和一个多服务器 MCP 客户端(把插座排接上,跑起对话循环)。做完这两个,任何 Agent 开发框架里的「接入 MCP」按钮背后是什么,你就全知道了。

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 集成),以加速该协议的推广。

协议这东西的价值不在技术含量 统一插头规格没有任何技术难度可言,难的是大家都认同并照做。MCP 值钱的地方也在这里:它把「外部工具怎么描述自己、怎么被调用、返回什么格式」这些原本各写各的事情钉死成一套约定,于是生态才长得起来。

1.2 服务器与客户端:谁是谁

这是初学 MCP 最容易绕晕的地方,因为它的命名和直觉相反。记住这一句就不会错:

⛔ 划分方式要背下来 在 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 在此基础上对这一过程进行了更高层次的封装与优化,从而构建起更为完善的交互与功能体系。

两层要分清 协议层(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 三者的分工

这三个词经常被混着用,但它们处在完全不同的层面:

1Function Call

AI 大模型调用函数的机制。模型只负责判断要不要调、调哪个、参数填什么,它自己从不执行。对应火线——电从哪来。

2MCP

一个标准协议,使大模型与 API 无缝交互。它规定插头形状、铭牌怎么写、线怎么接。对应插座规格——怎么接得上。

3Agent

一个自主运行的智能系统,利用 Function Call 和 MCP 来分析和执行任务,实现特定目标。对应管家——决定这会儿该用哪件电器。

所以三者是叠起来的,不是并列的三选一:Agent 站在最上面做决策,MCP 在中间把工具接进来,Function Call 在最底下真正把「调用」这件事发生出来。

02原理:一次工具调用是怎么走完的

四步流程、两种通信机制、三层技术生态,以及标准化换来的具体好处

图② MCP 的客户端—服务器架构与两种通信机制
图② MCP 的客户端—服务器架构与两种通信机制

2.1 客户端调用服务器工具的四步

从「用户问了一句话」到「工具真的被执行」,中间是固定的四步。前三步是 MCP 做的事,第四步才轮到 Function Call 登场

1建立连接

与 MCP 服务器搭建通信链路。本机场景下,客户端把服务器脚本作为子进程拉起来,用标准输入输出接上。相当于把插头插进插座。

2查询工具

获取服务器上所有外部工具的数量信息——每个工具叫什么、干什么、需要哪些参数。相当于读电器铭牌。

3生成列表

将查询到的外部工具整理成列表,并融入当前对话场景,转换成模型能读懂的工具描述格式。相当于把这排插座上接了什么,告诉管家。

4调用工具

通过 Function Call 技术调用所需的外部工具。模型返回工具名和参数,客户端据此路由到对应服务器执行。这一步走的是火线。

图③ 客户端调用服务器工具的四步,以及 Function Call 落在哪一步
图③ 客户端调用服务器工具的四步,以及 Function Call 落在哪一步

把这四步和 Function Call 那一讲的流程叠在一起看,会发现第 4 步之后的一切都没变:模型返回 tool_calls → 客户端执行 → 把结果作为 roletool 的消息回填 → 再调一次模型拿自然语言答复。变的只是工具描述从哪来(原来手写,现在向服务器查)和工具在哪执行(原来同进程,现在独立进程)。

⛔ 模型依然不执行任何东西 MCP 没有让模型获得执行能力。第 4 步里模型给出的仍然只是函数名和参数,真正把请求发给 MCP 服务器、等回结果的,是客户端代码。管家只会说「用热水壶」,按开关的还是那套电路。

2.2 两种通信机制:同屋墙插,还是跨楼供电

MCP 协议支持两种主要的通信机制,选哪种取决于工具和模型跑在不在同一台机器上:

机制传输方式适用场景比喻
本地通信通过 stdio 传输数据同一台机器上运行的客户端和服务器之间的通信同一间屋子的墙插,一插就通,没有布线问题
远程通信利用 SSE 与 HTTP 结合,实现跨网络的实时数据传输需要访问远程资源或分布式部署的场景跨楼的供电线路,要考虑线路、权限和距离

本讲的天气服务器用的是 stdiomcp.run(transport='stdio')。这意味着:

  • 客户端用配置里的 commandargs 把服务器当子进程启动,两者靠管道通信;
  • 服务器进程的生命周期完全由客户端掌管——客户端退出,服务器也跟着结束;
  • 服务器不能往标准输出打印任何调试信息,因为那条通道被协议占用了。这是新手最容易踩的一个坑,06 节会专门讲。
什么时候该换成远程 工具要访问的资源不在本机(内网数据库、公司文件服务)、工具要被多个客户端共享、工具的依赖太重不适合跟着客户端一起装——这三种情况就该走 SSE + HTTP。协议层的工具描述格式与调用流程完全一致,换的只是传输方式。

2.3 技术生态的三层

随着技术迭代加速,MCP 在发展进程中实现了重大跨越。如今它已远非简单的「协议」概念所能涵盖,而是构建起一个完整且自成体系的技术生态。三层协同:

内容作用
MCP 协议一套抽象的规范集合,涵盖大模型与工具的调度规范、服务器与客户端之间的通信规范等底层支撑,确保数据传输与交互的规范性和稳定性。遵循这些协议标准的对象,即被认定为 MCP 服务器或客户端
MCP 开发工具多种编程语言版本的 SDK开发者借助这些 SDK 可高效完成 MCP 服务器和客户端的开发,大大缩短开发周期
MCP 服务器生态开源的 MCP 服务器为核心构建起的庞大生态智能体开发人员可直接利用生态中的开源工具,加速自身项目的开发进程,降低技术应用门槛

对照插座的比喻:协议是国标文件SDK 是厂家拿到的模具和检测工具服务器生态是市面上已经按标准出厂的那一大堆电器。三者缺一不可——只有标准没有电器,插座排上空空如也;只有电器没有标准,又回到一抽屉转换头。

2.4 标准化到底换来了什么

这一节值得单独拎出来,因为它是 MCP 这一讲区别于 Function Call 那一讲的全部意义所在。同样是「让模型用上外部工具」,标准化带来了四件具体的事:

1工具描述不用手写了

裸 Function Call 要为每个函数手写一整段 JSON Schema。走 MCP 时,工具自己带铭牌:服务器返回名称、描述、输入模式,客户端自动转成模型要的格式。写错参数名这类低级错误从源头消失了。

2工具和应用解耦了

工具跑在独立进程里。改天气工具不必重启主应用,主应用换个模型也不影响工具。团队协作时,写工具的人和写 Agent 的人可以完全并行。

3接新工具变成改配置

加一个服务器就是在 servers_config.json 里加一段。客户端代码一个字不用动——这就是本讲 4.6 节能在五分钟内挂上第二个服务器的原因。

4别人写的工具能直接用

市面上已有成百上千的开源 MCP 服务器。只要遵循同一协议,拿来即用,不需要读源码再包一层。这是「一把锁一把钥匙」时代不可能有的效率。

标准化也带来了新的代价 多了一层进程边界,就多了一批新问题:子进程起不来怎么办、环境变量传不进去怎么办、客户端退出时资源没释放干净怎么办、工具调用失败要不要重试。本讲的客户端代码里,AsyncExitStackasyncio.Lockretries=2 这几处都是在处理这些代价——它们不是炫技,是协议化之后必须自己扛的工程责任

03最小代码:三十行写一个 MCP 服务器

先把最短的一条路走完,再去看完整的天气服务器和多服务器客户端

写一个 MCP 服务器,剥掉业务逻辑之后只剩三件事:建一个实例、给函数挂个装饰器、跑起来

① 建实例FastMCP("服务名")
② 挂装饰器@mcp.tool() 注册函数
③ 跑起来mcp.run(transport='stdio')

最短的服务器

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')以标准输入输出为传输通道启动,等待客户端的请求。进程会一直阻塞在这里
⛔ docstring 就是写给模型看的铭牌 别把它当注释随手写。工具名、描述、参数说明这三样,是模型唯一的判断依据——它看不到你的函数体。参数写清「必要参数,字符串类型,表示什么」,跟裸 Function Call 里认真写 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",加上天气服务器要用的 httpxpython-dotenv。另外需要两个 key:调用大模型的 key(本讲用兼容 OpenAI 接口的服务)和 OpenWeather 的 key。两个都写进 .env,代码里一律 os.environ.get(...) 读取。
OpenWeather 的 key 有延迟 注册流程是:官网 Sign in → Create an Account → 填信息 → 邮箱点 Verify your email → 登录后在用户名菜单里进 My API keys 拿 key。注意:API Key 大概需要一小时生效。 刚注册完立刻调用会返回 401,这不是代码的问题,等等再试。免费额度给得很足——100 万次/月,还包含 60 分钟级预报和 48 小时逐小时预报。

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 服务工具;协调数据获取与格式化;返回最终用户可读的天气报告
weather_server.py —— 天气 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() + 两个 exceptHTTP 错误与未知异常异常直接穿透到 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(只有变量名,没有值)。
stdio 模式下不要往标准输出打印 mcp.run(transport='stdio') 把标准输入输出整条占用作为协议通道。这时候在服务器里写一句 print("debug"),就等于往协议流里插了一段垃圾,客户端会解析失败、连接直接断。要调试就写文件,或者输出到标准错误。

4.2 接入配置文件:配电箱的接线清单

MCP 服务器的标准接入流程是通过写入一个配置文件来完成的,格式很固定:

{
  "mcpServers": {
    "服务名称": {          // 自定义服务标识,如 filesystem / git
      "command": "执行命令",
      "args": ["服务模块名",   // 如 @modelcontextprotocol/server-filesystem
               "--flag",       // 命令行标志
               "参数值"]       // 如 --repository 后的路径
    }
  }
}

三个典型例子放在一起看,就明白这份清单在描述什么了——「用什么命令、带什么参数,把这个服务器进程拉起来」

servers_config.template.json —— 三种服务器的接入写法骨架模板
{
  "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说明
weatherpython本讲手写的服务器,参数就是脚本文件名
filesystemnpx官方预构建的文件系统服务器,-y 免确认安装,最后一个参数是允许访问的目录
gituvxgit 服务器,--repository 后跟本地仓库路径

本讲最初只挂一个天气服务器,配置就是最短的那一段:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["weather_server.py"]
    }
  }
}
服务名会变成工具名的前缀 客户端会把工具统一重命名成 服务名_工具名,所以上面这份配置挂出来的工具叫 weather_query_weather这是为了避免不同服务器提供同名工具时打架——两个服务器都有 search 很正常,加上前缀就分得开了。

4.3 客户端(一):配置加载与工具封装

客户端系统的主要功能有四条:管理多个 MCP 服务器连接;将 MCP 工具无缝接入 OpenAI 的 Function Calling 体系,实现自然语言驱动的自动化操作;将 MCP 返回的工具信息封装为 Tool 对象,生成 LLM 可理解的描述;实现交互式聊天循环。

mcp_client.py —— 多服务器 MCP 客户端完整实现
"""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 服务器交互的核心代理,支持异步操作和错误恢复。

图④ Server 类的全生命周期:连接、发现、调用、释放
图④ Server 类的全生命周期:连接、发现、调用、释放
方法功能做了什么
__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() 关闭所有异步上下文

三处工程细节,每一处都对应一个真实事故

1环境变量要合并,不是替换

env={**os.environ, **self.config["env"]}。子进程默认继承不到你在配置里单独写的变量,而只传自定义变量又会把 PATH 之类的系统变量全丢掉,导致 command 根本找不到。两者必须合并。

2失败时先清理再抛出

initializeexcept 里先 await self.cleanup()raise连接建了一半失败,子进程可能已经起来了——不清理就会留下一个孤儿进程,反复调试几次机器上全是僵尸。

3清理要加锁

异步场景里 cleanup 可能被主动调用一次、又被异常路径调用一次。AsyncExitStack 重复关闭会抛错,asyncio.Lock 保证同一时刻只有一个清理在跑

retries=2 就是「接触不良再插一次」 工具调用失败未必是工具坏了——网络抖动、上游限流、子进程刚起来还没就绪,都会导致偶发失败。重试两次、间隔一秒,能挡掉相当一部分。但重试不能无限:真的坏了就要让错误浮上来,而不是让用户等着。

4.5 客户端(三):多轮工具调用怎么转起来

MultiServerMCPClient 是系统的核心控制器:管理多个 MCP 服务器,整合它们的工具,并通过自然语言交互驱动工具调用。它把工具格式转换为 OpenAI 兼容格式,维护对话上下文,处理多轮工具调用。

连接阶段:把所有服务器的工具汇成一张表

connect_to_servers 遍历配置里的每个服务器,逐个 initializelist_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_reasontool_calls,就执行工具、回填结果、再发一次请求,直到不是为止。

① 带工具表提问messages + all_tools
② 判断 finish_reason是 tool_calls 就继续
③ 回填两条消息assistant 原样 + tool 回执
④ 再请求一次回到 ②

回填的两条消息一条都不能少、顺序也不能反:

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_weatherwrite_write_file 两个工具交给模型。这时候说「今天北京天气怎么样,把结果存下来」,模型会连着调两次工具——先查天气,再写文件,中间由 chat_base 的循环自动串起来。
这就是插座统一规格之后的日常:新买一件电器,插上就能用,不用重新布线。

05骨架模板:四个文件起一套 MCP 应用

改完 TODO 就能跑,凭据一律走环境变量

文件角色改哪里
weather_server.pyMCP 服务器换成你自己的工具函数;密钥读环境变量,不要动成字面量
mcp_client.pyMCP 客户端不用改,行为全部由 .env 与配置文件决定
servers_config.template.json接线清单删掉用不上的服务,补上 TODO 路径
.env.template凭据模板复制成 .env 后填值;.env 必须进 .gitignore

5.1 服务器模板

换工具时只动三处:FastMCP("...") 的名字、@mcp.tool() 下面的函数、以及函数的 docstring。docstring 决定模型认不认得这件工具,别省。

weather_server.py骨架模板
"""一个最小可用的 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_client.py骨架模板
"""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 包。用不上的直接删。

servers_config.template.json骨架模板
{
  "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.template骨架模板
# 复制为 .env 后填值,.env 必须加进 .gitignore
# 大模型服务的 API key
LLM_API_KEY=
# 大模型服务的兼容 OpenAI 接口地址
BASE_URL=
# 使用的模型名
MODEL=
# OpenWeather 的 API key,weather_server.py 读它
OPENWEATHER_API_KEY=
变量被谁读说明
LLM_API_KEYmcp_client.py大模型服务的凭据,缺失时客户端启动阶段就报错
BASE_URLmcp_client.py兼容 OpenAI 协议的接口地址
MODELmcp_client.py模型名
OPENWEATHER_API_KEYweather_server.py天气接口凭据;服务器是子进程,所以客户端传 env 时必须合并系统环境变量
✅ 跑起来的顺序 cp .env.template .env 填好四个值 → 按需裁剪 servers_config.jsonpython 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 消息),再逐条 append roletool 的回执,并带上 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=2delay=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 类的五个方法各做什么?AsyncExitStackasyncio.Lock 分别防什么?

__init__ 初始化实例(存名称与配置,准备资源栈和清理锁);initialize 解析命令与参数、合并系统环境变量与自定义环境变量、通过 stdio_client 建通道、初始化 ClientSessionlist_tools 解析 ("tools", [...]) 元组并封装成 Tool 对象;execute_tool 执行调用并支持重试(retries=2delay=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 serverMCP 服务器外部工具那一端,把能力按标准格式暴露出来;输出只能被 MCP 客户端识别
MCP clientMCP 客户端接入这些外部工具的大模型运行环境,一个客户端可接多个服务器
Function Call函数调用模型调用函数的机制,只产出函数名和参数;MCP 的底层仍依托它
Agent智能体自主运行的智能系统,利用 Function Call 与 MCP 分析并执行任务
stdio标准输入输出本地通信机制,适用于同一台机器上的客户端与服务器
SSE服务器推送事件与 HTTP 结合实现跨网络实时数据传输,用于远程或分布式场景
JSON-RPCMCP 标准化接口所基于的通信形式,实现上下文传递与工具调用
FastMCPPython 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 约需一小时生效