第3讲我们写了一个 MCP Server,暴露了两个工具。但 Server 本身不做事------它等着别人来调。这一讲我们来写 MCP Client,让 Agent 能主动连上 Server、发现工具、发起调用。
学完这一讲,你会拥有一个通用的 MCP Client 封装,可以连接到任何 stdio 模式的 MCP Server。
一、MCP Client 的核心职责
一个 MCP Client 要做三件事:
| 职责 | 说明 | 对应方法 |
|---|---|---|
| 连接 | 启动 Server 进程(stdio)或建立 HTTP 连接(SSE) | stdio_client() |
| 发现 | 获取 Server 提供的工具列表 | list_tools() |
| 调用 | 按名称和参数调用具体工具 | call_tool() |
此外,一个好的 Client 还要处理:
-
连接失败的重试
-
工具调用超时
-
参数校验
-
多个 Server 的管理
二、基础 Client:单 Server 连接
我们先写一个最精简的 Client,只连一个 Server,顺序执行三步。
# simple_client.py
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class SimpleMCPClient:
"""最简单的 MCP Client:连接一个 Server,发现工具,调用工具"""
def __init__(self, server_command: list[str]):
"""
参数:
server_command: 启动 Server 的命令
例如:["python", "server.py"]
"""
self.server_params = StdioServerParameters(
command=server_command[0],
args=server_command[1:]
)
self.session = None
self.tools = []
async def connect(self):
"""连接到 MCP Server 并获取工具列表"""
# 建立 stdio 连接
self.read, self.write = await stdio_client(self.server_params).__aenter__()
# 创建会话
self.session = await ClientSession(self.read, self.write).__aenter__()
# 初始化(协议握手)
await self.session.initialize()
# 获取工具列表
tools_result = await self.session.list_tools()
self.tools = tools_result.tools
print(f"✅ 已连接到 Server")
print(f"📋 发现 {len(self.tools)} 个工具:")
for tool in self.tools:
params = list(tool.inputSchema.get("properties", {}).keys())
print(f" • {tool.name}({', '.join(params)})")
return self.tools
async def call_tool(self, tool_name: str, arguments: dict) -> str:
"""调用指定工具"""
if not self.session:
raise RuntimeError("未连接到 Server,请先调用 connect()")
result = await self.session.call_tool(tool_name, arguments)
# 提取文本内容
texts = []
for content in result.content:
if content.type == "text":
texts.append(content.text)
return "\n".join(texts)
async def close(self):
"""关闭连接"""
if self.session:
await self.session.__aexit__(None, None, None)
if hasattr(self, 'read') and self.read:
self.read.close()
if hasattr(self, 'write') and self.write:
self.write.close()
# 测试
async def test():
client = SimpleMCPClient(["python", "server.py"])
try:
# 1. 连接
await client.connect()
# 2. 调用工具
print("\n--- 查询技术部员工 ---")
result = await client.call_tool("query_database", {
"sql": "SELECT name, salary FROM employees WHERE department = '技术部'"
})
print(result)
print("\n--- 读取文件 ---")
result = await client.call_tool("read_file", {
"path": "sample.txt"
})
print(result)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(test())
运行:
python simple_client.py
输出:
✅ 已连接到 Server
📋 发现 2 个工具:
• query_database(sql)
• read_file(path)
--- 查询技术部员工 ---
[
{
"name": "张三",
"salary": 25000
},
...
]
--- 读取文件 ---
这是一个示例文件。
...
三、增强 Client:错误处理与超时
基础版能用,但不够健壮。生产级的 Client 需要处理各种异常。
# robust_client.py
import asyncio
import traceback
from datetime import datetime
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class RobustMCPClient:
"""增强版 MCP Client:带错误处理、超时、重试、日志"""
def __init__(self, server_command: list[str], timeout: float = 30.0):
self.server_params = StdioServerParameters(
command=server_command[0],
args=server_command[1:]
)
self.timeout = timeout
self.session = None
self.tools = []
self.connected = False
async def connect(self, retries: int = 3) -> bool:
"""连接 Server,带重试机制"""
last_error = None
for attempt in range(retries):
try:
self.read, self.write = await asyncio.wait_for(
stdio_client(self.server_params).__aenter__(),
timeout=self.timeout
)
self.session = await asyncio.wait_for(
ClientSession(self.read, self.write).__aenter__(),
timeout=self.timeout
)
await asyncio.wait_for(
self.session.initialize(),
timeout=self.timeout
)
tools_result = await asyncio.wait_for(
self.session.list_tools(),
timeout=self.timeout
)
self.tools = tools_result.tools
self.connected = True
print(f"✅ 连接成功 (尝试 {attempt + 1}/{retries})")
return True
except asyncio.TimeoutError:
last_error = f"连接超时 ({self.timeout}s)"
print(f"⚠️ 第 {attempt + 1} 次尝试超时")
await self._cleanup()
except Exception as e:
last_error = str(e)
print(f"⚠️ 第 {attempt + 1} 次连接失败: {e}")
await self._cleanup()
print(f"❌ 连接失败,已重试 {retries} 次: {last_error}")
return False
async def call_tool(self, tool_name: str, arguments: dict) -> dict:
"""
调用工具,返回结构化结果
返回:
{
"success": bool,
"data": str or None,
"error": str or None,
"duration_ms": float
}
"""
if not self.connected or not self.session:
return {
"success": False,
"data": None,
"error": "未连接到 Server",
"duration_ms": 0
}
start = datetime.now()
try:
result = await asyncio.wait_for(
self.session.call_tool(tool_name, arguments),
timeout=self.timeout
)
duration = (datetime.now() - start).total_seconds() * 1000
texts = []
for content in result.content:
if content.type == "text":
texts.append(content.text)
return {
"success": True,
"data": "\n".join(texts),
"error": None,
"duration_ms": round(duration, 1)
}
except asyncio.TimeoutError:
duration = (datetime.now() - start).total_seconds() * 1000
return {
"success": False,
"data": None,
"error": f"工具调用超时 ({self.timeout}s)",
"duration_ms": round(duration, 1)
}
except Exception as e:
duration = (datetime.now() - start).total_seconds() * 1000
return {
"success": False,
"data": None,
"error": str(e),
"duration_ms": round(duration, 1)
}
def get_tool_descriptions(self) -> str:
"""生成工具描述文本,供大模型使用"""
lines = []
for tool in self.tools:
params = tool.inputSchema.get("properties", {})
param_str = ", ".join([
f"{k}: {v.get('type', 'any')}"
for k, v in params.items()
])
lines.append(f"- {tool.name}({param_str}): {tool.description}")
return "\n".join(lines)
async def _cleanup(self):
"""清理资源"""
try:
if self.session:
await self.session.__aexit__(None, None, None)
if hasattr(self, 'read') and self.read:
self.read.close()
if hasattr(self, 'write') and self.write:
self.write.close()
except:
pass
self.session = None
self.connected = False
async def close(self):
"""关闭连接"""
await self._cleanup()
# 测试
async def test():
client = RobustMCPClient(["python", "server.py"], timeout=10.0)
# 测试连接(故意第一次失败,第二次成功)
connected = await client.connect(retries=2)
if not connected:
return
print(f"\n工具描述:\n{client.get_tool_descriptions()}\n")
# 测试正常调用
result = await client.call_tool("query_database", {
"sql": "SELECT COUNT(*) as count FROM employees"
})
print(f"调用结果 (成功={result['success']}, 耗时={result['duration_ms']}ms)")
print(result['data'])
# 测试错误调用
result = await client.call_tool("query_database", {
"sql": "INVALID SQL"
})
print(f"\n错误调用 (成功={result['success']})")
print(f"错误信息: {result['error']}")
await client.close()
if __name__ == "__main__":
asyncio.run(test())
四、高级 Client:多 Server 管理器
实际生产中,一个 Agent 可能需要同时连接多个 MCP Server(数据库 Server、文件 Server、API 网关 Server)。我们需要一个管理器来统一调度。
# multi_server_client.py
import asyncio
from typing import Dict, List
class MCPServerManager:
"""
多 MCP Server 管理器
可以同时管理多个 Server 连接,
所有工具统一暴露给 Agent
"""
def __init__(self):
self.servers: Dict[str, RobustMCPClient] = {}
self.all_tools: List[dict] = []
async def add_server(self, name: str, command: list[str], timeout: float = 30.0):
"""
添加并连接一个 MCP Server
参数:
name: Server 的唯一标识
command: 启动命令
timeout: 超时时间
"""
client = RobustMCPClient(command, timeout)
connected = await client.connect()
if not connected:
print(f"❌ Server '{name}' 连接失败")
return False
self.servers[name] = client
print(f"✅ Server '{name}' 已连接 ({len(client.tools)} 个工具)")
return True
def get_all_tools(self) -> List[dict]:
"""获取所有 Server 的所有工具(扁平化列表)"""
all_tools = []
for server_name, client in self.servers.items():
for tool in client.tools:
all_tools.append({
"server": server_name,
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema
})
return all_tools
def get_tool_descriptions(self) -> str:
"""生成所有工具的汇总描述"""
lines = []
for tool_info in self.get_all_tools():
params = tool_info["inputSchema"].get("properties", {})
param_str = ", ".join([
f"{k}: {v.get('type', 'any')}"
for k, v in params.items()
])
lines.append(
f"[{tool_info['server']}] {tool_info['name']}({param_str}): "
f"{tool_info['description'][:80]}..."
)
return "\n".join(lines)
async def call_tool(self, tool_name: str, arguments: dict) -> dict:
"""
调用指定工具(自动查找所属 Server)
如果多个 Server 有同名工具,调用第一个找到的
"""
for server_name, client in self.servers.items():
for tool in client.tools:
if tool.name == tool_name:
print(f"→ 路由到 Server '{server_name}' 的工具 '{tool_name}'")
return await client.call_tool(tool_name, arguments)
return {
"success": False,
"data": None,
"error": f"未找到工具 '{tool_name}'",
"duration_ms": 0
}
async def close_all(self):
"""关闭所有 Server 连接"""
for name, client in self.servers.items():
print(f"关闭 Server '{name}'...")
await client.close()
self.servers.clear()
# 测试:连接两个 Server
async def test():
manager = MCPServerManager()
# 假设有两个 Server(实际可以用同一个 Server 文件测试两次)
await manager.add_server("db-server", ["python", "server.py"])
print(f"\n所有工具:\n{manager.get_tool_descriptions()}\n")
# 调用工具
result = await manager.call_tool("query_database", {
"sql": "SELECT department, AVG(salary) as avg_salary FROM employees GROUP BY department"
})
if result["success"]:
print(f"查询成功 ({result['duration_ms']}ms):")
print(result["data"])
else:
print(f"查询失败: {result['error']}")
await manager.close_all()
if __name__ == "__main__":
asyncio.run(test())
五、Client 开发最佳实践
5.1 连接管理
# 推荐:使用 async with 确保资源释放
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# ... 使用 session
# 退出时自动关闭
5.2 超时设置
不同类型的操作应有不同的超时:
| 操作 | 建议超时 | 原因 |
|---|---|---|
| 连接建立 | 10s | 启动进程通常很快 |
| 工具列表获取 | 5s | 纯内存操作 |
| 工具调用 | 30-60s | 可能涉及数据库查询或文件读取 |
5.3 日志记录
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("MCPClient")
# 在每个关键步骤加日志
logger.info(f"Connecting to server: {command}")
logger.info(f"Found {len(tools)} tools")
logger.info(f"Calling tool: {name} with args: {arguments}")
logger.warning(f"Tool call failed: {error}")
5.4 工具缓存
工具列表通常在 Session 生命周期内不变,可以缓存避免重复请求:
class CachedClient(RobustMCPClient):
_tools_cache = None
async def list_tools(self, force_refresh=False):
if self._tools_cache and not force_refresh:
return self._tools_cache
result = await super().list_tools()
self._tools_cache = result
return result
六、常见错误 & 排坑
-
RuntimeError: Session not initialized-
原因:忘记调用
await session.initialize() -
解决:在
list_tools()之前必须先initialize()
-
-
FileNotFoundError: [Errno 2] No such file or directory-
原因:Server 文件路径不对
-
解决:使用绝对路径,或在 Server 文件所在目录运行 Client
-
-
json.decoder.JSONDecodeError-
原因:Server 返回了非 JSON 内容(如 print 输出混入 stdout)
-
解决:Server 代码中不要使用
print(),改用logging
-
-
多个 Server 同名工具冲突
-
原因:两个 Server 都提供了
query_database -
解决:在工具名前加 Server 前缀,如
db1_query_database
-
七、课后作业
-
实现连接池 :修改
RobustMCPClient,支持复用已有的 Server 进程(而不是每次新建)。 -
添加健康检查 :给 Client 增加
ping()方法,定期检查 Server 是否存活,失活时自动重连。 -
挑战题:实现一个"懒加载" Client------只在第一次调用工具时才连接 Server,而不是在初始化时就连接。
八、总结
这一讲我们完成了:
-
SimpleMCPClient:最精简的单 Server 连接
-
RobustMCPClient:带重试、超时、错误处理的增强版
-
MCPServerManager:多 Server 统一管理器
-
最佳实践:连接管理、超时设置、日志记录、工具缓存
你现在拥有了一个通用的 MCP Client 工具箱,可以连接任何 stdio 模式的 MCP Server。
下一讲,我们将进入实战环节------用 MCP 连接真实的数据库系统(MySQL/PostgreSQL),并加上只读安全和连接池管理。
🧰 开发之余,处理 Base64、JWT 解析、JSON 格式化、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top (子页 PDF 大师:PDF 大师 - zz365工具箱)。所有计算在浏览器完成,文件不上传服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。