第4讲:MCP Client 开发——连接、发现、调用

第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

六、常见错误 & 排坑

  1. RuntimeError: Session not initialized

    • 原因:忘记调用 await session.initialize()

    • 解决:在 list_tools() 之前必须先 initialize()

  2. FileNotFoundError: [Errno 2] No such file or directory

    • 原因:Server 文件路径不对

    • 解决:使用绝对路径,或在 Server 文件所在目录运行 Client

  3. json.decoder.JSONDecodeError

    • 原因:Server 返回了非 JSON 内容(如 print 输出混入 stdout)

    • 解决:Server 代码中不要使用 print(),改用 logging

  4. 多个 Server 同名工具冲突

    • 原因:两个 Server 都提供了 query_database

    • 解决:在工具名前加 Server 前缀,如 db1_query_database


七、课后作业

  1. 实现连接池 :修改 RobustMCPClient,支持复用已有的 Server 进程(而不是每次新建)。

  2. 添加健康检查 :给 Client 增加 ping() 方法,定期检查 Server 是否存活,失活时自动重连。

  3. 挑战题:实现一个"懒加载" 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工具箱)。所有计算在浏览器完成,文件不上传服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。

相关推荐
Swift社区1 小时前
Python 开发环境怎么选?PyCharm、VS Code、Trae 谁更适合 AI 开发?
人工智能·python·pycharm
卷无止境1 小时前
聊聊Web开发里的流式数据 从原理到FastAPI实战
后端·python·fastapi
SMF19191 小时前
【PyCharm】让 PyCharm 使用 .venv 虚拟环境
ide·python·pycharm
修远客1 小时前
感知模块:Agent的眼睛和耳朵 — 三层降级策略让Agent永不"失明"
python·agent
人间凡尔赛2 小时前
2026 开发者效率新基建:Agent Plugins 1.0 规范解读与 MCP 实战
ai·工具·效率
circuitsosk2 小时前
Prompt Engineering进阶:面向复杂业务场景的模板化管理与动态注入策略
python·langchain·prompt·跨境电商·rag·上下文管理·动态注入
lifallen2 小时前
edit-article:AI 味来自跳级
人工智能·学习·ai·ai编程·ai写作
Livia要学习2 小时前
Python闭包
开发语言·jvm·python
AI分享猿3 小时前
品牌设计系统提取:一个网址,让AI生成的PPT自动套用你的品牌风格
人工智能·ai·powerpoint·ppt