# 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`)。