加载中...
  • MCP 的工程真相:协议很好,但连接不是免费的 loading

    吃透 AI Agent 开发 · 第 13 篇 · 第三章 · Tool System

    MCP 让远程能力有了统一插槽,但连接、超时、版本、权限和数据边界仍然要由宿主应用负责。

    团队提出一个需求:“让 Agent 按公司的发布流程操作 Jira。”有人建议写 Skill,有人建议接 MCP,还有人想用 Hook 拦截所有命令。

    这些方案都可能有用,但它们解决的不是同一层问题。扩展 Agent 前,先问清楚要增加的是知识、快捷模板、组织策略、本地执行能力,还是远程服务。

    六种扩展各管一件事

    机制 解决什么 会不会执行代码 例子
    Skill 教 Agent 怎样完成一类工作 间接,通过现有工具 发布检查流程、代码评审方法
    Output Style 约束回答的组织和语气 不会 教学体、事故报告格式
    User Command 展开可复用 Prompt 模板 不会 /review $file
    Hook 观察或干预生命周期 可以 部署前审批、工具结果脱敏
    Local Tool 增加本机可执行能力 调内部 CLI、读专用文件格式
    MCP 接入标准化远程工具服务 Jira、数据库、浏览器服务

    最容易混淆的是 Skill 和 Tool。Skill 告诉 Agent“应该怎样做”,Tool 让 Agent“真的能够做”。一份发布 Skill 可以要求先跑测试、再看 diff、最后创建版本;真正执行测试和创建版本仍靠工具。

    用一个 Jira 需求走一遍

    “按公司流程处理 Jira”可以拆成:

    • 公司要求先读取工单、定位代码、给出方案,再等待批准:这是知识和流程,适合 Skill。
    • /fix-jira ABC-123 自动展开固定请求:这是 User Command。
    • 禁止未经批准把评论发到 Jira:这是执行策略,适合 Hook 或权限层。
    • 真正读取和评论 Jira:这是远程能力,适合 MCP 或专用 Tool。
    • 最终回答必须包含风险和验证结果:可以由 Skill 规定,或用 Output Style 统一格式。

    一个需求往往会组合多种扩展。关键是每层只承担自己的责任。

    Skill 为什么要渐进式披露

    把所有 Skill 正文都放进 system prompt,会迅速膨胀上下文。更合理的流程是:启动时只暴露名称和简短描述;任务命中后,Agent 再完整读取对应说明;说明引用的脚本和参考资料按需加载。

    Skill 的价值在于把可靠工作流和领域知识打包,而不是偷偷获得额外权限。它调用的工具仍要经过同一套权限、Hook 和审计。

    同名 Skill 还需要明确优先级,例如项目级覆盖用户级。否则不同机器加载顺序不同,同一个名称可能执行不同流程。

    User Command 不是 Shell 别名

    用户命令适合把重复输入变成模板:

    /review src/auth.ts
    
    展开为:
    请从安全、正确性和可测试性三个角度评审 src/auth.ts,
    只允许使用读取和搜索工具,不修改文件。
    

    模板只产生 Prompt,不应直接执行 Shell。它可以为本轮选择模型或收窄工具,但不能扩大权限,也不能绕过 Hook。

    命令参数要经过 tokenizer,而不是简单 split(' '),否则带空格的路径和引号很容易解析错。

    Hook 适合横切策略

    Hook 看到生命周期事件,例如会话开始、Prompt 提交、工具执行前后和子任务结束。它适合组织级规则:

    • 命令包含生产环境时要求确认。
    • 工具输出写入审计前先脱敏。
    • 子 Agent 完成后把摘要发到内部通知系统。

    Hook 不适合承载主要业务能力。用 pre-hook 偷偷替模型读取 Jira,调用轨迹会变得不可见,也难以让模型理解结果来自哪里。

    Hook 协议要有明确的超时、退出码和失败语义。安全 Hook 失败时默认阻断,可选统计 Hook 失败时允许降级。

    什么时候选 MCP

    MCP 的优势是标准化发现、Schema 和调用协议,一个服务可以被多个 Agent 客户端复用。适合已经服务化、需要跨项目共享的外部能力。

    但 MCP 不会自动解决:

    • 服务端本身的权限设计。
    • 工具过多导致的选择困难。
    • 网络超时和版本兼容。
    • 敏感结果是否应该进入上下文。

    如果只是当前项目调用一个稳定的本地脚本,专用 Tool 往往更简单。为了“看起来标准”强行上 MCP,会增加进程、配置和排障成本。

    一棵简单决策树

    面对新需求,可以依次问:

    只是改变回答形式? -> Output Style
    只是复用一段请求? -> User Command
    是知识和步骤? -> Skill
    要观察或拦截生命周期? -> Hook
    要执行本地动作? -> Local Tool
    要连接共享远程服务? -> MCP
    

    如果答案跨两层,就组合,不要把所有逻辑硬塞进一个机制。

    扩展越多,启动越脆弱

    外部服务和用户扩展都应默认可禁用。一个可选 MCP 连不上,不该阻止第一次启动;一个格式错误的项目 Skill 应给出来源和错误,但不应让帮助命令失效。

    扩展加载还要记录来源和覆盖关系。出了问题,用户需要知道当前调用的是内置、用户级还是项目级版本。

    用 Fallback 做个小练习

    为“生成并发布周报”设计扩展方案,至少包含:

    1. 一份 Skill:规定数据收集和审批步骤。
    2. 一个 User Command:接收日期范围。
    3. 一个 Tool 或 MCP:读取项目数据。
    4. 一个 Hook:发布前检查是否包含敏感信息。

    然后写清楚:哪一层失败时允许降级,哪一层失败时必须停止。

    Config 到 Fallback 的小结

    扩展体系的核心是选对层。知识交给 Skill,模板交给 Command,表现交给 Output Style,横切策略交给 Hook,执行能力交给 Tool 或 MCP。边界清楚后,权限、审计和测试才有落点。

    最后一篇回到一个最现实的问题:Agent 没报错、也给了答案,怎么证明它真的完成了任务,而且没有以失控的成本或危险方式完成?

    真正麻烦的是连接活着时和死掉时不一样

    本地 Tool 通常跟 Agent 在同一个进程里,MCP 却多了一段连接生命周期。服务启动成功,只能说明握手完成,不代表五分钟后的调用仍可用。网络会闪断,服务端会升级 Schema,认证会过期,子进程也可能留下半开的管道。

    可以把一次 MCP 会话拆成六个明确阶段:

    stateDiagram-v2
      [*] --> Configured
      Configured --> Connecting: "读取配置并创建 transport"
      Connecting --> Ready: "握手 + tools/list"
      Connecting --> Degraded: "超时或认证失败"
      Ready --> Calling: "tools/call"
      Calling --> Ready: "返回结构化结果"
      Calling --> Degraded: "连接中断"
      Degraded --> Connecting: "满足重连策略"
      Degraded --> Closed: "禁用或退出"
      Ready --> Closed: "正常清理"
      Closed --> [*]
    

    这些状态不要藏在一个 client !== null 判断里。Ready 说明工具列表可用,Degraded 说明当前不可调用但宿主仍可继续,Closed 说明资源已经释放。这样 Dashboard、日志和用户提示才能说清楚“Jira 扩展暂时不可用”,而不是把整个 Agent 说成启动失败。

    用一条断线轨迹看错误应该落在哪里

    设想模型刚选择 jira_add_comment,但 MCP 服务在执行前断线。宿主不应把它记成“模型没调用工具”,因为调用意图已经产生;也不能把不确定结果当成失败后安全重试,因为第一次请求可能已经到达服务端并成功写入,只是响应丢了。

    {
      "tool": "jira_add_comment",
      "callId": "mcp-91",
      "phase": "awaiting-response",
      "delivery": "unknown",
      "retryable": false,
      "userAction": "先读取工单评论,确认是否已写入"
    }
    

    读操作通常可以按退避策略重试,写操作则需要幂等键、服务端查询或人工确认。把所有网络错误都标成 retryable: true,会让 Agent 重复发评论、重复建工单,甚至重复付款。协议统一了调用格式,却没有替业务决定幂等性。

    工具发现也有预算

    一个企业 MCP server 可能暴露两百个工具。把全部 Schema 每轮都发给模型,既花 token,也让工具选择变难。宿主可以先展示稳定的工具摘要,再根据任务搜索或激活小集合;激活以后仍要经过本地权限和 Hook。

    这里要区分“服务器说它有这个工具”和“当前用户可以调用这个工具”。MCP 的 tools/list 是能力发现,不是授权凭证。最终可见集合可以写成一个交集:

    本轮工具 = 服务端已发现
             ∩ 本地启用配置
             ∩ 用户权限
             ∩ 当前任务 allowed-tools
             ∩ Hook 未阻断
    

    如果服务端重新连接后 Schema 改了,Registry 需要原子替换这台 server 的工具集合,不能一边调用旧定义、一边逐个覆盖新定义。正在执行的旧调用按旧快照收尾,新一轮模型请求再看到新 Schema,边界会清楚很多。

    从配置到 Registry 的阅读路线

    现场步骤 q-code 中的可选参考 重点核对
    1. 发现配置 src/mcp/config.ts 用户级与项目级配置如何合并,禁用项和错误来源怎样保留
    2. 建立连接 src/mcp/client.ts transport、握手、超时、工具适配与关闭是否处于同一资源生命周期
    3. 登记能力 src/mcp/registry.ts server 名称、连接对象和工具快照如何一起增加、查询和删除
    4. 进入执行管线 src/tools/registry.ts MCP 工具是否仍经过统一审计、Hook、参数校验和结果包装

    这四个文件不是 MCP 的唯一写法,它们提供的是一个检查角度:连接管理不能绕开工具治理,工具治理也不该假装网络永远可靠。

    给扩展系统做一次拔网线演练

    先连接一个只读的测试 MCP,让 Agent 成功列出并调用工具;随后停止服务端,再发一次相同请求。合格的结果不是“自动恢复一切”,而是下面几件事都说得通:宿主继续可用,失效工具不再被模型选择,审计中能看到 disconnect,用户得到简短的降级提示,恢复服务后 Registry 不会出现重复工具。

    然后把场景换成写操作,在服务端处理成功但客户端收响应前断线。系统应该把交付状态标成 unknown,并要求查询验证,而不是盲目重试。这一次演练能测到的,远比启动时看到一行“Connected”更多。

    MCP 配置也是一条信任链

    一个 server 配置通常包含命令、参数、环境变量或远程 URL。项目仓库里的配置可以告诉客户端“连接什么”,不应自动继承用户所有环境变量,更不应把 token 写回仓库。启动子进程时只传必要环境,日志对命令参数和 endpoint 做脱敏。

    用户级和项目级配置合并时,要显示最终来源。项目可以新增一个服务,但是否允许它启动本地命令、访问网络或读取凭据,仍由本机策略决定。打开陌生仓库就自动启动任意 MCP 子进程,等于把“查看代码”变成了代码执行。

    远程 MCP 还要处理 TLS、认证和服务身份。连接到同名 jira 不代表连接到同一个组织实例;Registry 名称可以带配置来源或稳定 serverId,审计记录脱敏主机和 schema hash,避免把测试与生产服务混为一谈。

    Schema 兼容不能只看工具名

    服务端升级后,create_issue 仍叫同一个名字,却新增必填字段或改变枚举意义。客户端重连时应比较 schema hash,把变化应用到下一轮工具工作集;正在进行的旧调用保留当时版本。

    兼容变化可以自动接受,例如增加可选描述字段;破坏性变化应记录 warning,并让旧会话恢复进入需要检查的状态。不能把旧参数交给新服务后,再把 400 当成模型犯错。

    MCP 统一了发现与调用,却没有定义每个业务工具的长期版本策略。生产服务仍需要契约测试:固定几组 request/response,在服务端升级前验证客户端关心的字段和错误语义。

    取消、关闭和重连要有所有者

    主进程退出时,stdio server 需要关闭输入、等待有限时间,再终止子进程;HTTP/SSE 连接需要取消订阅和在途请求。若每个 tool call 临时创建一个 client,连接成本和泄漏都会失控;若全局单例永不关闭,测试和多项目切换又会互相污染。

    把连接所有权放在 Runtime:配置加载后创建,Registry 只保存可调用引用,退出或项目切换时由 Runtime 关闭。tool call 使用局部 AbortSignal,但取消一次调用不应误关整台共享 server。连接断开事件则让该来源的工具工作集失效,并通知后续请求重新选择能力。

    重连使用退避和上限,避免服务不可用时刷屏。用户主动禁用后不再自动重连;认证失败也不应无限尝试。可恢复网络错误、配置错误和明确禁用必须是不同状态。

    什么时候不该用 MCP

    一个只在本项目调用、输入输出稳定、没有跨客户端复用需求的本地脚本,包装成普通 Tool 往往更直接。MCP 增加 transport、配置、连接、版本和独立进程,收益必须来自标准化共享,而不是“架构听起来新”。

    相反,一个由平台团队维护、同时服务多个 IDE 和 Agent、需要独立认证与发布节奏的 Jira 能力,MCP 的边界就很自然。判断依据是所有权和复用,不是工具数量。

    本文目录
    本文目录