【MCP 全栈教程】第 24 篇:MCP Client 架构——Host 应用如何管理多个 Server 连接
本系列定位:从协议原理到 Server 开发、Client 开发、再到各大平台实战集成,系统化掌握 MCP(Model Context Protocol)全栈技术体系。
本篇你将学到
- Host 应用的核心职责与分层职责边界
- Client 与 Server 的一对一连接模型
- 连接池管理与健康检查的实现思路
- Server 重连策略(指数退避、5 次重试、60 秒上限)
- 工具命名空间隔离机制(
mcp_{server}_{tool}) - 多 Server Host 的典型架构组成
学完本篇,你将理解一个 MCP Host 应用如何在内部同时管理多个 Server 连接、做工具隔离与健康恢复,为后续各篇的 Client 编码实战打下架构基础。
一、回顾:Host、Client、Server 三层模型
在模块一的第 02 篇中,我们介绍了 MCP 的三层架构。进入模块四后,视角要从“Server 怎么写”切换到“Host 怎么编排 Server”。先做一次精确定义:
| 角色 | 定位 | 数量关系 | 关键职责 |
|---|---|---|---|
| Host | 宿主应用,面向最终用户 | 1 个 | 用户交互、安全策略、多 Client 生命周期管理 |
| Client | 连接器,封装单个 Server 的会话 | N 个(每个 Server 一个) | 协议握手、消息收发、能力缓存 |
| Server | 能力提供者,暴露 Tools/Resources/Prompts | N 个 | 执行工具、读取资源、返回 Prompt |
MCP 的一条铁律是:一个 Client 只对应一个 Server。Host 想要同时使用 N 个 Server,就必须在内部创建 N 个 Client 实例。这种“一对一”约束不是性能限制,而是安全与隔离的设计——每个 Server 运行在独立的信任域,工具调用、资源访问互不干扰,某个 Server 崩溃也不影响其他连接。
1.1 Host 的职责清单
Host 是整个体系的“总调度”,它对上负责用户交互,对下负责管理 Client 池:
| 职责域 | 具体内容 |
|---|---|
| 用户交互 | 渲染对话界面、命令面板、工具授权弹窗、Elicitation 表单 |
| Client 生命周期 | 按配置创建/销毁 Client、维护连接状态机 |
| 能力聚合 | 把多个 Server 的 Tools/Resources/Prompts 汇总成统一视图 |
| 命名空间隔离 | 给每个 Server 的工具加前缀,防止重名冲突 |
| 安全控制 | 用户确认工具执行、过滤敏感参数、限制资源访问范围 |
| 健康与恢复 | 健康检查、自动重连、状态恢复 |
1.2 Client 的职责清单
Client 是 Host 内部的“连接器对象”,职责相对单一:
Client 的内部状态: ├── transport # STDIO 或 Streamable HTTP 传输层 ├── capabilities # 缓存的 Server 能力(tools/resources/prompts) ├── protocol_version # 协商出的协议版本 ├── subscriptions # 当前订阅的通知列表 ├── pending_requests# 未完成的请求(用于取消、超时) └── state # 连接状态(connecting / connected / reconnecting / disconnected)Client 不做用户交互,也不做 LLM 调用——它只负责“和某个 Server 对话”。LLM 调用、工具路由这些逻辑属于 Host 的上层编排(第 29 篇详细讲解)。
二、一对一连接模型
2.1 为什么要一对一
考虑一个反例:如果一个 Client 同时管理两个 Server,会发生什么?
| 问题 | 描述 |
|---|---|
| 能力污染 | 两个 Server 的工具混在一起,无法区分来源 |
| 故障扩散 | 一个 Server 崩溃,连接对象失效,另一个也受影响 |
| 安全边界模糊 | 工具授权、资源访问范围难以按 Server 隔离 |
| 协议状态混乱 | 两个 Server 的_meta、capabilities 各不相同 |
MCP 的解决方案是1 Host : N Clients : N Servers,每个 Client 是一个干净的隔离单元。Host 通过一个“连接注册表”统一管理这些 Client:
Host 进程 ├── ClientRegistry │ ├── Client("filesystem") ──STDIO──> filesystem-server │ ├── Client("github") ──HTTP───> github-server │ ├── Client("database") ──HTTP───> postgres-server │ └── Client("search") ──STDIO──> search-server ├── ToolRouter (按命名空间路由 tools/call) ├── ResourceManager (聚合 resources/list) ├── PromptManager (聚合 prompts/list) └── LLMBackend (第 29 篇)2.2 ClientRegistry 的数据结构
一个连接注册表本质上是一个server_name -> Client的映射,外加状态元数据。下面用 Python 伪代码描述其核心字段:
fromdataclassesimportdataclass,fieldfromenumimportEnumfromtypingimportAnyclassConnState(Enum):CONNECTING="connecting"CONNECTED="connected"RECONNECTING="reconnecting"DISCONNECTED="disconnected"FAILED="failed"# 重试耗尽@dataclassclassClientEntry:name:str# Server 名称,如 "filesystem"client:Any# 底层 Client 实例transport:str# "stdio" 或 "streamable_http"state:ConnState=ConnState.DISCONNECTED capabilities:dict=field(default_factory=dict)# 缓存的能力last_heartbeat:float=0.0# 上次健康检查时间retry_count:int=0# 当前重试次数config:dict=field(default_factory=dict)# 原始连接配置(用于重连)对应的 TypeScript 版本:
enumConnState{Connecting="connecting",Connected="connected",Reconnecting="reconnecting",Disconnected="disconnected",Failed="failed",}interfaceClientEntry{name:string;client:Client;// @modelcontextprotocol/sdk 的 Clienttransport:"stdio"|"streamable_http";state:ConnState;capabilities:Record<string,unknown>;lastHeartbeat:number;retryCount:number;config:ServerConfig;// 保留原始配置用于重连}三、连接池管理与健康检查
当 Host 启动多个 Server 时,需要一套连接池逻辑来统一管理“谁连上了、谁断了、谁需要重连”。
3.1 启动流程
Host 启动时,读取配置文件,为每个 Server 条目创建一个 Client,并发起连接。连接是异步的,可以并行启动以缩短总启动时间:
importasynciofromtypingimportAnyclassHostConnectionPool:def__init__(self,server_configs:list[dict]):self.configs=server_configs self.registry:dict[str,ClientEntry]={}asyncdefstart_all(self)->None:# 并发连接所有 Server,缩短启动时间tasks=[self._connect_one(cfg)forcfginself.configs]awaitasyncio.gather(*tasks,return_exceptions=True)# 打印连接结果摘要forname,entryinself.registry.items():print(f"[{name}] state={entry.state.value}")asyncdef_connect_one(self,cfg:dict)->None:entry=ClientEntry(name=cfg["name"],client=None,transport=cfg["transport"],config=cfg,)self.registry[cfg["name"]]=entrytry:entry.state=ConnState.CONNECTING# 实际连接逻辑见第 25 篇entry.client=awaitself._create_client(cfg)entry.capabilities=awaitself._discover(entry.client)entry.state=ConnState.CONNECTED entry.retry_count=0exceptExceptionasexc:entry.state=ConnState.FAILEDprint(f"[{cfg['name']}] 连接失败:{exc}")3.2 健康检查策略
健康检查的目的是尽早发现“假死”的连接(TCP 还在但 Server 无响应)。两种常见策略:
| 策略 | 做法 | 适用传输 | 开销 |
|---|---|---|---|
| 心跳 ping | 定期发送一个轻量 JSON-RPC 请求(如 ping),超时则标记异常 | Streamable HTTP | 中 |
| 进程探活 | 检查子进程是否存活(PID 是否存在) | STDIO | 低 |
| 被动检测 | 任何请求超时即标记异常,不主动探测 | 两者 | 0 |
STDIO 传输下,Server 是 Host 的子进程,child.poll()返回非 None 即说明进程已退出,无需额外 ping。HTTP 传输下,由于连接可能经过代理、负载均衡,需要主动心跳。
一个折中方案是被动检测为主 + 低频主动 ping 为辅:
asyncdefhealth_check_loop(self,interval:float=30.0)->None:whileTrue:awaitasyncio.sleep(interval)forname,entryinlist(self.registry.items()):ifentry.state!=ConnState.CONNECTED:continueifentry.transport=="stdio":# 检查子进程存活proc=entry.config.get("_process")ifprocandproc.poll()isnotNone:entry.state=ConnState.DISCONNECTEDprint(f"[{name}] 子进程已退出,触发重连")asyncio.create_task(self._reconnect(entry))else:# HTTP 主动 pingtry:awaitasyncio.wait_for(entry.client.ping(),timeout=5.0)entry.last_heartbeat=asyncio.get_event_loop().time()exceptException:entry.state=ConnState.DISCONNECTED asyncio.create_task(self._reconnect(entry))四、Server 重连策略:指数退避
网络抖动、Server 重启、OOM 都会导致连接中断。Host 必须能自动重连,而不是把异常抛给用户。
4.1 重连参数
MCP 实践中常用的重连参数(本系列采用):
| 参数 | 取值 | 说明 |
|---|---|---|
| 最大重试次数 | 5 | 超过后标记为FAILED,通知用户 |
| 初始退避 | 1 秒 | 第一次重试前等待 |
| 退避倍率 | 2.0 | 每次失败后翻倍 |
| 最大退避 | 60 秒 | 退避时间的上限 |
| 抖动(jitter) | ±20% | 避免多个 Client 同时重连(惊群效应) |
4.2 指数退避算法
核心公式(带抖动):
base = min(initial * (multiplier ^ retry_count), max_backoff) jitter = base * random(-0.2, 0.2) delay = base + jitterPython 实现:
importasyncioimportrandomasyncdefreconnect_with_backoff(entry:ClientEntry,connect_fn,initial:float=1.0,multiplier:float=2.0,max_backoff:float=60.0,max_retries:int=5,)->bool:"""对断开的连接执行指数退避重连,成功返回 True。"""forattemptinrange(max_retries):entry.state=ConnState.RECONNECTING base=min(initial*(multiplier**attempt),max_backoff)jitter=base*random.uniform(-0.2,0.2)delay=max(0.1,base+jitter)print(f"[{entry.name}] 第{attempt+1}/{max_retries}次重连,"f"等待{delay:.1f}s")awaitasyncio.sleep(delay)try:entry.client=awaitconnect_fn(entry.config)entry.capabilities=awaitentry.client.discover()entry.state=ConnState.CONNECTED entry.retry_count=0print(f"[{entry.name}] 重连成功")returnTrueexceptExceptionasexc:print(f"[{entry.name}] 重连失败:{exc}")entry.state=ConnState.FAILEDprint(f"[{entry.name}] 重试耗尽,标记为 FAILED")returnFalse对应的退避时间表(无抖动时的基准值):
| 重试次数 | 等待时间 | 累计等待 |
|---|---|---|
| 第 1 次 | 1s | 1s |
| 第 2 次 | 2s | 3s |
| 第 3 次 | 4s | 7s |
| 第 4 次 | 8s | 15s |
| 第 5 次 | 16s | 31s |
注意:5 次重试后累计约 31 秒(加上抖动可能到 40 秒左右),仍在可接受范围内。60 秒的上限主要针对长退避场景,避免极端情况下等待过久。
4.3 重连后的状态恢复
重连成功不等于“状态恢复”。新连接的 Server 可能已经变更了工具列表(升级、配置改动)。因此重连后必须:
- 重新执行
discover,刷新 capabilities 缓存; - 重新调用
tools/list、resources/list、prompts/list,重建本地视图; - 通过
subscriptions/listen重新订阅通知(旧订阅在新连接上无效); - 触发 Host 上层的 UI 刷新。
状态恢复的逻辑在第 30 篇会详细展开,这里只强调一个原则:重连 = 建立连接 + 重建状态,两者缺一不可。
五、工具命名空间隔离
当多个 Server 同时提供工具时,必然出现命名冲突——两个 Server 都可能有名为search的工具。Host 必须有一套命名空间隔离机制。
5.1 命名规则:mcp_{server}_{tool}
本系列采用的命名约定是mcp_{server_name}_{tool_name}:
| Server 名称 | 原始工具名 | 隔离后工具名 |
|---|---|---|
| filesystem | read_file | mcp_filesystem_read_file |
| github | search | mcp_github_search |
| database | query | mcp_database_query |
| search | search | mcp_search_search |
这种三段式命名有两个好处:
- 无歧义:用户和 LLM 都能从工具名直接判断它来自哪个 Server;
- 路由简单:Host 从工具名解析出
server_name,就能定位到对应的 Client。
5.2 工具聚合与路由
Host 维护一个“全局工具表”,把所有 Client 的工具加前缀后合并:
fromtypingimportAnyclassToolRouter:def__init__(self,pool:"HostConnectionPool"):self.pool=pool self.global_tools:dict[str,dict]={}# namespaced_name -> tool_metaasyncdefrefresh_all(self)->None:"""从所有已连接 Client 重新拉取工具列表。"""self.global_tools.clear()forname,entryinself.pool.registry.items():ifentry.state!=ConnState.CONNECTED:continuetools=awaitentry.client.list_tools()fortoolintools:ns_name=f"mcp_{name}_{tool['name']}"# 同时保存原始名和来源 Server,便于路由self.global_tools[ns_name]={**tool,"_server":name,"_original_name":tool["name"],}defresolve(self,namespaced_name:str)->tuple[str,str]|None:"""把带前缀的工具名解析为 (server_name, original_tool_name)。"""meta=self.global_tools.get(namespaced_name)ifnotmeta:returnNonereturnmeta["_server"],meta["_original_name"]TypeScript 版本:
interfaceToolMeta{name:string;description?:string;inputSchema:Record<string,unknown>;_server:string;_original_name:string;}classToolRouter{privateglobalTools=newMap<string,ToolMeta>();constructor(privatepool:HostConnectionPool){}asyncrefreshAll():Promise<void>{this.globalTools.clear();for(const[name,entry]ofthis.pool.registry){if(entry.state!==ConnState.Connected)continue;consttools=awaitentry.client.listTools();for(consttooloftools){constnsName=`mcp_${name}_${tool.name}`;this.globalTools.set(nsName,{...tool,_server:name,_original_name:tool.name,});}}}resolve(nsName:string):{server:string;tool:string}|null{constmeta=this.globalTools.get(nsName);if(!meta)returnnull;return{server:meta._server,tool:meta._original_name};}}5.3 对 LLM 如何呈现
向 LLM 描述工具时,可以直接使用带前缀的名称,也可以保留原始名但在描述中注明来源。推荐前者,因为 LLM 调用工具时返回的 name 字段必须能被 Host 路由:
# 传给 LLM 的工具列表(摘要) mcp_filesystem_read_file : 读取本地文件内容 mcp_github_search : 搜索 GitHub 仓库 mcp_database_query : 执行 SQL 查询 mcp_search_search : 全文搜索知识库LLM 返回mcp_database_query,Host 解析出server=database, tool=query,路由到对应 Client 发起tools/call。第 29 篇会完整展示这个循环。
六、典型多 Server Host 架构图
把前面的组件组合起来,一个生产级 MCP Host 的内部结构如下(ASCII 架构图):
┌─────────────────────────────────────────────────────────────┐ │ Host 应用 │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ │ │ UI 层 │ │ LLM Backend │ │ 安全策略层 │ │ │ │ (对话/表单) │ │ (tool calling)│ │ (授权/过滤) │ │ │ └──────┬──────┘ └──────┬───────┘ └───────┬────────┘ │ │ │ │ │ │ │ └────────────┬────┴───────────────────┘ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 编排层 (Orchestrator) │ │ │ │ ToolRouter · ResourceManager · PromptManager │ │ │ └──────────────────────┬───────────────────────────────┘ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ ClientRegistry (连接池) │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ │ │ Client A │ │ Client B │ │ Client C │ │ │ │ │ │ (stdio) │ │ (http) │ │ (http) │ │ │ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ └────────┼─────────────┼─────────────┼────────────────┘ │ └───────────┼─────────────┼─────────────┼───────────────────┘ │ STDIO │ HTTP+SSE │ HTTP+SSE ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │Server A│ │Server B│ │Server C│ │(本地) │ │(远程) │ │(远程) │ └────────┘ └────────┘ └────────┘各层职责小结:
| 层 | 核心组件 | 关注点 |
|---|---|---|
| UI 层 | 对话窗口、Elicitation 表单、授权弹窗 | 用户体验、交互流畅 |
| 编排层 | ToolRouter、ResourceManager、PromptManager | 命名空间、路由、聚合 |
| 连接层 | ClientRegistry、健康检查、重连 | 连接生命周期、容错 |
| 传输层 | STDIO / Streamable HTTP | 字节流、消息帧 |
理解这张图后,后续各篇就是在逐层填实现:
- 第 25 篇实现“连接层”的建立逻辑;
- 第 26-27 篇实现“编排层”的工具、资源、Prompt 聚合;
- 第 28 篇实现“UI 层”的 Elicitation 表单;
- 第 29 篇实现“LLM Backend”与编排层的联动;
- 第 30 篇实现“连接层”的通知订阅与状态恢复。
本篇小结
| 知识点 | 核心内容 |
|---|---|
| 三层模型 | Host 管理多个 Client,每个 Client 一对一连接一个 Server |
| Host 职责 | 用户交互、Client 生命周期、能力聚合、命名空间隔离、安全控制、健康恢复 |
| Client 职责 | 单 Server 会话管理,缓存 capabilities,不负责 LLM 调用 |
| 连接池 | server_name -> ClientEntry映射,含状态机与重试计数 |
| 健康检查 | STDIO 用进程探活,HTTP 用 ping,默认被动检测 + 低频主动 ping |
| 重连策略 | 指数退避:初始 1s、倍率 2、上限 60s、最多 5 次、带 ±20% 抖动 |
| 命名空间 | mcp_{server}_{tool}三段式,防冲突 + 便于路由 |
| 架构分层 | UI 层 / 编排层 / 连接层 / 传输层,职责清晰 |
下篇预告
第 25 篇:连接 Server——STDIO Client 与 HTTP Client 实现
从架构走向代码——用 Python SDK 和 TypeScript SDK 真正建立到 Server 的连接,讲解上下文管理器、超时配置、环境变量传递和子进程安全隔离。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。