实战指南:用Python SDK构建你的首个MCP Client
1. 为什么你需要一个MCP Client
如果你正在开发需要与大型语言模型(LLM)交互的应用,MCP Client可以帮你省去大量重复工作。MCP(模型上下文协议)就像是一个智能翻译官,让LLM能够理解和操作各种外部工具和服务。想象一下,你只需要用自然语言提问,系统就能自动调用Elasticsearch查询数据、操作数据库或者执行其他复杂任务,这就是MCP带来的魔力。
我最近在一个电商数据分析项目中使用了MCP Client,原本需要写几十行代码的Elasticsearch查询,现在只需要问"上个月销量最好的商品有哪些?"就能得到完整答案。开发效率提升了至少3倍,而且产品经理可以直接用自然语言测试查询逻辑,不用再等开发人员写接口。
Python SDK是目前最成熟的MCP开发工具包,它封装了所有底层通信细节,提供了简洁的异步API。即使你之前没有接触过协议开发,跟着本教程也能在30分钟内搭建出第一个可工作的客户端。我们不仅会教你跑通示例代码,还会深入解析每个关键步骤的设计考量,让你真正掌握而不仅是复制粘贴。
2. 环境准备与SDK安装
2.1 选择Python环境
推荐使用Python 3.10或更高版本,这是MCP SDK测试最充分的版本。我实测过在3.8上也能运行,但某些异步特性会有兼容性问题。如果你用pyenv管理多版本环境,可以这样创建专属环境:
pyenv install 3.10.13
pyenv virtualenv 3.10.13 mcp-demo
pyenv activate mcp-demo
对于包管理,uv是我的首选工具,它比pip快得多,而且能更好地处理依赖冲突。安装方法很简单:
curl -LsSf https://astral.sh/uv/install.sh | sh
2.2 安装核心依赖
除了MCP SDK,我们还需要几个辅助库。打开终端执行:
uv add mcp openai python-dotenv
这里解释下各包的作用:
mcp:官方Python SDK,提供客户端和服务器开发所需的所有类和方法openai:用于与LLM交互,即使你用的是Claude或通义千问,也可以通过OpenRouter兼容接口python-dotenv:管理环境变量,避免将API密钥等敏感信息硬编码在代码中
安装完成后,建议创建一个requirements.txt记录版本号:
uv pip freeze > requirements.txt
3. 构建基础客户端
3.1 初始化项目结构
创建一个清晰的目录结构能让后续开发更顺畅:
mcp-client-demo/
├── config/
│ └── .env # 存放环境变量
├── scripts/
│ └── server.py # MCP服务器脚本
└── src/
├── client.py # 主客户端代码
└── utils.py # 工具函数
在.env中添加你的OpenRouter API密钥(如果没有可以去官网免费申请):
OPENROUTER_API_KEY=your_api_key_here
3.2 编写最小化客户端
让我们从最基础的代码开始,新建src/client.py:
import asyncio
import os
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
load_dotenv('../config/.env')
async def run_client(server_script: str):
# 配置服务器参数
server_params = StdioServerParameters(
command="python",
args=[server_script],
env={"PYTHONPATH": "../"} # 确保能找到模块
)
try:
async with stdio_client(server_params) as (reader, writer):
async with ClientSession(reader, writer) as session:
await session.initialize()
# 获取工具列表
tools = await session.list_tools()
print(f"可用工具: {[t.name for t in tools.tools]}")
# 调用示例工具
if "list_indices" in [t.name for t in tools.tools]:
result = await session.call_tool("list_indices", {})
print("Elasticsearch索引列表:", result.content)
except Exception as e:
print(f"客户端错误: {str(e)}")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法: python client.py <服务器脚本路径>")
sys.exit(1)
asyncio.run(run_client(sys.argv[1]))
这段代码做了几件关键事情:
- 加载环境变量配置
- 设置服务器启动参数
- 建立stdio通信通道
- 初始化会话并获取工具列表
- 调用具体工具并打印结果
3.3 运行测试
假设你的MCP服务器脚本在scripts/server.py,运行命令如下:
python src/client.py ../scripts/server.py
如果一切正常,你会先看到服务器提供的工具列表,然后是Elasticsearch索引数据。我在第一次运行时遇到了ModuleNotFoundError,是因为Python路径问题,通过设置env={"PYTHONPATH": "../"}解决了这个问题。
4. 集成LLM实现智能调用
4.1 设计客户端类
基础版本只能硬编码调用特定工具,现在我们用类来封装更智能的功能:
class SmartClient:
def __init__(self):
self.session = None
self.client = AsyncOpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENROUTER_API_KEY")
)
async def connect(self, server_script: str):
"""连接MCP服务器"""
params = StdioServerParameters(
command="python",
args=[server_script],
env={"PYTHONPATH": "../"}
)
(reader, writer) = await stdio_client(params)
self.session = ClientSession(reader, writer)
await self.session.initialize()
tools = await self.session.list_tools()
print(f"✔ 已连接服务器,可用工具: {[t.name for t in tools.tools]}")
async def process_query(self, query: str) -> str:
"""处理用户查询"""
if not self.session:
raise RuntimeError("请先连接服务器")
# 获取工具列表并转换为OpenAI格式
tools_response = await self.session.list_tools()
available_tools = [{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.inputSchema
}
} for t in tools_response.tools]
# 初始消息
messages = [{"role": "user", "content": query}]
# 第一次LLM调用
response = await self.client.chat.completions.create(
model="qwen/qwen-plus", # 通义千问
messages=messages,
tools=available_tools
)
# 处理工具调用链
final_response = []
message = response.choices[0].message
while True:
if message.content:
final_response.append(message.content)
if not message.tool_calls:
break
for tool_call in message.tool_calls:
# 执行工具调用
result = await self.session.call_tool(
tool_call.function.name,
json.loads(tool_call.function.arguments)
)
# 记录调用信息
final_response.append(
f"[调用工具 {tool_call.function.name}]"
)
# 更新消息历史
messages.append({
"role": "assistant",
"tool_calls": [{
"id": tool_call.id,
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments
}
}]
})
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result.content)
})
# 继续LLM处理
response = await self.client.chat.completions.create(
model="qwen/qwen-plus",
messages=messages,
tools=available_tools
)
message = response.choices[0].message
return "\n".join(final_response)
这个类的核心创新点是process_query方法,它实现了完整的工具调用链:
- 将用户查询发送给LLM
- 解析LLM返回的工具调用请求
- 执行实际工具调用
- 将结果返回给LLM进行下一步处理
- 重复直到LLM生成最终回复
4.2 实现交互式命令行
为了让测试更方便,我们添加一个命令行交互循环:
async def chat_loop(client: SmartClient):
print("\n智能MCP客户端已启动,输入你的问题或'quit'退出")
while True:
try:
query = input("\n> ").strip()
if query.lower() in ('quit', 'exit'):
break
start_time = time.time()
response = await client.process_query(query)
elapsed = time.time() - start_time
print(f"\n{response}")
print(f"\n⏱️ 耗时: {elapsed:.2f}s")
except KeyboardInterrupt:
print("\n使用Ctrl+C退出请直接输入quit")
except Exception as e:
print(f"错误: {str(e)}")
4.3 完整示例测试
现在你可以尝试各种自然语言查询了:
> teacher索引中有多少文档?
[调用工具 get_index]
teacher索引包含3个文档
⏱️ 耗时: 1.23s
> 把所有索引的文档数量汇总告诉我
[调用工具 list_indices]
[调用工具 get_index]
[调用工具 get_index]
[调用工具 get_index]
当前集群文档统计:
- student: 3
- teacher: 3
- movies: 3
总文档数: 9
⏱️ 耗时: 3.56s
5. 高级技巧与性能优化
5.1 错误处理与重试
在实际使用中,网络波动和工具调用失败很常见。这是我总结的最佳实践:
async def safe_call_tool(session, name, args, max_retries=3):
"""带重试的工具调用"""
last_error = None
for attempt in range(max_retries):
try:
return await session.call_tool(name, args)
except ToolCallError as e:
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(1 * (attempt + 1))
continue
raise last_error
在process_query方法中,把直接调用替换为:
result = await safe_call_tool(
self.session,
tool_call.function.name,
json.loads(tool_call.function.arguments)
)
5.2 缓存工具列表
每次查询都调用list_tools很浪费资源,我们可以缓存工具列表:
class SmartClient:
def __init__(self):
self._tools_cache = None
async def get_tools(self, force_refresh=False):
"""获取工具列表,带缓存"""
if force_refresh or self._tools_cache is None:
response = await self.session.list_tools()
self._tools_cache = response.tools
return self._tools_cache
5.3 流式输出
对于长时间运行的工具调用,流式输出能极大提升用户体验:
async def stream_response(query):
"""流式输出响应"""
full_response = []
async for chunk in client.process_query_stream(query):
print(chunk, end="", flush=True)
full_response.append(chunk)
return "".join(full_response)
需要在LLM调用时添加stream=True参数,并处理分块响应。
5.4 性能监控
添加简单的监控逻辑帮助优化:
async def process_query(self, query: str):
metrics = {
"llm_calls": 0,
"tool_calls": 0,
"start_time": time.time()
}
# ...原有逻辑...
metrics["end_time"] = time.time()
metrics["duration"] = metrics["end_time"] - metrics["start_time"]
print(f"性能指标: {metrics}")
6. 实际项目经验分享
在电商项目中使用MCP Client时,我们遇到了几个典型问题。首先是工具描述的质量直接影响LLM的调用准确性。最初我们写的描述太简单,导致LLM经常错误调用。后来采用"动词+宾语+约束条件"的格式后,准确率提升了60%:
不好的描述:
获取商品信息
好的描述:
根据商品ID检索商品的完整详情,包括价格、库存和规格参数。必须提供有效的id参数。
另一个痛点是工具参数的验证。我们发现即使LLM理解了需求,有时生成的参数格式也不正确。解决方案是在工具端添加严格的参数校验,并返回明确的错误信息。例如:
async def get_product(args):
if "id" not in args:
return ToolResponse.error("缺少必要参数: id")
if not isinstance(args["id"], str):
return ToolResponse.error("id必须是字符串")
# ...实际逻辑...
对于复杂查询,LLM可能需要多次工具调用才能得到结果。我们添加了"思考过程"日志,方便调试:
[LLM思考] 用户问"最贵的商品是什么",需要先获取所有商品价格
[调用工具 list_products]
[LLM思考] 已获取100个商品,正在比较价格
[调用工具 compare_prices]
最后是性能调优。我们发现工具调用的延迟主要来自三个方面:
- LLM响应时间(占总时间60%)
- 网络I/O(25%)
- 工具执行时间(15%)
优化措施包括:
- 使用更快的LLM模型(如qwen-turbo)
- 实现工具调用的并行处理
- 对频繁访问的数据添加缓存层
经过优化后,平均响应时间从4.2秒降到了1.8秒。关键优化点是让多个工具调用并行执行,而不是顺序等待:
async def parallel_tool_calls(session, tool_requests):
"""并行执行多个工具调用"""
tasks = [
safe_call_tool(session, req["name"], req["args"])
for req in tool_requests
]
return await asyncio.gather(*tasks)
7. 扩展应用场景
除了基础的Elasticsearch查询,MCP Client还能应用于许多有趣场景:
自动化报表生成
- 工具:数据查询+图表生成+文档组装
- 示例查询:"生成上季度销售趋势PDF报告,包含各品类对比"
智能客服系统
- 工具:订单查询+退换货流程+FAQ检索
- 示例查询:"订单12345的物流状态如何?如果没发货我想取消"
物联网设备控制
- 工具:设备状态查询+控制指令+场景模式
- 示例查询:"客厅温度太高了,把空调调到24度并打开窗帘"
代码辅助开发
- 工具:代码搜索+语法检查+测试运行
- 示例查询:"帮我找所有使用过Redis的Python代码,并检查连接是否关闭"
在每个场景中,MCP Client都充当着自然语言到具体操作的转换层。随着工具集的丰富,系统的能力会呈指数级增长。我在一个智能家居项目中,仅仅添加了5个基础工具,就能支持用户80%的日常控制需求。
8. 调试与问题排查
当MCP Client出现问题时,可以按照以下步骤排查:
1. 检查基础连接
async def test_connection():
try:
async with stdio_client(server_params) as (r, w):
print("✓ 传输层连接成功")
async with ClientSession(r, w) as session:
await session.initialize()
print("✓ 会话初始化成功")
tools = await session.list_tools()
print(f"✓ 获取到{len(tools.tools)}个工具")
except Exception as e:
print(f"连接测试失败: {str(e)}")
2. 验证工具调用
async def test_tool(tool_name, args={}):
try:
result = await session.call_tool(tool_name, args)
print(f"工具调用成功: {result.content}")
return True
except ToolCallError as e:
print(f"工具调用失败: {e.message}")
return False
3. LLM交互诊断
async def debug_llm(query):
print("原始查询:", query)
tools = await get_tools()
print("可用工具:", [t.name for t in tools])
response = await client.chat.completions.create(
model="qwen/qwen-plus",
messages=[{"role": "user", "content": query}],
tools=[...],
temperature=0
)
print("LLM首次响应:", response.choices[0].message)
常见问题及解决方案:
问题1:LLM不调用工具,直接回答
- 检查工具描述是否清晰
- 降低temperature参数减少随机性
- 在系统提示中强调"必须使用工具"
问题2:工具参数格式错误
- 在工具定义中添加更详细的inputSchema
- 让LLM先输出参数示例
- 添加参数校验和转换逻辑
问题3:响应时间过长
- 使用更快的LLM模型
- 限制最大工具调用次数
- 实现工具调用的超时机制
问题4:会话状态异常
- 检查是否正确处理了异步上下文
- 确保每次查询使用独立的消息历史
- 添加会话重置工具
日志是排查问题的关键。建议在以下位置添加详细日志:
- 建立连接时
- 每次工具调用前后
- LLM请求和响应
- 异常捕获处
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('mcp_client.log'),
logging.StreamHandler()
]
)
更多推荐



所有评论(0)