diff --git a/docs/runbook/mcp.md b/docs/runbook/mcp.md new file mode 100644 index 00000000..1c2f6b8c --- /dev/null +++ b/docs/runbook/mcp.md @@ -0,0 +1,87 @@ +# MCP 服务端运行手册(AG-1) + +实现:`apps/server/src/modules/mcp/`(Streamable HTTP 端点、会话表、资源与提示模板、能力统计)、 +`packages/contracts/src/mcp.ts`(合同)、迁移 `0039_capability_stats`(`mcp_server_config` + `audit_event` 能力索引)、 +界面 `设置 → Agent → MCP 服务端 / 能力清单`(`apps/web/app/components/settings/agent-tools.tsx`)。 +依据:REQ R13、§10.3、§12.3。SDK:`npm:@modelcontextprotocol/sdk@1.30.1`,传输用 `WebStandardStreamableHTTPServerTransport`(Deno 下直接吃 `Request` / 返回 `Response`)。 +SDK 的子路径类型靠 `typesVersions` 映射,`deno check` 不认,所以每个 `@modelcontextprotocol/sdk/…js` import 上方带 `// @ts-types="…"`(去掉 `.js` 的同名子路径)。 + +## 接入 + +端点:`<站点根>/api/v1/mcp`,Streamable HTTP(`POST` 发消息,`GET` 开服务端推送流,`DELETE` 结束会话)。 +鉴权:`Authorization: Bearer `。在「设置 → API Key」新建 Key,明文只显示一次。 + +Claude Code(或其它支持 Streamable HTTP 的客户端)配置,界面上「复制接入配置」给的就是这一段,密钥用环境变量占位: + +```json +{ + "mcpServers": { + "uhd-media-tool": { + "type": "http", + "url": "https://umt.example.lan/api/v1/mcp", + "headers": { "Authorization": "Bearer ${UMT_API_KEY}" } + } + } +} +``` + +命令行等价写法:`claude mcp add --transport http uhd-media-tool https://umt.example.lan/api/v1/mcp --header "Authorization: Bearer $UMT_API_KEY"`。 + +自检(不需要客户端): + +```sh +curl -s -X POST https://umt.example.lan/api/v1/mcp \ + -H "Authorization: Bearer $UMT_API_KEY" -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' -i +``` + +响应头里的 `mcp-session-id` 就是会话 ID,之后的请求都要带上。 + +## 暴露什么 + +| MCP | 来源 | +|---|---| +| tools | 能力注册中心里对这个 Key 可见的能力。工具名是能力 ID 把 `.` 换成 `__`(`download.submit` → `download__submit`);描述、inputSchema 取描述符与输入 zod schema;annotations 标 readOnly / destructive / idempotent | +| resources | `umt://settings/summary`(自动化开关、预算、Agent 上限,不含凭证)、`umt://decisions/pending`(未结束的待处理,最多 50 条)、`umt://subscriptions/overview`(各状态计数 + 最近 20 个订阅);需要 `read` scope | +| prompts | `triage_pending`、`acquire_title(title, year?)`、`diagnose_task(taskId)` | + +工具可见性(`mcpToolVisible`):Key 的 scope 覆盖能力的 scope;没有 `organize.execute` / `admin` 时只给 read / preview 类。`agent.*`(派生子会话等)只在 Agent 会话内可用,不经 MCP 暴露。当前能力集下:`read` 9 个,`read + organize.execute` 18 个,`admin` 23 个(界面「可见工具」按实时注册表计算)。 + +## 安全边界:MCP 不绕过授权 + +- 每次工具调用都走 `ctx.capabilities.invoke(source="mcp", actorId="api_key:")`,和 Web、内置 Agent 是同一条路径:输入 schema 校验 → 授权回调 → 服务端幂等键 → 操作账本 → 输出 schema → 审计。 +- 授权只看库里的记录:propose / approve / execute 类要有覆盖它的**持续授权**(`automation_grant`)或有效的**单次审批**。API Key 不享有控制台管理员的"手动即授权",客户端或模型在参数里声称"已同意"不起作用。缺授权时工具返回 `isError` + `{"error":{"code":"unauthorized",...}}`,不执行。 +- 单次审批只能在控制台登录后申请和批准(`POST/PATCH /approvals` 只接受管理员会话),MCP 客户端不能给自己开审批。 +- 自动化总开关关闭或只读对账时,execute 类一律拒绝。 +- 幂等键按会话归属(`id:v版本:mcp::sha256(输入)`):同一会话同参数的有副作用调用只执行一次,重复调用直接返回账本里的结果。 +- 输出与资源内容出服务端前再过一遍 `redactForModel`(cookie / passkey / token / apiKey 等键一律遮盖)。 +- 审计:`audit_event` 里 `capability.invoke` / `capability.denied`,`payload.source = "mcp"`;会话建立写 `mcp.session.open`,配置变更写 `mcp.config.update`。 + +## 会话、限速与开关 + +- 会话与建立它的 API Key 绑定:换一个 Key 带同一个 `mcp-session-id` 返回 403。控制台 cookie 会话不能连 MCP(403)。 +- 空闲 30 分钟回收;每个 Key 最多 8 个会话,全进程 256 个;超出返回 429 / 503。 +- 限速(每个 Key、滑动一分钟):工具调用 60 次,HTTP 请求 600 次。工具调用超限返回 `rate_limited`,请求超限返回 HTTP 429。站点实时搜索、TMDB 等外部 IO 能力另受进程级并发与 15 s 超时约束(与 REST 共用 `sharedInvokeLimiter`)。 +- 启用开关与允许的 API Key 在 `mcp_server_config`(`PATCH /mcp/config`,admin scope)。停用或把某个 Key 移出允许列表时,它的现有会话立即断开。列表为 `null` 表示所有未撤销的 Key;撤销 Key 后下一个请求即 401。 +- 会话表在 API 进程内存里,重启后客户端需要重新 initialize(按规范收到 404 即重建会话)。多 API 进程部署时需要按会话粘滞路由。 + +## 能力统计与清单 + +| 端点 | 说明 | +|---|---| +| `GET /capabilities/stats?days=7` | 按能力汇总 `audit_event`:调用数、失败(error / invalid)、被拒绝、平均耗时、最近一次与调用来源(内置 Agent = agent / task,MCP,REST = web / cli)。`capability_invocation` 只记有副作用的调用,不用于统计 | +| `GET /capabilities/tools` | 描述符 + 工具名 + 输入 / 输出 JSON Schema + 授权状态(无需授权 / 有持续授权 / 需审批 / 仅 Agent 会话)+ 有效持续授权数 | +| `GET /mcp/status` | 启用、允许的 Key、在线会话(会话 ID 只回前 8 位)、最近 20 次 MCP 调用、限速、各 Key 组合可见工具数(admin scope) | + +## 排障 + +| 现象 | 处理 | +|---|---| +| initialize 返回 403「MCP 服务端已停用」/「不在 MCP 允许列表里」 | 设置 → Agent → MCP 服务端,打开开关或勾选这个 Key | +| 工具列表里没有 execute 类工具 | Key 缺 `organize.execute`(或 `admin`)scope | +| 工具返回 `unauthorized` | 没有覆盖该能力的持续授权。发布收录策略会生成持续授权,或在控制台为计划申请单次审批后带 `approvalId` 调用 | +| 404「会话不存在或已过期」 | 空闲超时或进程重启,重新 initialize | +| 429 | 超过每分钟上限或会话数上限,稍后重试 / 关闭不用的会话 | + +测试:`apps/server/test/mcp/mcp.test.ts`(官方 SDK client 经 Streamable HTTP 实连:工具列表按 scope 过滤、read 能力调用与脱敏、execute 无授权被拒与有授权后记账本、资源与提示模板、会话绑定、cookie / 停用 / 允许列表拒绝、统计与清单端点)。