6. MCP

Harness Engineering

6. MCP

6.1 原理:连接生命周期与目录快照生命周期

MCP 把外部能力统一为 Server 暴露的 Tools、Resources、Prompts。Harness 作为 Client,负责配置合并、连接、能力发现、命名空间、权限、调用、结果归一化和失效刷新。

理解 MCP 加载时要同时画出两条生命周期:

  • 连接生命周期:配置 → transport → initialize → ready/needs-auth/failed → reconnect/close;
  • 目录快照生命周期:list tools/resources/prompts → 规范化与策略过滤 → 模型暴露 → list_changed/配置变化 → 失效并发布新快照。

两者不能混成一个“已连接”布尔值。连接仍然存活,不代表工具目录没有变化;工具 Schema 已缓存,也不代表原连接、认证或 Session ID 仍然有效。最强的一致性边界是“一次模型采样看到的工具目录,与执行该 ToolCall 的 client、配置、权限和 catalog revision 属于同一个 binding”。

完整链路:

配置加载                                                       // 读取 MCP Server 地址、传输方式、认证和启停配置
 → 建立 stdio / SSE / Streamable HTTP / in-process transport   // 创建与 Server 匹配的双向通信通道
 → initialize 能力协商                                         // 交换协议版本及双方支持的能力集合
 → list_tools/list_resources/list_prompts                      // 发现 Server 当前暴露的工具、资源和提示模板
 → 转为模型 Tool Schema                                        // 将 MCP Tool 定义转换成模型可理解的函数 Schema
 → 模型生成 tool call                                          // 模型基于工具描述产生全限定名和结构化参数
 → 权限检查与审批                                              // 在访问外部 Server 前校验身份、范围和风险
 → call_tool                                                   // 通过已初始化连接发送实际工具调用请求
 → 进度/elicitation/错误/超时处理                              // 消费进度、补充信息请求及各种协议终态
 → 结果大小控制与上下文注入                                   // 对结果校验、裁剪,或保存到外部文件并只保留引用

关键问题:

  • 命名冲突:使用 mcp__server__tool 全限定名,权限规则也必须匹配全限定名,避免外部工具伪装成内建 Write。
  • 能力与连接分离:工具清单可缓存,但连接断开、会话过期或授权过期要重新初始化。
  • 大结果限制:MCP 输出可能远超上下文,应分页、过滤、截断或存入外部文件,只给模型摘要和受控引用。
  • 不可信元数据:Tool 描述、schema、resource URI 和 _meta 都来自外部 Server,需要长度上限、协议校验和安全渲染。
  • 认证:远程 Server 可能使用 OAuth;Token 应放安全存储,支持过期刷新、撤销与 step-up authorization。

6.2 MCP 加载、刷新与调用绑定图

图表加载中…

图中的“预热”只是把 D—L 提前执行来降低延迟;真正的正确性仍由 M—P 保证。否则后台刷新恰好与 ToolCall 并发时,模型可能拿旧 Schema 调新 Server。

6.3 Claude Code:两阶段配置、AppState 目录与定向失效

6.3.1 配置加载与连接状态

Claude Code 的 MCP 管理器先把新 Server 放入 pending,配置被删除或连接参数变化时识别 stale client,先取消旧重连定时器,再清理旧连接,避免旧闭包用过期配置重新连回并覆盖新状态:useManageMCPConnections.ts:765、useManageMCPConnections.ts:782、useManageMCPConnections.ts:791。

真正连接采用两阶段加载:先读取本地、项目、插件和动态配置并立即连接;远端账户配置的网络请求同时启动,返回后经过策略过滤与内容去重,再作为第二批连接。这样慢远端配置不会阻塞本地 stdio Server:useManageMCPConnections.ts:856、useManageMCPConnections.ts:861、useManageMCPConnections.ts:875、useManageMCPConnections.ts:889、useManageMCPConnections.ts:904。

Server 状态至少包括 pending、connected、needs-auth、failed、disabled。disabled Server 在连接前就被分流,近期 401 或只有 OAuth discovery 没有 Token 的远端 Server直接进入 needs-auth,并只暴露认证工具,避免周期性无效探测:client.ts:2228、client.ts:2243、client.ts:2303、client.ts:2317。

本地 stdio/sdk 与远端网络 Server 使用两个并发队列。本地队列并发更低以限制进程创建资源竞争,远端队列并发更高;pMap 按槽位连续调度,某个慢 Server 不会卡住下一整批:client.ts:2214、client.ts:2220、client.ts:2266、client.ts:2390。

6.3.2 initialize 后如何形成模型工具目录

client.connect(transport) 完成 MCP initialize,随后读取 Server capabilities、version 和 instructions;instructions 超长会被截断:client.ts:1050、client.ts:1159、client.ts:1161、client.ts:1163。只有连接成功后,才按 capabilities 并行加载 Tools、Prompts、MCP Skills 和 Resources:client.ts:2326、client.ts:2344、client.ts:2346。

Tools 通过 tools/list 获取并先清理异常 Unicode,再转换为内部 Tool。默认名称使用 mcp__server__tool,同时保存原始 server/tool 映射;外部 _meta.searchHint 会压平换行,description 有长度上限,readOnly/destructive/openWorld annotations 被转换为权限和并发判断输入:client.ts:1745、client.ts:1754、client.ts:1759、client.ts:1768、client.ts:1770、client.ts:1778、client.ts:1791、client.ts:1797。

MCP Tool 的内部 checkPermissions 返回 passthrough,让统一权限层按全限定名和参数决定,而不是信任 Server 自报的安全属性:client.ts:1816。Prompts 通过 prompts/list 转成动态 Command,实际调用时再 getPrompt;Resources 通过 resources/list 附加 server 名称:client.ts:2002、client.ts:2035、client.ts:2044、client.ts:2075。

连接结果不会逐个立刻重渲染 AppState。管理器把同一时间窗口内到达的 Server 更新放入队列,每 16ms 批量合并;更新某个 Server 时只替换它的 tools、commands 和 resources,disabled/failed 会明确清空旧能力,防止幽灵工具继续出现在模型目录:useManageMCPConnections.ts:203、useManageMCPConnections.ts:216、useManageMCPConnections.ts:245、useManageMCPConnections.ts:255、useManageMCPConnections.ts:293。

6.3.3 缓存失效必须和变化类型对应

Tools、Prompts、Resources 与 MCP Skills 使用按 Server 名称键控的有界 LRU 缓存:client.ts:1726、client.ts:1745、client.ts:2002、client.ts:2035。连接关闭时不仅删除连接 memo,还同时删除四类 fetch 缓存,否则新连接会继承旧目录:client.ts:1375、client.ts:1385、client.ts:1388、client.ts:1398。

Server 声明 listChanged capability 后,管理器注册三类定向刷新:

  • tools/list_changed:只删除 tools cache,重新 list tools 并替换该 Server 的工具:useManageMCPConnections.ts:616、useManageMCPConnections.ts:627、useManageMCPConnections.ts:631;
  • prompts/list_changed:删除 prompts cache,同时复用 MCP Skills cache,再合并新 commands:useManageMCPConnections.ts:667、useManageMCPConnections.ts:679、useManageMCPConnections.ts:681;
  • resources/list_changed:删除 resources cache;由于 MCP Skills 来自 Resources,还要删除 MCP Skills 与 prompts cache,并发重建三者,避免并发通知最后写入陈旧 commands:useManageMCPConnections.ts:705、useManageMCPConnections.ts:717、useManageMCPConnections.ts:719、useManageMCPConnections.ts:725。

这说明缓存失效图必须跟依赖图一致:MCP Skill 依赖 Resources,所以 Resource 变化会向上影响 Skill Search Index;Prompt 变化却不应无故重读 Resource Skill。

6.3.4 调用时重连与失败恢复

内部 Tool 闭包保存发现时的 tool metadata,但每次调用前会 ensureConnectedClient,连接缓存失效后取得新 client,再执行 URL Elicitation 与 callTool:client.ts:1835、client.ts:1860、client.ts:1864、client.ts:1865。HTTP MCP Session 过期时只自动恢复重试一次,避免无限重放可能有副作用的 ToolCall:client.ts:1861、client.ts:1913、client.ts:1916。

远端 transport 关闭使用指数退避重连;stdio/sdk 不做同样的自动重连。达到最大次数后进入 failed 并清空目录:useManageMCPConnections.ts:354、useManageMCPConnections.ts:370、useManageMCPConnections.ts:417、useManageMCPConnections.ts:446。这种实现通过 AppState 更新保持 UI 与目录一致,但没有像 Codex PreparedMcpCall 那样把 ToolCall 显式绑定到 catalog revision;设计新系统时应补上版本检查,避免重连后 Schema 已变化而旧闭包继续调用。

6.4 Codex:不可变 Runtime、Step Binding 与版本拒绝

6.4.1 Desired State、dirty 和唯一发布者

Codex 先从 Session 配置、认证、Turn 环境、sandbox 和已选择 capability roots 计算 MCP desired state,再生成当前 Step 的 Runtime projection:mcp.rs:93、mcp.rs:104、mcp.rs:118。配置、认证或 capability root 变化只负责标记 dirty;refresh_mcp_if_dirty 通过单 permit semaphore 保证同一时间只有一个发布者,claim 后重新读取最新期望状态:mcp.rs:150、mcp.rs:170、mcp.rs:177、mcp.rs:195。

刷新任务如果在发布前被取消,McpRefreshInvalidationGuard 会把 dirty 恢复,确保下一次 Step 不会误以为刷新已经完成:mcp_refresh.rs:7、mcp_refresh.rs:21、mcp_refresh.rs:42、mcp_refresh.rs:48。后台预热使用有界 channel 合并重复请求,只提前准备最新状态;源码明确指出 exact model step 才是正确性路径:mcp_prewarm.rs:1、mcp_prewarm.rs:9、mcp_prewarm.rs:45、mcp_prewarm.rs:58。

6.4.2 ArcSwap 发布与旧请求继续持有旧快照

McpRuntime 用 ArcSwap<PublishedMcpRuntime> 保存当前不可变快照,快照包含连接集、配置、认证、插件状态和 capability roots:runtime.rs:70、runtime.rs:74、runtime.rs:80。刷新时先构造新的 McpConnectionSet,完整构造后一次 store 原子替换,再打开 publication gate;读取者不会观察到“新配置 + 旧连接”这种半发布状态:runtime.rs:163、runtime.rs:184、runtime.rs:191、runtime.rs:200。

原子替换不会强制杀死正在执行的旧请求。旧的 McpBinding 持有自己的 Arc<McpConnectionSet>、clients、config、tools 和 prepared calls,直到该采样请求结束:binding.rs:1、binding.rs:29、binding.rs:31。这比“全局连接对象就地修改”更容易证明并发正确性。

6.4.3 required Server 与 Step 目录固化

Turn 会从显式 mcp://server mention、Skill 依赖和插件能力收集 required servers。mcp_runtime_for_step 先刷新 dirty Runtime,再捕获需要的 Server 已完成 startup 的 binding;初始化阶段也会把所有 required Server 失败聚合后一次报告:turn.rs:650、turn.rs:692、mcp.rs:278、mcp.rs:295、required.rs:10、required.rs:15。

McpBinding 的 tools() 是当前模型采样看到的冻结目录;prepare_call 返回同一 binding 中已经绑定的 exact client 和 metadata,而不是再去全局 Map 用名称查一次:binding.rs:78、binding.rs:83。Session 侧单独提供 prepare_mcp_call,先刷新,再从当前 binding 准备调用:mcp_runtime.rs:43、mcp_runtime.rs:49。

更关键的是 PreparedMcpCall 保存准备时的 catalog_revision 和共享 revision source。真正执行副作用前再次比较;如果目录已经替换,旧调用被拒绝并要求重新构建 Step,而不是拿旧参数调用新 Schema:binding.rs:159、binding.rs:166、binding.rs:260、binding.rs:271。

6.4.4 initialize、目录缓存与工具暴露预算

每个 Server 完成 initialize 后读取 capability。Server 可通过 experimental capability 明确禁止 Tool Catalog Cache;随后才执行 uncached list tools,并把结果发布到共享 cache,只有最新 fetch ticket 能覆盖缓存:rmcp_client.rs:843、rmcp_client.rs:864、rmcp_client.rs:869、rmcp_client.rs:888、rmcp_client.rs:892、rmcp_client.rs:905。

工具进入模型前还要经过暴露策略。普通 MCP Tool 先过滤 model visibility;Apps Tool 还必须有 connector id,并经过 App policy 对 destructive/open-world 等属性判断:mcp_tool_exposure.rs:21、mcp_tool_exposure.rs:30、mcp_tool_exposure.rs:90、mcp_tool_exposure.rs:98。开启 Tool Search 时工具默认 deferred,否则 direct;Agent Plugin MCP Tool 还受单个 8KB、总计 64KB Schema 预算约束,超限工具 hidden 而不是继续挤占 Prompt:mcp_tool_exposure.rs:14、mcp_tool_exposure.rs:36、mcp_tool_exposure.rs:55、mcp_tool_exposure.rs:71。

6.5 两套 MCP 加载策略对比

维度Claude CodeCodex设计结论
配置加载本地配置先连接,远端账户配置第二阶段到达每 Step 从 Session、环境、认证投影 desired state慢配置源不应阻塞本地能力,但 Step 前必须固化最终视图
连接状态AppState 保存 pending/connected/needs-auth/failed/disabledMcpRuntime 发布连接集,required Server 可等待并聚合失败连接状态与工具目录状态要分开建模
并发本地与远端使用不同并发上限ConnectionSet 并发启动,Runtime 由单一 gate 发布并发优化不能制造不确定发布顺序
目录缓存按 Server 名称的有界 LRU,通知驱动定向失效initialize 后共享 Tool Catalog Cache,可由 Server 禁用缓存键必须包含足够的 transport、认证和环境身份
发布方式16ms 批量更新 AppState 中该 Server 的能力ArcSwap 原子替换不可变 Published Runtime读者不能看到半新半旧的配置与连接组合
Tool 暴露全限定名、描述截断、风险 annotations、统一权限 passthroughvisibility、App policy、direct/deferred/hidden 与字节预算协议发现结果不能未经治理直接进入模型 Prompt
调用绑定Tool 闭包调用前确保连接有效,Session 过期最多重试一次Step 冻结 McpBinding,Prepared Call 校验 catalog revisionSchema、client、权限与调用必须来自同一版本
变化通知tools/prompts/resources 分别失效,Resource 变化联动 MCP Skilldirty → refresh gate → 构造新连接集 → 原子发布失效传播应沿真实依赖图,而不是全部清空或完全不清

6.6 参考实现伪代码

async function refreshMcpRuntimeIfDirty(session):                              // 在模型 Step 前发布与最新配置一致的 MCP Runtime
    if not session.mcpRefresh.pending:                                         // 先走无变化快速路径,避免每个 Step 重建所有连接
        return session.mcpRuntime.current                                      // 返回当前已发布的不可变 Runtime 快照
    lock = await session.mcpRefresh.singlePublisher.acquire()                  // 获取唯一发布权,防止多个刷新任务交错覆盖
    try:                                                                        // 确保取消或异常时能够恢复 dirty 状态
        if not session.mcpRefresh.claimLatestInvalidation():                   // 再次确认本任务确实领取到尚未处理的变化
            return session.mcpRuntime.current                                  // 其他发布者已完成时直接复用最新快照
        guard = restoreDirtyUnlessPublished(session.mcpRefresh)                // 创建发布保护器,未完成发布就自动恢复 pending
        desired = snapshotConfigAuthEnvironmentAndPlugins(session)             // 一次读取配置、认证、环境、插件和权限期望状态
        servers = resolveEffectiveServers(desired)                             // 合并来源、去重并过滤 disabled 或策略禁止的 Server
        connections = await connectWithTransportAwareLimits(servers)           // 按本地进程与远端网络使用不同并发上限建立连接
        catalogs = await initializeAndListCapabilities(connections)            // 完成 initialize 后并行发现 Tools、Resources、Prompts 和 Skills
        governed = validateNamespaceFilterAndBudget(catalogs, desired.policy)  // 校验外部元数据并执行命名空间、可见性、权限和字节预算
        nextRuntime = freezeRuntime(desired, connections, governed)             // 冻结配置、client 与目录,禁止发布后就地修改
        session.mcpRuntime.atomicPublish(nextRuntime)                           // 一次原子替换,让读者只看到完整旧快照或完整新快照
        guard.markPublished()                                                   // 标记发布成功,取消保护器的 dirty 恢复动作
        return nextRuntime                                                      // 返回可供当前 Step 捕获的最新 Runtime
    finally:                                                                    // 无论成功、失败还是取消都要释放唯一发布权
        lock.release()                                                          // 允许后续配置变化启动下一次刷新

async function executeBoundMcpCall(stepBinding, fqName, args):                 // 使用模型采样时冻结的目录执行一次 MCP ToolCall
    server, tool = parseQualifiedMcpName(fqName)                                // 从全限定名解析 Server 与 Tool,避免外部工具冒充内建能力
    prepared = stepBinding.prepareCall(server, tool)                            // 取得与该 Step 的 Schema、client、配置和权限绑定的调用对象
    require(prepared != null)                                                   // 工具不在冻结目录中时拒绝执行,不能回退到全局最新名称查找
    require(await prepared.catalogRevisionStillCurrent())                      // 在副作用前确认目录版本未被刷新替换
    validatedArgs = validate(prepared.inputSchema, args)                        // 按模型实际看到的同一份 Schema 验证结构化参数
    decision = await approveMcpAction(prepared.authority, validatedArgs)        // 使用绑定的 Server 来源和权限策略完成审批或等待用户
    require(decision.isAllowed)                                                 // 拒绝、取消或超时都不能向远端发送 call_tool
    result = await prepared.callWithTimeoutAndElicitation(validatedArgs)        // 在可取消超时内调用,并处理进度与补充信息请求
    normalized = validateExternalMcpContent(result)                             // 校验外部内容块、URI、结构化数据和不可信元信息
    if estimateTokens(normalized) > stepBinding.outputBudget:                   // 判断完整响应是否会挤占过多上下文窗口
        handle = persistLargeMcpOutput(normalized)                              // 把大结果保存到受控存储并生成后续读取句柄
        return summarizeWithBoundedReference(normalized, handle)                // 只向模型返回有界摘要和明确的按需读取方式
    return normalized                                                           // 小结果直接作为 ToolResult 回到 Agent Loop

6.7 出现异常时如何判断

层级典型现象应检查的状态或证据不应做的错误重试
配置层Server 不在列表或重复出现scope、动态配置、插件重载键、内容去重、stale config hash不要在未确认来源时反复新增同名配置
认证层needs-auth、401、Token 更新后仍失败OAuth discovery、安全存储缓存、auth version、账号是否变化不要按固定周期无限探测无 Token Server
Transport 层timeout、ECONNRESET、进程退出stdio stderr、HTTP/SSE close、连接 uptime、重连次数不确认幂等性时不要自动重放已经发出的 ToolCall
协议层initialize 成功但没有工具capabilities、tools/list 响应、Server 是否关闭目录缓存不要把“空目录”直接判定成网络故障
目录层工具仍是旧 Schema 或出现幽灵工具list_changed、fetch cache、catalog revision、disabled/failed 是否清目录不要只重连却保留旧 tools/resources/prompts cache
暴露层Server 有 Tool 但模型看不到model visibility、App policy、deferred search、Schema 字节预算不要绕过策略把远端原始列表直接注入 Prompt
权限层工具可见但调用被拒绝全限定名规则、参数风险、Server 来源、Elicitation 状态不要把 Server 的 readOnlyHint 当作本地最终授权
执行层Session 过期、目录变化后调用失败exact client、catalog revision、是否允许一次安全恢复不要用旧参数静默调用新版本 Schema
输出层Tool 成功但上下文爆满内容类型、Token 估算、二进制与大文本持久化不要把未验证的完整外部响应直接拼进会话

6.8 高频工程检验题

问:MCP 是不是 Function Calling 的替代品?

不是。Function Calling 是模型输出结构化工具意图的机制;MCP 是 Harness 与外部能力提供方之间的标准协议。常见链路是“模型 Function Call → Harness 路由 → 本地权限与版本检查 → MCP call_tool”。

问:为什么连接成功后还可能不能调用工具?

因为连接、目录、暴露和权限是四个阶段。initialize 成功只表示协议连接建立;Server 可能没有声明 tools capability,list 结果可能被策略或字节预算隐藏,ToolCall 还可能被本地审批拒绝。诊断时应逐层看状态,不应只看 connected。

问:MCP 工具变化后为什么推荐重新采样?

模型生成参数时依赖当时看到的 description 和 input schema。如果目录变化后仍执行旧 ToolCall,就破坏了判断与执行的一致性。Codex 的 Prepared Call 会检查 catalog revision,变化后拒绝旧调用;通用实现也应重新构建 Step,而不是把旧参数交给新工具。

6.9 原理深化:MCP 的一致性边界是“连接版本 + 工具快照 + 调用授权”

MCP Client 不能只缓存一张工具表。initialize 协商的是某次连接的能力,tools/list 得到的是该连接版本的目录,权限决策又依赖当前 Session 和参数。可靠实现必须把三者一起绑定:模型看到的 Schema、执行使用的 client、批准使用的 authority 必须属于同一个可验证版本。

Claude Code 的优势是产品状态完整:两阶段配置加载、细粒度 list_changed、AppState 批量更新、needs-auth 与远程重连都直接服务于交互体验。Codex 的优势是并发正确性边界清晰:dirty 只表示期望变化,单一 gate 构造新 Runtime,ArcSwap 原子发布,Step 捕获不可变 binding,Prepared Call 在副作用前检查 catalog revision。

把两者合起来,可以得到企业级 MCP Harness 的四条原则:

  1. 发现结果不等于模型权限:所有远端 Tool 都要经过命名空间、策略、预算和本地审批;
  2. 预热不承担正确性:后台任务只降低延迟,Step 前的刷新与 binding 才决定执行版本;
  3. 失效传播遵循依赖图:Resource 变化可能影响 MCP Skill,连接关闭必须同时失效其目录缓存;
  4. 失败按阶段分类:Transport、Protocol、Catalog、Permission、Tool 和 Output 失败具有不同重试语义。

这套设计避免 MCP 成为绕过 Harness 的“协议捷径”。无论能力来自本地进程、远端服务、插件还是 App Connector,进入 Agent Loop 后都必须服从同一套快照、审批、可观测、输出预算和恢复规则。

6.10 DeepSeek Harness:MCP 工具目录按连接代际原子替换

packages/mcp/mcp-client 的每个插件实例只连接一个 stdio 或 Streamable HTTP Server,serverName 在同一 Cordis app 内必须唯一。模型可见名称为 mcp__<serverName>__<rawName>;超长或非法字符会规范化并追加身份 hash,线路上始终使用原始 tool name,不反向解析公开名。

mcp-client/src/tools.ts 用两阶段同步工具:先分页拉取并构造完整新代定义,任何失败都保留旧代;再撤销旧 disposer 并注册新代,如果名称冲突则回滚到“该 Server 零工具”,不暴露半套目录。连接监督器负责首次就绪、掉线重连、调用超时、在途任务静默化和卸载;MCP isError 被转换为统一 Tool Runtime 错误结果。

最后更新 8/17/2026, 6:27:24 PM