16-mcp-support.md 15 KB

16 MCP 支持完善

状态:已实施(2026-10-29),五个阶段全部完成,实施记录见 §9。决策见 ADR-0025。面向 MCP 服务开发者的说明见《MCP 接入指南》(docs/guides/mcp-guide.md)。

1. 目标

第三方 MCP 服务经 MCP Gateway 转接给两个引擎(ADR-0021)。此前只用到了 MCP 的"工具"部分,本次按 MCP 2025-11-25 规范补齐:

阶段 内容
A 工具提示(annotations)、结构化输出(structuredContent / outputSchema)、工具列表变化通知(list_changed)
B 长任务(Tasks),与后台调用合并
C 向用户询问信息(Elicitation)
D OAuth 授权(按服务共享)
E 服务端的资源(Resources)与提示模板(Prompts)

不做:Sampling(服务借用平台的模型)——花平台的 token、外部服务可以借此驱动模型,安全与费用都难以控制。

原则:只实现规范中定义的机制,不自定义协议字段;规范中的可选项按本文选定的方式使用,写进《MCP 接入指南》(docs/guides/mcp-guide.md)。

2. 阶段 A:工具提示、结构化输出、列表变化

2.1 工具提示

服务在工具定义中声明的提示(规范中均为"提示",不保证可信):

提示 规范默认值 平台的用法
readOnlyHint false 只读:默认直接调用;只读模式下只提供只读工具(原有)
destructiveHint true(仅对非只读工具有意义) 明确声明为 true 的工具默认"总是审批":自动模式下也要审批
idempotentHint false 只读或幂等的工具遇到网络错误时重新连接后重试一次;其他工具不重试,避免重复执行
openWorldHint true 后台工具列表中标出;审计记录中带上
title — 后台显示名
  • 默认值为 true 的 destructiveHint 不能直接用来强制审批,否则所有没写提示的工具在自动模式下都要审批;所以只认明确声明的 true。
  • 工具策略增加一档"总是审批"(confirm):不论权限模式都询问(只读模式下非只读工具仍然不提供)。明确声明破坏性的工具默认为这一档,管理员可以改。
  • 外部服务的提示不一定可信:管理员可以在"MCP 服务"页面逐个工具覆盖只读、破坏性、幂等三个提示(toolHints),覆盖值优先。
  • 网关提供给引擎的工具定义带上生效的提示(覆盖后的值)。

2.2 结构化输出

  • 工具结果同时有文本和 structuredContent 时:文本中已经包含同样的 JSON(规范建议服务这样做)就不重复;否则把 JSON 附在文本后面交给模型。只有 structuredContent 时转为文本(原有)。超过长度上限的保存为工作区文件,交给模型路径。
  • 工具声明了 outputSchema 时,按 schema 校验 structuredContent(规范要求客户端校验)。不合格时:结果照常交给模型,审计记录中带上校验错误。
  • 网关不把 outputSchema 原样转给引擎(引擎的 MCP 客户端会据此严格校验,网关改写过的结果可能通不过),而是在工具说明末尾附上返回结构的简述(截断到 1500 字),让模型事先知道返回什么。
  • 审计记录中保存结构化结果(截断到 20 KB)。

2.3 工具列表变化

  • 连接上游服务时声明支持 notifications/tools/list_changed;收到后(合并 2 秒内的多次通知)重新列出工具并更新缓存。
  • 每次建立新连接时也在后台刷新一次工具缓存,缓存不会长期过时。工具列表和能力有变化时才更新 toolsUpdatedAt:它是 Codex 会话的工具版本之一,变化会让会话在下一轮分叉、重新获取工具列表(ADR-0020、ADR-0021),没有变化时不应触发。
  • Claude 每一轮重新列出工具;Codex 在下一轮(分叉后)看到新的工具列表。正在进行的这一轮看不到新增的工具,调用已删除的工具时返回"工具不存在"。

3. 阶段 B:长任务(Tasks)

3.1 与后台调用合并

后台调用(docs/design/15-background-calls.md)增加"等待方式":

等待方式 条件 平台怎么等 服务重启
wait(原有) 工具不支持任务 保持连接,等工具返回 标记失败,通知发起方
task(新增) 服务声明 tasks.requests.tools.call,工具的 execution.taskSupport 为 required 或 optional 保存任务 id,按 pollInterval 调用 tasks/get 继续查询

记录表、唤醒消息、取消、顶部的后台调用栏、后台"后台调用"页面、工作组子任务等待都共用。

3.2 流程

  1. 网关以任务方式调用工具(tools/call 带 task: { ttl },ttl 为后台调用的最长时间),立即得到任务 id。
  2. 先在前台查询状态,最多等 backgroundCalls.autoAfterSec(默认 180 秒):期间完成的,取回结果直接返回给模型,和普通调用一样。
  3. 超过时间(或工具被标记为"总是后台执行"),转为后台:记录任务 id,告诉模型"已转为后台执行"并结束本轮。服务在 _meta["io.modelcontextprotocol/model-immediate-response"] 中提供的说明一并交给模型。
  4. 服务端按 pollInterval(最短 5 秒、最长 5 分钟,未提供时 30 秒)查询;到达终态(completed / failed / cancelled)后用 tasks/result 取回结果,经过与普通调用相同的结果处理,作为一条新消息唤醒发起方。
  5. 状态为 input_required 时调用 tasks/result,服务借此发出询问(阶段 C),用户回答后任务继续。
  6. 取消:调用 tasks/cancel(尽力而为),记录为已取消。
  7. 查询出错:网络错误下次再查;连续失败超过 30 分钟,或服务返回任务不存在(已过期),按失败处理。
  8. 后台调用已达上限(maxPerTarget)时:在前台查询直到完成,最多 10 分钟(与普通调用相同)。

相关代码集中在 mcp-gateway/tasks.ts:Tasks 仍是规范中的实验性功能,以后版本调整时只改这一处。

4. 阶段 C:向用户询问信息(Elicitation)

4.1 能力与形式

连接上游服务时声明 elicitation: { form: {}, url: {} }。

模式 平台的处理
表单(form) 按 requestedSchema 生成表单卡片:文本(含 email / uri / date / date-time 格式)、数字、是否、单选、多选,带默认值、必填、长度与范围限制;提交时服务端再校验一次
网址(url) 链接卡片:显示说明和完整域名,用户点开后自行完成,再点"已完成"或"拒绝";平台不自动打开网址

用户的选择对应规范的三种结果:提交(accept,带内容)、拒绝(decline)、关闭(cancel)。

4.2 卡片

  • 交互增加一种:form(InteractionFormRequest),走现有的交互流程:会话事件 interaction.required、Agent API / Bot API 的回复接口、超时(limits.interactionTimeoutSec)按"关闭"处理。
  • 回复:{ action: 'accept', content }、{ action: 'decline' }、{ action: 'cancel' }。
  • 无人值守(定时任务、没人在看的 API 会话)与审批相同:卡片照常发出,超时后按"关闭"处理。
  • 询问与权限模式无关:自动模式、只读模式下同样询问。
  • 不能用于索要密码、密钥(规范禁止):接入指南中说明,需要授权的用网址模式。

4.3 询问属于哪个会话

规范中询问请求本身不带"由哪次调用发起"的信息,而上游连接是多个会话共用的。为了不把一个会话的询问显示到另一个会话:

  • HTTP 服务:连接按"服务 × 会话"建立(HTTP 连接开销很小),询问一定属于这个会话。
  • 本地(stdio)服务:一个进程多个会话共用。询问到达时,如果这个连接上正在执行的调用都属于同一个会话,归到这个会话;否则拒绝这次询问(decline),审计中记录原因。
  • 长任务:询问带有 io.modelcontextprotocol/related-task,按任务 id 找到发起方;后台查询使用的是不属于任何会话的连接,只认任务 id。
  • 后台阶段的询问发生时会话可能没有在执行,卡片不属于任何一轮(runId 为空),照常显示和回复。

5. 阶段 D:OAuth 授权(按服务共享)

  • HTTP 服务的认证方式:无 / 请求头令牌(原有)/ OAuth。
  • OAuth 按 MCP 规范的授权流程(OAuth 2.1 + PKCE,授权服务器元数据发现):
    1. 管理员在"MCP 服务"页面点"授权",平台发现授权服务器、注册客户端(支持动态注册;也可以填写预先申请的 Client ID / Secret 和 scope),返回授权网址。
    2. 管理员在弹出的窗口中登录并同意,授权服务器回调 <后台地址>/admin/v1/mcp/oauth/callback,平台用授权码换取令牌。
    3. 令牌由服务端保存(与请求头令牌一样存放在数据库中,后台和接口只显示"已授权",不返回令牌;数据库文件在文件管理中不可下载),访问令牌过期前用刷新令牌自动续期。
  • 授权按服务共享:所有使用这个服务的会话用同一个账号,与请求头令牌的方式一致。
  • 刷新失败(授权被撤销、刷新令牌过期):服务状态显示"需要重新授权",调用返回明确的错误,模型据此告诉用户。
  • 回调地址取管理员发起授权时访问后台的地址(经反向代理时依赖 AGENT_SERVER_TRUST_PROXY)。

6. 阶段 E:资源与提示模板

6.1 资源(Resources)

用法 说明 token 消耗
模型按需读取 Profile 勾选的服务中有声明资源能力的,网关提供两个工具:mcp_resources(server?, cursor?) 列出资源和资源模板(分页,只有目录信息);mcp_resource_read(server, uri) 读取 只在读取时,只有读到的部分
用户附加 会话输入框"附加资料":选择服务和资源,平台读取后保存到会话的 uploads/,作为附件发出 这一轮附件的篇幅
  • 读取结果:文本不超过 20,000 字直接返回,超过的保存到工作区 .agent/mcp/ 并返回开头和路径;二进制一律保存为文件。
  • 资源模板(如 docs://{product}/{topic}):列出时给出模板和说明,模型填好参数后用 mcp_resource_read 读取。
  • 规范中没有搜索资源的方法:资料多的服务应另外提供搜索工具(接入指南)。
  • 不做:订阅资源变化(resources/subscribe)、Profile 固定附加资源。需要时再加。

6.2 提示模板(Prompts)

  • 会话输入框"快捷指令":列出 Profile 勾选的服务提供的提示模板,选择后填写参数,平台调用 prompts/get,把得到的内容放进输入框,用户可以修改后发送。
  • 模板内容中的嵌入资源保存为附件,图片保存为文件。
  • Agent API:GET /v1/sessions/:id/mcp/prompts、POST /v1/sessions/:id/mcp/prompts/get;资源:GET /v1/sessions/:id/mcp/resources、POST /v1/sessions/:id/mcp/resources/attach。

7. 参考 MCP 服务

examples/mcp-reference-server/:用官方 SDK 写的示例服务(虚构的订单系统),实现本文的工具提示、结构化输出、长任务(含任务中询问)、表单与网址询问、资源与模板、提示模板、工具列表变化。用于线上端到端验收,也作为接入指南的示例代码;test/mcp-reference.test.ts 启动它并经网关逐项检查,保证示例与平台的支持一致。

OAuth 不在参考服务中(需要一个授权服务器):由 test/mcp-oauth.test.ts 中的模拟授权服务器覆盖(元数据发现、动态注册、PKCE、刷新令牌)。

8. 数据模型变化

  • mcp_servers:toolHints(工具名 → 覆盖的提示)、authType(none / headers / oauth)、oauth(客户端信息、令牌、PKCE 校验码、状态;不出现在 DTO 中)。
  • McpCachedTool:增加 title、annotations、outputSchema、taskSupport。
  • background_calls:mode(wait / task)、taskId、pollAfter、workspaceDir、pollErrorSince。
  • interactions:runId 可为空;kind 增加 form。
  • McpToolPolicy 增加 confirm。

9. 实施记录(2026-10-29)

阶段 代码 测试
A 工具提示、结构化输出、列表变化 services/mcp-servers.ts(生效提示、confirm 策略、toolHints 覆盖)、mcp-gateway/results.ts(结果处理、按 outputSchema 校验)、mcp-gateway/upstream.ts(list_changed) mcp-hints.test.ts
B 长任务 mcp-gateway/tasks.ts、mcp-gateway/background.ts(mode = task) mcp-tasks.test.ts(前台完成、转后台、失败、取消、重启后继续)
C 询问用户 mcp-gateway/elicitation.ts、services/interactions.ts(form 交互、runId 可为空、内容校验)、chat-ui/InteractionCard.vue mcp-elicitation.test.ts
D OAuth services/mcp-oauth.ts、回调 GET /admin/v1/mcp/oauth/callback(不需要后台登录,凭一次性 state) mcp-oauth.test.ts
E 资源与提示模板 services/mcp-content.ts、/v1/sessions/:id/mcp/*、chat-ui/McpPicker.vue(输入框的"资料"、"快捷指令") mcp-resources.test.ts

实施中的调整:

  • 上游调用不用 client.callTool:SDK 的这个方法在返回结构与 outputSchema 不符时直接报错,改为 client.request 后自行校验,不合格时照常返回、记录在审计中(§2.2)。
  • 只提供资源或提示模板的服务:没有 tools 能力,列出工具时直接返回空列表,不再当作连接失败。
  • 任务归属:SDK 自带的内存任务存储把任务绑定在创建它的 MCP 会话上,平台重连(或重启)后查不到。所以平台用创建任务的那个连接查询,并在任务进行中保持这个连接不因空闲关闭;服务端则应按认证身份归属任务(接入指南 §5.3,参考服务按此实现)。按会话绑定的服务在平台重启后,查询时会得到"找不到任务",按失败通知。
  • 询问的卡片:调用过程中的询问显示在这一轮(Run 进入 waiting_input);长任务中的询问不属于某一轮(runId 为空),不改变 Run 状态,会话删除时取消。
  • OAuth 未授权时:连接前就给出"需要 OAuth 授权"的提示,不让 SDK 在运行中尝试动态注册(没有回调地址时 SDK 会当作客户端凭证流程处理)。
  • 数据库迁移:0014(tool_hints)、0015(后台调用的任务字段)、0016(interactions.run_id 可为空、kind 增加 form)、0017(oauth)、0018(features)。