diff --git a/docs/code-index/peri-middlewares.md b/docs/code-index/peri-middlewares.md index ea74a77c..9e283cb7 100644 --- a/docs/code-index/peri-middlewares.md +++ b/docs/code-index/peri-middlewares.md @@ -50,7 +50,7 @@ | 改 goal steering | `src/goal_middleware.rs` + `src/goal/tool.rs` | `GoalMiddleware`(goal_middleware.rs:24,`new` :33、`after_agent` :85、`render_steering` :45);`GoalTool`(goal/tool.rs:17,deferred,is_direct 默认 false) | controller 可用才装配(链最后,assembly.rs:565-573);goal active 且无既有 block_continue 时按 round 递增紧迫感模板注入(round 1/2/3+ 三档),必须以 `Human + ` 经 v2 MessageQueue `Defer` kind 注入(禁止 BaseMessage::system——会污染 frozen_system_prompt),并设 `block_continue = "goal_active"` 让 executor 自驱续跑 | | 改 AGENTS.md 注入 | `src/agents_md/mod.rs` | `AgentsMdMiddleware`(:22,`with_extra_paths` :45、`with_excludes` :51、`with_frozen_content` :61、`read_frozen_content` :84) | 会话创建时读取冻结(主 + 本地 CLAUDE.md/AGENTS.md,excludes 过滤),SubAgent 复用冻结内容;禁止中途重读(ARC-FROZEN-001,测试 `frozen_claude_md`) | | 改文件 / 终端 / Web / Todo / Image 工具 | `src/middleware/` | filesystem.rs(collect_tools :39);terminal.rs(BashTool :21、TerminalMiddleware :480,collect_tools :534);web.rs(WebMiddleware :7,WebFetchTool :38 / WebSearchTool :37);todo.rs(TodoMiddleware :18,`new` 收 notify_tx :25);image/(ImageMiddleware :26 + compressor.rs :30) | 纯工具提供器:collect_tools 注册 + 透传 is_direct;Todo 带通知通道;Image 处理 @image 附件转 ContentBlock::Image | -| 改 agent 定义 / 默认 prompt / 归属注入 | `src/agent_define/` + `src/default_system_prompt/` + `src/at_mention/` + `src/attribution/` | `load_overrides`(agent_define/mod.rs:78,`candidate_paths` :45);`DefaultSystemPromptMiddleware`(default_system_prompt/mod.rs:112,`sections` :127)/`LangMiddleware`(:159);`AtMentionMiddleware`(at_mention/mod.rs:28);`GitAttributionMiddleware`(attribution/mod.rs:38,`attribution_text` :58) | 链第一组上下文注入器;agent 定义 overrides 同时供 DefaultSystemPrompt 与 SubAgent fork 复用;Lang 语言指令段持有者;attribution 按 model_name 生成归属文本 | +| 改 agent 定义 / 默认 prompt / 归属注入 | `src/agent_define/` + `src/default_system_prompt/` + `src/at_mention/` + `src/attribution/` | `load_overrides`(agent_define/mod.rs:78,`candidate_paths` :45);`DefaultSystemPromptMiddleware`(default_system_prompt/mod.rs:112,`sections` :127)/`LangMiddleware`(:159);`AtMentionMiddleware`(at_mention/mod.rs:28);`GitAttributionMiddleware`(attribution/mod.rs:42,`attribution_text` :62、`current_branch` :118) | 链第一组上下文注入器;agent 定义 overrides 同时供 DefaultSystemPrompt 与 SubAgent fork 复用;Lang 语言指令段持有者;attribution 按 model_name 生成归属文本,并以 null stdin、1 秒异步等待预算与 direct-child kill-on-drop 做 best-effort 分支漂移观测,等待超时后继续 agent | | 改装配输入端口实现 | `src/host_ports.rs` | `PluginManager`(:26,PluginManagerPort)、`SettingsHooksLoader`(:406,SettingsHooksPort)、`SkillsProvider`(:425,SkillsPort) | 3.0 批 2 波 2:插件加载 / 设置 hooks / skills provider 经端口注入 Agent 层装配面,本文件是端口实现方;其余端口(McpPoolPort / ToolSearchPort / WorkflowMiddlewarePort / CronSchedulerPort)实现在 Agent 层 session 工厂 | ## 子系统(按目录) diff --git a/docs/design/mcp-connector-guide-v2.md b/docs/design/mcp-connector-guide-v2.md index 67760970..57969a23 100644 --- a/docs/design/mcp-connector-guide-v2.md +++ b/docs/design/mcp-connector-guide-v2.md @@ -63,29 +63,30 @@ flowchart LR A["MCP App(ui:// 资源)"] subgraph AGENT["peri-agent —— MCP 连接持有者(client)"] MC["MCP Client
tools/list · tools/call · resources/read · subscriptions/listen"] - HOST["App Host 逻辑
握手校验 · 工具路由 · pending calls"] + RELAY["Apps relay ports
raw metadata/result · capability profile"] end subgraph ACP["peri-acp —— ACP 服务层(数据出口)"] SESS["Session / 事件映射"] ST["StdioTransport · MpscTransport"] end - subgraph VIEW["view 层(UI 界面)"] - TUI["peri-tui
TUI 版 view"] - WEB["web UI 界面
webview + 桥 JS"] + subgraph VIEW["ACP client"] + DOWNSTREAM["下游 UI / Web Host
通过 stdio ACP 消费 Apps 数据"] + TUI["peri-tui
不声明、不消费 Apps capability"] end T <-->|"MCP 原生协议(JSON-RPC)"| MC N <--> MC S <--> MC - A <-->|"resources/read 拉取 HTML"| MC - MC <--> HOST - HOST <-->|"ACP JSON-RPC
peri/app/* 透传帧"| SESS + A <-->|"resources/read"| MC + MC <--> RELAY + RELAY <-->|"Apps ports / DTO"| SESS SESS <--> ST - ST <-->|"ACP stdio 出数据"| WEB - ST <-->|"MpscTransport(内嵌)"| TUI - WEB <-->|"postMessage JSON-RPC(经宿主桥转发)"| A + ST <-->|"ACP stdio envelope"| DOWNSTREAM + ST <-->|"MpscTransport(普通 ACP)"| TUI ``` +下游 Web Host 与 MCP Apps FE 的 iframe/`postMessage` 数据流不属于 Peri,实现细节不在本拓扑展开。 + ### 2.3 对外契约与能力索引 **对外人员的唯一契约**:实现标准 MCP server(stdio 或 Streamable HTTP 传输),按需暴露下列能力;互通由 peri 侧(MCP client + ACP 通道)完成,外部无需感知 peri 内部结构。 @@ -409,42 +410,49 @@ MCP Apps 是 MCP `2026-07-28` 规范中 **Extensions 框架的官方扩展**(S ### 6.1 核心机制 1. **资源形态**:server 以 `ui://` scheme 的 resource 发布 HTML(MIME `text/html;profile=mcp-app`)。 -2. **工具绑定**:`tools/list` 返回的工具携带 `_meta.ui.resourceUri`(如 `ui://get-time/mcp-app.html`),host 据此把 App 关联到工具调用结果上。 -3. **渲染与通信**:host 拉取 HTML 后在隔离 iframe 渲染;App 内脚本通过 postMessage 与 host 走 JSON-RPC(`ui/initialize` 握手后,可发 `tools/call` 回调 server、收 `toolresult` 事件)。 -4. **安全模型**:iframe 沙箱 + CSP(默认同源,`connectDomains` / `resourceDomains` 白名单),opaque origin、无 cookie 继承,权限(camera 等)默认全禁。 +2. **工具绑定**:`tools/list` 返回的工具携带 `_meta.ui.resourceUri`;同时处理 `_meta.ui.visibility`,默认可同时面向 model 与 app。 +3. **能力方向**:当前进程在 MCP pool prewarm 前读取一次 `PERI_MCP_APPS`;变量只看是否存在,值不解析(包括空串和 `0`)。存在时,所有初始连接和重连都在 MCP `initialize` 中传播 `io.modelcontextprotocol/ui`;不存在时完全关闭。该 deployment profile 与 Apps `appCapabilities`/`hostCapabilities` 是不同层次。 +4. **渲染与通信**:下游 Web Host 拉取 HTML 并自行决定如何渲染与通信;Peri 不实现 iframe、sandbox、CSP、Permissions Policy 或 `postMessage`。 +5. **工具结果**:Host/model 发起的调用由下游按 Apps 规范投影为 `ui/notifications/tool-input*` 与 `ui/notifications/tool-result`;App 发起的 `tools/call` 使用匹配原始 id 的标准 JSON-RPC response。Peri 保留 `content`、`structuredContent`、`_meta`、`isError`。 +6. **安全模型**:下游负责 iframe/CSP/权限执行;Peri 负责 server/session/tool 归属、visibility、connection capability 和既有权限/HITL 边界。 -### 6.2 架构:iframe · 宿主页面 · WebServer · ACP Agent 的关系与透传 +### 6.2 架构:下游 Web Host · MCP server · Peri deployment capability 的关系 -四者的关系图: +Web Host/iframe 仅为下游消费方,本仓库不实现。MCP Apps/UI capability 来自进程启动环境中的 `PERI_MCP_APPS`,不是 ACP capability: ```mermaid flowchart LR - subgraph VIEW["view 层 · Host WebSite / webview"] - IFRAME["MCP App iframe
(沙箱 · opaque origin
CSP 默认同源 · 无 cookie 继承
权限默认全禁)"] - BRIDGE["桥 JS(宿主侧镜像)
postMessage ⇄ JSON-RPC"] - end - subgraph AGENT["ACP Agent(peri-agent + peri-acp)"] - HOST["App Host 逻辑
握手校验 · 工具路由 · pending calls"] - CLIENT["MCP Client
tools/call · resources/read"] - ACP["ACP 通道(数据出口)"] - end - WS["MCP WebServer
(ui:// HTML · tools)"] - - IFRAME <-->|"postMessage JSON-RPC
ui/initialize · tools/call · toolresult"| BRIDGE - BRIDGE <-->|"ACP 透传帧
(见下方透传理念)"| HOST - HOST <--> CLIENT - CLIENT <-->|"MCP 原生协议
Streamable HTTP / stdio"| WS - HOST <--> ACP + ENV["PERI_MCP_APPS\n存在即启用"] + P["Peri\nACP stdio + MCP client"] + S["MCP server\nui:// resource · tools"] + H["下游 Web Host\n消费 Peri ACP 数据"] + ENV -->|"进程启动时冻结 deployment profile"| P + P -->|"初始连接与重连\n传播 UI capability"| S + P <-->|"ACP envelope\nresource/result/app data"| H + P <-->|"MCP JSON-RPC\ntools/list · resources/read · tools/call"| S ``` -**peri ACP 如何透传**(理念定稿,信封结构与 id 映射见 `docs/design/mcp-multiplexing.md`): +Peri 不创建 iframe、不实现 sandbox/CSP/Permissions Policy、不处理浏览器 `postMessage`,也不实现 MCP Apps FE。下游 Web Host 负责把 Peri 的 ACP 数据接入自己的 Apps host;`peri-tui` 不参与。 + +Peri 侧数据流: + +1. Peri 在 MCP pool prewarm 前读取一次 `PERI_MCP_APPS` 是否存在并冻结 deployment profile;值不解析。 +2. 变量存在时,初始连接和重连均传播 `io.modelcontextprotocol/ui` 及 `text/html;profile=mcp-app`;不存在时使用普通 MCP capabilities。 +3. Peri 从 `tools/list` 发现 `Tool._meta.ui.resourceUri`,通过 `peri/mcp/open` 建立 connection-owned resource binding,再以 `peri/mcp/resource` 获取 `ui://` resource。 +4. Peri 保留 resource 的 `text|blob`/`_meta` 与 `CallToolResult` 的 `content`、`structuredContent`、`_meta`、`isError`。 +5. 下游是否创建 iframe、如何执行 `postMessage`、如何消费 Apps tool notifications,完全由下游 Web Host 决定。 + +### 6.3 Peri ACP 如何透传 + +- **payload 保留 MCP Apps 原始消息**:Peri 解析外层 envelope 进行 server/session 路由校验,但不实现下游 Web Host 的 `postMessage` bridge。 +- **外层方法名统一包装**:`peri/mcp/app` 与 `peri/mcp/resource`;信封分离 `envelopeVersion`、`mcpProtocolVersion`、`appsProtocolVersion`,并携带 `serverId` / `appSessionId` / `resourceUri` 与 Apps payload。 +- **能力开关由环境驱动**:`PERI_MCP_APPS` 存在即启用整个进程的 immutable deployment profile;不再读取或回显 ACP MCP Apps capability。 +- **Web Host 属于下游**:iframe、sandbox、CSP、Permissions Policy、`postMessage` 和 MCP Apps FE 均不在 Peri 实现范围。 +- **工具调用不绕过权限**:App 发起的调用与 agent 发起的 MCP 工具调用共用既有执行路径与 HITL 权限。 -- **payload 保留 MCP 原始消息**:view 侧剥掉信封即得协议原文,与 App 的 postMessage 层直接对接,两端都不需要二次序列化。 -- **外层方法名统一包装**:`peri/mcp/app`(App 交互双向)与 `peri/mcp/resource`(`ui://` 内容读取);信封携带路由元数据(`serverId` / `appSessionId` / `protocolVersion`)与 MCP 原文 payload。原「裸传 MCP 原文」倾向已在定稿中否决(理由见 mcp-multiplexing.md §3.1)。 -- **App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限**——透传不绕过安全模型。 -- 已知冲突点(实施前处理):`mcp/oauth_*` 占用 `mcp/` 前缀、`elicitation/create` 与 MCP elicitation 撞名(SEP-1036);参照系 grok-build 选择 `x.ai/mcp/*` 包装(非裸传)。 +### 6.4 关键 JSON-RPC 方法(当前 SDK/实验观察;不是封闭方法全集) -### 6.3 关键 JSON-RPC 方法(实验验证) +MCP Apps 协议会随 capability 扩展增加 `ui/*`、context、link、message、teardown 等交互;下表只列当前研究涉及的代表性消息,不应作为实现层的永久白名单。 | 方向 | 方法 | 说明 | | --- | --- | --- | @@ -452,30 +460,38 @@ flowchart LR | App → Host | `ui/notifications/initialized` | 初始化完成通知 | | App → Host | `tools/call` | App 主动回调 server 工具(参数 `{name, arguments}`) | | App → Host | `ui/notifications/size-changed` | 自适应尺寸通知 | -| Host → App | `toolresult` | 工具结果事件(含 `content`) | -| Host → App | `ui/notifications/host-context-changed` | 主题 / 时区等宿主上下文(可传递暗色主题) | -| Host → App | `ui/notifications/teardown` | 销毁通知 | +| Host → App | `ui/notifications/tool-input-partial` | 初始工具调用的增量输入(可选) | +| Host → App | `ui/notifications/tool-input` | 初始工具调用的完整输入 | +| Host → App | `ui/notifications/tool-result` | 初始工具调用结果;完整保留 `content`、`structuredContent`、`_meta`、`isError` | +| Host → App | `ui/notifications/tool-cancelled` | 初始工具调用被取消 | +| Host → App | `ui/resource-teardown` | Host → App request;需要按原 request id 返回 response | +| Host → App | `ui/notifications/host-context-changed` | 宿主上下文变化 | +| App → Host | `ui/notifications/size-changed` | 尺寸通知 | ```mermaid sequenceDiagram - participant A as MCP App(iframe) - participant H as Host - A->>H: ui/initialize
{appInfo{name,version}, appCapabilities, protocolVersion} - H-->>A: ui/notifications/initialized - H-->>A: ui/notifications/host-context-changed(暗色主题) - A->>H: tools/call {name, arguments} - H-->>A: toolresult {content} - A->>H: ui/notifications/size-changed - H-->>A: ui/notifications/teardown + participant A as MCP App FE(下游) + participant H as Web Host(下游) + A->>H: ui/initialize request + H-->>A: ui/initialize response(hostCapabilities/hostContext) + A->>H: ui/notifications/initialized + H-->>A: ui/notifications/tool-input-partial(可选) + H-->>A: ui/notifications/tool-input + H-->>A: ui/notifications/tool-result 或 tool-cancelled + A->>H: tools/call request + H-->>A: JSON-RPC response(同一 App request id) + H->>A: ui/resource-teardown request + A-->>H: JSON-RPC response ``` -### 6.4 生态与 SDK 现状 +Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择透传的 payload,并负责自己的 ACP connection capability、MCP server capability propagation、server/session/tool 路由和结果完整性。 +### 6.5 生态与 SDK 现状 - **官方 SDK**:`@modelcontextprotocol/ext-apps`(前端 `App` 类 + server 端 `registerAppTool` / `registerAppResource` helpers),TypeScript。 - **已支持客户端**:ChatGPT、Claude(web / Desktop,本地需隧道)、VS Code(1.100+)、Goose(experimental / draft 实现)、Postman、MCPJam、mcp-use、官方参考实现 `basic-host`。 - **协议版本注意**:`@modelcontextprotocol/sdk` npm latest 仍以 `2025-11-25` 为 `LATEST_PROTOCOL_VERSION`,`2026-07-28` 尚未发布到 npm;ext-apps 的代码路径已是新协议风格(无 session 模式,见 5.5)。 -### 6.5 实验踩坑(`side-projects/mcp-apps`) +### 6.6 实验踩坑(`side-projects/mcp-apps`) 1. **`appInfo.version` 必填**:`new App({ name })` 缺 version,host 侧 zod 校验失败返回 `-32603 InternalError`(错误消息含 `path: ["params","appInfo","version"]`)。构造时必须传 `{ name, version }`。 2. **host 资源缓存**:Goose 把拉到的 HTML 缓存到 `~/.config/goose/mcp-apps-cache/`,改 UI 后必须清缓存(或等缓存过期)才能看到新版。 @@ -544,9 +560,11 @@ sequenceDiagram 4. **技能(可选,第 7 章)**:以 `skill://` 资源暴露 `SKILL.md`(frontmatter + 正文),声明 `capabilities.resources` 即可被 peri 异步发现并注入技能缓存与命令列表(`mcp____`);另提供 **skill 搜索工具**(检索入口,命名含 `skill`,如 `search_skills`)可提升检索直达性——peri 自有 DiscoverMCP 已提供全域查询(server / tool / resource / skill),server 侧搜索工具会作为普通 deferred 工具进索引,不强制。 5. **安全基线**:HITL 确认、工具注解视为不可信、敏感操作提示确认;App 场景遵守 iframe 沙箱 + CSP 默认同源 + 权限默认全禁。 -## 9. peri 内部落地现状与路径 +## 9. peri 内部落地现状与目标路径 -### 9.1 现状 +> 本节严格区分代码事实与目标设计。目标 contract 由 `spec/issues/2026-08-27-mcp-apps-stdio-relay.md` 冻结;在对应契约测试通过前,不得据此声称 relay 已实现。 + +### 9.1 当前代码事实 - MCP client 已进入 peri 主代码(`peri-middlewares/src/mcp/`,基于 rmcp):tools 桥接、资源读取(`mcp_read_resource`)、OAuth 授权、断线重连均已落地;MCP Apps(`ui://` 渲染)仍在 `side-projects/mcp-apps/` 实验。 - **MCP 域查询与技能分发已落地(2026-08-13)**:DiscoverMCP 只读工具(deferred / `meta`,search / list / detail)+ MCP `skill://` 异步发现与命令注入(McpSkillRegistry,session 级,分源合并),形态见 §7.4。 @@ -557,49 +575,31 @@ sequenceDiagram ### 9.2 信道划分(已定稿) -传输层为纯 JSON-RPC 2.0(Request / Notification / Response,无 frame 概念)。现有 ACP 方法名空间:`session/*`、`plugin/*`、`peri/agent_event*`、`mcp/oauth_*`、`marketplace/*`、`elicitation/create` 等。MCP 数据到达 view 的透传通道**设计已定稿**:`docs/design/mcp-multiplexing.md`(单 ACP 连接上的多路数据分离——信封结构、三层 id 映射、App 会话鉴权、分流规则、错误语义、可靠性)。该设计为 **MCP Apps 专属**,MCP Apps 当前搁置(§9.6)——设计定稿但**无实施计划**,实施与否随其评估结果。定稿要点: - -1. 透传的 `payload` **必须保留 MCP 原始消息**(view 侧剥信封即得原文,与 App postMessage 层直接对接)。 -2. **外层方法名统一包装**:`peri/mcp/app`(App 交互,双向)+ `peri/mcp/resource`(`ui://` 内容读取)。信封携带路由元数据(`serverId` / `appSessionId` / `protocolVersion`)。原「倾向裸传 MCP 原文」已否决,理由:方法名空间平铺共享需白名单、`mcp/` 前缀已被 `mcp/oauth_*` 占用、参照系 grok-build 亦为包装(详见 mcp-multiplexing.md §3.1)。 -3. 已知冲突点,实施前需处理:`mcp/oauth_*` 占用 `mcp/` 前缀(透传用 `peri/mcp/` 避让)、`elicitation/create` 与 MCP elicitation 撞名(SEP-1036)。 -4. 参照系:grok-build 选择 `x.ai/mcp/*` 包装(`extensions/mcp.rs`),非裸传。 - -### 9.3 协议层最小增量(peri-acp / MCP client 层) - -- 支持 `resources/read`(拉取 `ui://` HTML)。 -- `tools/list` 透传 `_meta.ui.resourceUri` 到事件链。 -- 订阅场景:2026-07-28 `subscriptions/listen` 已落地(全链路见 9.1);2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现。 - -### 9.4 agent / 工具系统 +传输层为纯 JSON-RPC 2.0(Request / Notification / Response)。MCP Apps 数据到达下游的 contract 见 `docs/design/mcp-multiplexing.md`:外层 ACP envelope、Apps payload id 与 ACP id 分离、connection-owned App session、错误分层和 lifecycle。 -- 工具执行结果携带 app 标记(`ui.resourceUri`),事件链(tool_call → tool_result)透传到 view。 -- App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限(架构图与透传理念见 6.2)。 +1. Apps `payload` 保留 JSON-RPC 语义和下游 request id;Peri 将允许的方法映射到 MCP client API,不做字节级 server 透传。 +2. 外层使用 `peri/mcp/app` 与 `peri/mcp/resource`;信封携带 `envelopeVersion`、`serverId`、`appSessionId`、`resourceUri`,并分离 `mcpProtocolVersion` 与 `appsProtocolVersion`。 +3. `appSessionId` 必须由 ACP connection owner、server generation、resource/tool binding 共同约束。 +4. 已知方法名冲突通过 `peri/mcp/` 命名空间隔离;下游未知 Apps 方法与字段按版本化 contract 保留。 -### 9.5 view 层落点:TUI 集成建议 +### 9.3 协议层目标最小增量(尚未实现) -**总体判断**:协议层值得做,渲染层分档做。MCP Apps 协议本身很薄(7 个 JSON-RPC 方法 + 1 个资源 MIME),成本集中在「宿主渲染与桥接」。 +以下为 active spec 的目标,不是当前代码事实: -TUI 没有 DOM / iframe 渲染能力,纯文本终端无法直接渲染 HTML App,因此有三条路线: +- 支持受 capability/profile 约束的 `resources/read`(拉取 `ui://` HTML)。 +- raw tool metadata、resource item 与 `CallToolResult` 经 relay 保留标准字段和未知 `_meta`;不得提前压成文本。 +- 订阅场景:2026-07-28 `subscriptions/listen` 已落地(全链路见 9.1);2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现。 -| 方案 | 做法 | 成本 | 收益 | -| --- | --- | --- | --- | -| A. webview 弹出面板(推荐) | 工具结果带 `_meta.ui.resourceUri` 时,TUI 弹出 webview 窗口渲染;注入 JS 桥实现 App ↔ TUI JSON-RPC | 中(协议薄、webview 集成为主) | 完整兼容官方 App 生态 | -| B. MCP-UI 元素子集渲染 | 渲染 `chart` / `button` / `link` 等结构化 JSON 元素(ratatui 原生绘图) | 低-中 | 无 webview 依赖,但只覆盖简单 widget | -| C. ASCII / 文本降级渲染 | 解析 HTML 摘录文本 | 中高且效果差 | 不推荐 | +### 9.4 Agent / 工具系统目标(尚未实现) -**webview 方案要点**: +- 经验证的 agent 初始 tool invocation 才能创建 App session;完整 tool input 恰好一次,随后 `tool-result XOR tool-cancelled` 恰好一个 terminal。 +- App 发起的 `tools/call` 目标为复用 session-local effective view、canonical dispatch 与既有 Permission/HITL seam;在对应测试通过前不得视为已接通。 -- `webview` crate(macOS WKWebView / Linux webkit2gtk / Windows WebView2)。 -- 加载前注入桥 JS(postMessage ↔ TUI 进程 JSON-RPC 转发,即 ext-apps `PostMessageTransport` 的宿主侧镜像)。 -- App 生命周期状态机:discover → open → handshake → active → teardown。 -- 安全基线对齐官方模型:opaque origin、CSP 默认同源、权限默认全禁、禁止 cookie 继承。 -- `ui/initialize` 成功后发送 `host-context-changed` 传递暗色主题。 +### 9.5 下游 Web Host 边界 -**成本评估**: +Web Host、iframe、sandbox、CSP、Permissions Policy、`postMessage` bridge 与 MCP Apps FE 均由下游实现,Peri 和 `peri-tui` 不实现这些能力。Peri 只提供 connection-scoped capability gate、MCP capability profile、resource/result DTO 和双向 ACP envelope。 -- 协议层约 1–2 周(纯 JSON-RPC + 事件路由,可完全单测)。 -- webview 集成约 2–4 周(主要成本在平台差异与焦点 / 布局)。 -- 收益:与 ChatGPT / Claude / VS Code / Goose 生态协议对齐,一次实现,全端通用。 +下游实现可自行选择 Web、IDE webview 或其他渲染容器;这些选择不能反向改变 Peri 的安全与传输 contract。 ### 9.6 MCP 能力支持度矩阵(2026-08-14 核查) @@ -617,13 +617,13 @@ peri 作为 MCP client,对照 2026-07-28 协议能力面的支持度与路线 | Roots | ❌ 未实现 | **不做(永远)** | 不向 server 暴露工作目录 | | Tasks(正式扩展) | ❌ 未实现 | **不做(永远)** | rmcp 模型齐全,不接 | | Elicitation(SEP-1036 正式扩展) | ❌ 未实现(默认 Decline) | **搁置(可能做)** | 与 ACP `elicitation/create` 撞名(§9.2) | -| MCP Apps(正式扩展) | ❌ 未实现 | **搁置(可能做)** | 设计定稿 `mcp-multiplexing.md`(§9.2),**无实施计划**;前置:能力声明、`ui.resourceUri` 透传 | +| MCP Apps(正式扩展) | ❌ 未实现 | **本次设计范围:Peri stdio relay;Web Host 不做** | 前置:ACP capability gate、条件 MCP capability、raw resource/result DTO、connection-owned session、HITL seam | | WebSocket 传输 | ❌ 未接 | **隔离**(不接入主链路) | rmcp 支持(`ws.rs`);peri 仅 stdio + HTTP,需要时走独立路径 | | Progress / Cancelled 通知 | ❌ 未处理 | 不做 | 长任务进度、优雅取消不可见 | | Logging(`logging/message` / `set_level`) | ❌ 未处理 | 不做 | | | Resource templates / complete | ❌ 未接 | 不做 | | -**决策记录**:Prompts / Sampling / Roots / Tasks 明确**永远不会做**(2026-08-14);Elicitation 与 MCP Apps 搁置待评估;WebSocket 传输隔离;其余未支持项不做。 +**决策记录**:Prompts / Sampling / Roots / Tasks 明确不做(2026-08-14);MCP Apps 当前仅设计 Peri stdio relay,Web Host/iframe 永久留给下游;WebSocket transport 维持隔离。 ## 10. 参考 diff --git a/docs/design/mcp-multiplexing.md b/docs/design/mcp-multiplexing.md index 5e4aa123..fb2eb7b5 100644 --- a/docs/design/mcp-multiplexing.md +++ b/docs/design/mcp-multiplexing.md @@ -2,9 +2,9 @@ > 本文件是「外部 MCP server ↔ view 层」透传信道的设计定稿,回答一个问题:**ACP 只有一条连接,多个 MCP server 的数据(App 交互、工具结果、通知)如何在这条信道上分离路由,保证数据正确送达正确的接收方。** > -> 最后核对:2026-08-14 -> 状态:**设计定稿但无实施计划**——本文为 MCP Apps 专属设计(guide §6、§9.2);MCP Apps 当前搁置(guide §9.6 决策),实施与否随其评估结果 -> 关联文档:`docs/design/mcp-connector-guide-v2.md`(MCP 生态定位,§6 MCP Apps、§9 内部落地、§9.6 支持度矩阵);`docs/design/peri-acp-protocol.md`(ACP 协议) +> 最后核对:2026-08-27 +> 状态:**目标设计,实施中但尚未成为代码事实**——最小 ACP stdio relay 的 active spec 为 `spec/issues/2026-08-27-mcp-apps-stdio-relay.md`;在对应契约测试通过前,本文描述的 Apps capability、envelope、session 与 relay 均不得视为已实现 +> 关联文档:`docs/design/mcp-connector-guide-v2.md`(MCP 生态定位,§6 MCP Apps、§9 内部落地);`docs/design/peri-acp-protocol.md`(ACP 协议) > 本文是设计说明,不是规范;不搬运规范原文。 ## 目录 @@ -25,20 +25,22 @@ ### 1.1 要解决的问题 -- ACP 连接**只有一条**(TUI ↔ ACP Server 为 `MpscTransport`,外部 IDE 为 `StdioTransport`),传输层是纯 JSON-RPC 2.0(Request / Notification / Response)。 -- 需要在这条信道上承载**多个 MCP server** 的数据:MCP App(SEP-1865)的 `ui/*` 握手与 `tools/call` 回调、`ui://` 资源读取、Host → App 的推送(`host-context-changed` / `teardown`)。 -- 目标:**任意时刻、任意并发 App 之间的数据互不串扰**——一个 App 发起的 `tools/call` 的结果只回到该 App,且只能调用**它所属 server** 的工具。 +- ACP 连接只有一条(下游 UI ↔ ACP Server 为 `StdioTransport`),传输层是纯 JSON-RPC 2.0(Request / Notification / Response)。`peri-tui` 不属于本设计范围。 +- 需要在 stdio ACP 信道上承载多个 MCP server 的 Apps 数据:`PERI_MCP_APPS` 在进程启动时存在则启用 deployment capability,并向 MCP server 传播 UI extension。 +- 目标:**任意时刻、任意并发 App 之间的数据互不串扰**——一个下游 UI session 发起的 `tools/call` 的结果只回到该 session,且只能调用它所属 server 的工具。 ### 1.2 范围 -- **在**:透传帧的信封结构、id 映射、会话注册与鉴权、分流规则、错误语义、可靠性。 -- **不在**:MCP Apps 协议本身(`ui/initialize` 等 7 个方法,见 guide §6.3);iframe 渲染与桥 JS 内部实现细节;HITL 权限机制(共用现有执行路径,不重新设计)。 +- **在**:环境 deployment capability、MCP server capability 传播、透传帧、id 映射、会话绑定、错误语义与可靠性。 +- **不在**:MCP Apps 协议本身(`ui/initialize` 等代表性方法,见 guide §6.3);Web Host/iframe 渲染、sandbox、CSP、Permissions Policy、`postMessage` bridge 及 MCP Apps FE 内部实现;HITL 权限机制(共用现有执行路径,不重新设计)。 ### 1.3 设计原则(沿用 guide §6.2/§9.2 既有结论) -1. **payload 保留 MCP 原始消息**:view 侧剥掉信封即得协议原文,与 App 的 postMessage 层直接对接,两端都不二次序列化。 +1. **payload 保留 MCP Apps 原始消息**:下游 Web Host 可将 payload 交给其自己的 Apps bridge;Peri 不解析或实现 `postMessage`,但必须解析外层 envelope 并执行 server/session 路由校验。 2. **透传不绕过安全模型**:App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限。 -3. **view 不接触 MCP**:view 不持有 MCP 连接、不猜测协议版本;所有 MCP 侧信息由 agent 侧填充。 +3. **能力由 deployment 环境驱动**:`PERI_MCP_APPS` 只看是否存在;存在时整个进程的初始 MCP 连接和重连声明 UI capability,不存在时完全关闭。 +4. **下游 Web Host 不属于 Peri**:iframe、sandbox、CSP、Permissions Policy、`postMessage`、App FE 生命周期由下游实现;Peri 只提供 ACP 数据与协议 contract。 +5. **view 不接触 MCP connection**:下游只通过 ACP 获取 Peri 投影的数据,不持有 Peri 的 MCP peer、stdio handle 或 server credentials。 ## 2. 信道现状(代码事实) @@ -60,18 +62,18 @@ | # | 决策点 | 选择 | 理由 | | --- | --- | --- | --- | | D1 | 外层方法名 | **包装**:`peri/mcp/app`、`peri/mcp/resource`(否决定稿前 guide §9.2 的「倾向裸传」) | 见 3.1 | -| D2 | 信封字段 | `serverId` + `appSessionId` + `protocolVersion` + `payload` | 见第 4 章 | -| D3 | id 空间 | 三层(App 原始 id / ACP 传输层 id / MCP server id),**payload.id 恒为 App 原始 id** | 见第 5 章 | -| D4 | 请求-响应关联 | 外层 request 用传输层 id(`RequestRouter` 机制),agent 侧维护 `server 请求 → (appSessionId, 原始 id)` 映射 | 见 5.2 | -| D5 | App 会话 | agent 侧进程级注册表 `appSessionId → {serverId, resourceUri, state}`,握手时创建 | 见第 6 章 | -| D6 | 防双写 | agent 发起的调用走事件链;App 发起的调用只走透传 | 见第 7 章 | -| D7 | 错误分界 | envelope 校验错用 ACP 层错误码(`-32000` 系列);MCP 协议错误原样透传 | 见第 8 章 | -| D8 | 背压 | App 事件天然低频 + 同类通知 coalesce;不做硬隔离(列为未决) | 见 9.2 | -| D9 | 协议版本 | `protocolVersion` 由 agent 侧从 server 协商结果填充,view 不猜 | 见 9.3 | +| D2 | 信封字段 | `envelopeVersion` + `serverId` + `appSessionId` + `resourceUri` + `mcpProtocolVersion`(可选)+ `appsProtocolVersion`(由 payload/下游协商)+ `payload` | 见第 4 章 | +| D3 | id 空间 | ACP 外层 transport id 与 payload 内 Apps id 分离;MCP server id 由 MCP client 管理,不假设 Peri 可见 | 见第 5 章 | +| D4 | 请求-响应关联 | ACP request 用 transport id;payload 保留 Apps request id;Peri 不复制 MCP client 的内部 pending map | 见 5.2 | +| D5 | App 会话 | connection-owned 注册表 `appSessionId → {connectionId, serverId, resourceUri, bindings, state}` | 见第 6 章 | +| D6 | 防双写 | agent 发起的调用走下游结果通知;App 发起的调用只走对应 JSON-RPC response | 见第 7 章 | +| D7 | 错误分界 | envelope/routing error 与 payload 内 JSON-RPC error 分层表达,不根据 `-320xx` 范围猜测来源 | 见第 8 章 | +| D8 | 背压 | App 事件天然低频 + 同类通知 coalesce;具体队列策略待实现时验证 | 见 9.2 | +| D9 | 协议版本 | 分离 ACP、envelope、MCP core、MCP Apps 四类版本;不以 server core version 替代 Apps version | 见 9.3 | ### 3.1 D1:为什么包装,而不是裸传(对 §9.2 的修订) -guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult`、`ui/initialize`)」。定稿改为**包装一层**,理由基于代码事实: +guide §9.2 原方案倾向外层直接使用 Apps 方法名。定稿改为**包装一层**,理由基于代码事实: 1. **方法名空间平铺共享**:`session/*`、`plugin/*`、`marketplace/*`、`mcp/oauth_*` 都在同一信道。裸传 `tools/call`、`ui/initialize` 与 ACP 原生方法名无结构性区分——view 侧桥 JS 必须维护一份「MCP 方法名白名单」才能分辨「剥信封」与「正常处理」,而白名单会随 MCP 规范演进失效。 2. **`mcp/` 前缀已被占用**:`mcp/oauth_*` 占用了 `mcp/` 前缀(guide §6.2 已知冲突点)。裸传方案必须与 `mcp/oauth_*` 共存或改名,包装用 `peri/mcp/` 天然避让。 @@ -86,21 +88,26 @@ guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult` ```jsonc { - "method": "peri/mcp/app", // 透传方法(App 交互) - "id": 100, // 仅 request 携带;notification 省略(JSON-RPC 2.0) + "method": "peri/mcp/app", + "id": 100, "params": { - "serverId": "github", // 路由键:McpClientPool 的 key - "appSessionId": "app_01H4X...",// 路由键:App 会话(握手后存在,见第 6 章) - "protocolVersion": "2026-07-28", // 该 server 协商的 MCP 协议版本(agent 填充) - "payload": { // MCP 原始 JSON-RPC 消息,不做任何改写 + "envelopeVersion": "1", + "serverId": "github", + "appSessionId": "app_01H4X...", + "resourceUri": "ui://get-time/mcp-app.html", + "mcpProtocolVersion": "2026-07-28", + "appsProtocolVersion": "2026-01-26", + "payload": { + "jsonrpc": "2.0", + "id": "req-1", "method": "tools/call", - "params": { "name": "list_issues", "arguments": {} }, - "id": "req-1" // App 侧原始 id(L1),见第 5 章 + "params": { "name": "list_issues", "arguments": {} } } } } ``` +约束:`payload` 保留下游 Apps JSON-RPC 语义,但不会字节级转发到 MCP server;Peri 将其映射到 MCP client API。`mcpProtocolVersion` 表示 Peri 与 server 的 core MCP 协商结果;`appsProtocolVersion` 表示下游 Apps payload 使用的版本,两者不可互换。 资源读取(view 拉 `ui://` HTML 渲染)不依赖 App 会话,单独方法: ```jsonc @@ -133,66 +140,72 @@ guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult` ## 5. id 映射:数据正确性的核心 -### 5.1 三层 id 空间 +### 5.1 两层可见 request id | 层 | id 由谁分配 | 形态 | 用途 | | --- | --- | --- | --- | -| L1 | App(iframe 内) | string 或 number,App 自选 | postMessage 层的请求 id | -| L2 | ACP `RequestRouter` | 全局递增 i64 | 单信道上的请求-响应关联 | -| L3 | rmcp client(McpClientPool 内部) | client 层管理 | agent ↔ server 的请求-响应关联 | +| L1 | 下游 Apps payload | string 或 number | 下游 Apps request/response 关联 | +| L2 | ACP `RequestRouter` | 全局递增 i64 | ACP 单信道 request/response 关联 | + +MCP server 侧 request id 由 `rmcp`/MCP client 内部管理,不属于 ACP payload,也不由 Peri 复制维护。 -**不变量**:`payload.id` 恒为 **L1(App 原始 id)**。L2、L3 只存在于外层信封与 agent 内部映射表中,永不出现在 payload 里。这是「payload 保留 MCP 原始消息」原则的直接推论——view 与 agent 都不改写 payload,两端各自维护自己的外层映射,避免双重映射错误。 +**不变量**:Apps payload 中的 `id` 保持 L1;ACP 外层使用 L2;Peri 将 L1 request 映射为 MCP client API 调用,并用 L2 response 恢复对应的 Apps response。Apps notification 不产生 response;Host → App 的 request 也必须在下游完成自己的 response 关联。 ### 5.2 一次 `tools/call` 的完整旅程 ```mermaid sequenceDiagram - participant A as MCP App(iframe) - participant V as 桥 JS(view 侧) - participant H as App Host(agent 侧) + participant A as MCP App(下游) + participant V as Web Host(下游) + participant H as Peri relay/router participant S as MCP server A->>V: postMessage {id:"req-1", method:"tools/call", params} Note over V: 映射表1: L2=200 → {appSessionId, L1="req-1"} V->>H: send_request("peri/mcp/app", envelope{serverId, appSessionId, payload{id:"req-1"}}) [L2=200] Note over H: 校验 appSessionId ∈ 注册表 且 serverId 匹配(§6.3) - Note over H: 调 McpClientPool.call(serverId, ...) [L3 由 client 层分配] - H->>S: tools/call(L3) - S-->>H: 工具结果(L3 关联) - Note over H: 映射表2: L3 → {appSessionId, L1="req-1", L2=200} - H-->>V: send_response(200, result) 信封 payload{method:"toolresult", id:"req-1"} - Note over V: 查映射表1: 200 → L1="req-1" + Note over H: 调用 MCP client API(server-side request id 由 rmcp 内部管理) + H->>S: tools/call + S-->>H: CallToolResult + H-->>V: send_response(200, result)(标准 JSON-RPC response;无 method) + Note over V: 还原并返回下游 Apps 的 L1 id V-->>A: postMessage {id:"req-1", result} ``` ### 5.3 映射表规则 -- **view 侧(桥 JS)**:`L2 → {appSessionId, L1}`。request 发出时写入,response 到达时取出并还原 L1,随后删除。表项带 TTL(与 agent 侧一致,见 §9.1),超时视为信道错误。 -- **agent 侧(App Host)**:`L3 → {appSessionId, L1, L2}`。向 server 发请求时写入,server 响应到达时取出,构建响应信封(`payload.id` 还原为 L1),经 `transport.send_response(L2)` 返回。 -- 两个方向**互不共享映射表**:view 侧映射与 agent 侧映射是各自独立的(中间隔着 L2 关联),这是单信道多路复用不串扰的结构保证。 +- **下游侧**:负责 L2 与其 Apps L1 id 的关联;这是 Web Host 的实现细节,不属于 Peri。 +- **Peri 侧**:ACP host 已以 L2 关联入站 request;调用 MCP client async API 后,用同一个 L2 返回结果。除非实现证据表明 rmcp 无法关联请求,否则不建立第二份 server request-id pending map。 +- 多个 App 即使复用相同 L1 id,也因 `connectionOwner + appSessionId + L2` 隔离而不冲突。 ### 5.4 并发正确性 -- 多个 App 并发时:L2 全局唯一(RequestRouter 递增),L1 可能重复(两个 App 都用 `"req-1"`)——**L1 重复不冲突**,因为每个 App 的请求绑定唯一 L2,agent 侧映射表 key 是 L3(唯一),view 侧映射表 key 是 L2(唯一)。 -- 同一 App 内并发多个请求:L2 不同,互不干扰。 -- `payload.id` 为 string 时同样成立(L2 是数字,映射表 key 与 payload 内容无关)。 +- 多个 App 或 ACP connections 并发时,L2 由 ACP transport 唯一关联;L1 仅在各自下游 Apps session 内有意义。 +- 同一 App 内并发多个 request 时,下游和 ACP transport 分别维护各自 pending state。 +- `payload.id` 支持 string 或 number;notification 无 id 且不能生成 response。 ## 6. App 会话生命周期与鉴权 -### 6.1 会话注册表(agent 侧,进程级) +### 6.1 会话注册表(按 ACP connection 所有权隔离) ```rust struct AppSession { - app_session_id: String, // 握手时由 agent 生成(uuid) - server_id: String, // McpClientPool key - resource_uri: String, // ui:// 资源 URI(创建依据) - state: AppSessionState, // handshake / active / tearing_down + app_session_id: String, + connection_owner: String, + server_id: String, + server_generation: u64, + resource_uri: String, + allowed_tools: Vec, + apps_protocol_version: Option, + state: AppSessionState, created_at: Instant, } ``` -- 存储:进程级 `RwLock>`(与 `McpClientPool` 同生命周期)。 -- 与 `McpSubscriptionPort` 的 inbox 注册**不同**:透传不按 session 注册,App 会话是进程级(server 是进程级共享的)。 +- 每个 session 必须绑定 ACP connection owner;connection EOF 只清理该 owner 的 session。 +- `server_generation` 防止 MCP server 重连后旧 App session 调用新 peer。 +- `resource_uri` 与 tool binding 来自 `Tool._meta.ui.resourceUri`,不能从 App 自报的 `appInfo.name` 推导,也不能依赖 resource 必须出现在 `resources/list`。 +- session registry 可以由进程级 owner 持有,但 key/值必须包含 connection ownership,不能成为跨连接共享授权状态。 ### 6.2 状态机 @@ -209,24 +222,25 @@ stateDiagram-v2 agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: -1. **会话存在**:`appSessionId` 在注册表中(`ui/initialize` 之前没有会话 → 仅 `ui/initialize` 允许「无会话」状态)。 -2. **归属一致**:信封 `serverId` == 会话注册的 `server_id`。**这一步防止恶意 App(或被劫持的桥 JS)伪造 `serverId` 调用别的 server 的工具。** -3. **状态合法**:`tools/call` 要求 `state == active`;`ui/initialize` 要求无既有会话。 -4. **server 存在**:`serverId` 在 `McpClientPool` 中(`peri/mcp/resource` 也校验此项)。 +1. **会话存在且 owner 一致**:`appSessionId` 属于当前 ACP connection;不能访问其他 connection 的 session。 +2. **归属一致**:信封 `serverId`、绑定 `resourceUri` 与 session 一致。 +3. **server generation 一致**:MCP server 重连后旧 session 必须失效。 +4. **状态合法**:业务 request 仅在 active 状态允许;notification 不产生 response。 +5. **tool 可调用**:tool 属于绑定 server,且 `_meta.ui.visibility` 包含 `"app"`;未声明 visibility 时按规范默认值处理。 +6. **capability 已启用**:当前 ACP connection 声明 Apps/UI capability,且对应 MCP connection profile 已向 server 传播并完成协商。 +7. **权限路径有效**:App 工具调用进入 canonical invocation/HITL seam;不得直接调用低层 MCP peer 绕过审批。 -`ui/initialize` 握手校验(对齐 guide §6.3 实验事实): - -- `payload.params.appInfo` 必含 `name` + `version`(缺 version 报 `-32603`)。 -- 会话创建依据:`appInfo.name` 应能在该 server 的 `ui://` 资源列表中匹配(宽松校验,列表缓存于握手前一次 `resources/list`);匹配失败仅警告不拒绝(资源列表可能未刷新),但 `serverId` 必须属于已知 server。 -- 成功后 response 携带 `appSessionId` 与 `protocolVersion`,view 侧桥 JS 保存用于后续透传。 +`ui/initialize` 属于下游 Web Host ↔ App 的协议握手,Peri 不以 `appInfo.name` 推导授权。Peri session 应由下游针对已发现的 `{serverId, resourceUri, tool binding}` 显式创建/绑定,并将随后 payload 限制在该绑定内。 ### 6.4 teardown 触发 | 触发源 | agent 侧动作 | view 侧动作 | | --- | --- | --- | -| App 主动 teardown(notification) | 销毁会话 | 关闭 iframe | -| server 断开 / 重连失败(`McpClientPool` 状态变化) | 销毁该 server 全部会话,发 `teardown` 推送 | 关闭 iframe 并提示 | -| view 关闭(连接断开) | 按连接清理会话 | — | +| 下游主动关闭 App session | 清理该 connection-owned session 和 pending requests | 下游自行 teardown Web Host/App | +| server 断开或 generation 变化 | 失效该 server 旧 generation 的全部 session;通过 ACP 通知下游 | 下游决定 UI 处置 | +| ACP connection 关闭 | 只清理该 connection owner 的 session | — | + +`ui/resource-teardown` 是下游 Web Host → App 的 request/response,不由 Peri 生成或执行;若下游选择经 ACP 传递相关 lifecycle payload,Peri 仅做 session 定向与至多一次 terminal delivery。 ## 7. 分流规则(防双写) @@ -244,12 +258,15 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: | 类别 | 错误码 | 说明 | | --- | --- | --- | -| envelope 校验失败(§6.3 任一) | `-32001` invalid_app_session / `-32002` forbidden / `-32003` unknown_server | ACP 层错误,`data` 携带原因;payload 不执行 | -| 透传请求超时(§9.1) | `-32000` timeout | agent 侧(server 无响应)与 view 侧(agent 无响应)各自计时 | -| MCP 协议错误(`-32602` 参数错等) | **原样透传** | 封装进 response 的 error 字段,view 不拦截 | -| 工具执行错误(`isError: true` 结果) | **原样透传** | 属正常业务结果,模型/App 自纠 | +| envelope/session/routing 校验失败 | ACP 外层 JSON-RPC error | 不执行 payload;`data.kind` 明确标识 `invalid_session` / `forbidden` / `unknown_server` / `capability_disabled` 等类别 | +| Peri policy/HITL 拒绝 | ACP 外层或 Apps payload response 中的结构化 policy error | 由调用路径明确来源,不依赖 error code 范围判断 | +| MCP JSON-RPC error | Apps payload 内原样保留 | 不提升为 ACP transport error | +| 工具执行错误(`isError: true`) | 标准 `CallToolResult` | 属正常 JSON-RPC result,不转换为 protocol/transport error | +| timeout/disconnect/cancellation | ACP 外层 lifecycle error | pending request 至多结算一次,并带稳定 `data.kind` | -**view 侧桥 JS 分界规则**:response 的 error code 在 `-32000` ~ `-32099` 视为**信道错误**(提示/重试/关闭 App);其余(含 `-32600` 系列)视为 MCP 侧错误,透传给 App 原样处理。 +禁止按 `-32000..-32099` 范围推断错误来自 Peri 还是 MCP:该范围也可被 MCP server 或下游 Host policy 使用。错误层级由“ACP 外层 error”与“payload 内 JSON-RPC error”的结构位置决定。 + +- 错误分类由 ACP 外层 `data.kind` 与 payload 内 JSON-RPC error 的结构位置决定;具体用户提示与重试策略由下游实现。 ## 9. 可靠性:超时 / 背压 / 协议版本 @@ -267,21 +284,132 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: ### 9.3 协议版本 -- `protocolVersion` 由 agent 侧从该 server 的协商结果填充(`McpClientPool` 持有),view 不猜、不存。 -- 桥 JS 按版本分支解析 payload(当前两版在透传相关方法上差异小:`ui/*`、`tools/call`、`toolresult` 基本一致;字段差异集中在握手与订阅,未来版本演进时此字段是唯一判断依据)。 -- `peri/mcp/resource` 响应不需要版本字段(内容是 MCP 原文 content)。 +- `envelopeVersion` 版本化 Peri 私有 envelope schema。 +- `mcpProtocolVersion` 来自 Peri ↔ MCP server 的 core MCP 协商结果。 +- `appsProtocolVersion` 属于下游 App ↔ Web Host 协议;Peri 仅承载和校验支持范围,不用 core MCP version 替代。 +- ACP protocol/capability version 由 ACP initialize contract 单独管理。 +- `peri/mcp/resource` response 应携足以解释 resource 的 envelope/version 信息,不能让下游依赖猜测。 + +### 9.4 Peri 与下游边界 + +本设计的 Peri 改造范围是 **`PERI_MCP_APPS` deployment profile → MCP server capability/data → ACP stdio relay**。下游 Web Host 不由本仓库实现。 + +| 参与方 | 本次是否改造 | 责任 | +| --- | --- | --- | +| 进程 launcher | 配置 | 通过 `PERI_MCP_APPS` 是否存在选择 immutable deployment profile;值不解析 | +| Peri | 是 | prewarm 前冻结 profile;初始连接/重连传播 UI extension;发现 resource/tool metadata;维护 connection-owned binding;resource relay 与 Binding lease/canonical HITL `tools/call` 已接通 | +| MCP server | 按规范提供 | 提供 UI resource、tool metadata 和标准结果 | +| Web Host / our FE | **不由 Peri 实现** | 消费 ACP contract,自行实现 Apps Host/UI | +| `peri-tui` | 否 | 不实现或消费 MCP Apps UI | + +Peri 不负责创建 iframe,不实现 Web Host,不处理浏览器 `postMessage`,不实现 MCP Apps FE SDK。文档中的 Web Host/iframe 仅用于解释下游如何消费 Peri 提供的数据。 + +### 9.5 两段 server-side transport、三层消息、四种数据 + +必须把传输边界画成两段,而不是把 MCP Apps 称为 “stdio UI 协议”: + +```mermaid +flowchart LR + APP["MCP Apps FE\niframe"] <-->|"postMessage\nApps JSON-RPC"| FE["our FE\nApp Host / Bridge"] + FE <-->|"ACP JSON-RPC\nstdio newline framing\nperi/mcp/* envelope"| P["Peri\nACP stdio host"] + P <-->|"MCP JSON-RPC\nrmcp peer\nstdio 或 Streamable HTTP"| S["MCP server"] +``` + +- **Peri → 下游数据**:Peri 只通过 ACP envelope 提供 resource、tool input/result 和 control/context payload;下游 Web Host 自行决定是否及如何转发给 MCP Apps FE。 +- **下游 → Peri 数据**:下游 Web Host 将其 Apps protocol payload 包装进 ACP request;Peri 校验 capability、session、server/tool 归属后才访问 MCP server。 +- **Peri ↔ MCP server**:仍是标准 MCP JSON-RPC,不把 ACP envelope 转发给 MCP server。 +- **D1:UI resource**:HTML、MIME、CSP、permissions、domain 等展示/安全元数据。 +- **D2:tool input**:工具调用参数;host/model 发起时可先推送给 App。 +- **D3:tool result**:完整 `content`、`structuredContent`、`_meta`、`isError`;不得压扁成文本。 +- **D4:control/context**:初始化、host context、尺寸、teardown、能力请求;按 Apps 协议方向传递。 + +stdio 只负责可靠传递 ACP JSON-RPC 报文(当前实现是一行一个 JSON 报文);HTML 不应被拼接到裸 stdout,也不应绕过 JSON-RPC envelope 写入 stdout。所有日志继续走 stderr/tracing,避免污染 stdio 数据流。 + +### 9.6 端到端时序:从能力协商到 App 交互 + +```mermaid +sequenceDiagram + participant F as 下游 ACP client / Web Host + participant P as Peri stdio + participant M as MCP server + participant A as MCP Apps FE(下游) + + Note over P: 启动前读取 PERI_MCP_APPS(存在即启用) + P->>M: MCP initialize(deployment profile 传播 UI extension) + M-->>P: initialize result(core MCP version/capabilities) + F->>P: ACP initialize(不协商 MCP Apps capability) + P-->>F: 普通 ACP initialize response + P->>M: tools/list + M-->>P: Tool + _meta.ui.resourceUri + visibility + F->>P: peri/mcp/open(serverId, toolName) + P-->>F: appSessionId + resourceUri + F->>P: peri/mcp/resource(appSessionId) + P->>M: resources/read(uri=ui://...) + M-->>P: contents[](text|blob, mimeType, _meta.ui) + P-->>F: resource response(完整 resource content) + Note over P,F: open 单次消费初始 canonical invocation 签发的 Binding lease + Note over F,A: 下游自行完成 ui/initialize / initialized + A->>F: tools/call request + F->>P: peri/mcp/app request + P->>M: 经 lease EffectiveToolDispatcher + Permission/HITL 调用 canonical MCP bridge + M-->>P: raw CallToolResult + P-->>F: Apps JSON-RPC response(完整 raw result) +``` + +模型/host 发起的调用是另一条路径:Peri 把完整 tool input/result 及 `resourceUri` 定向投影给下游;下游自行生成 `ui/notifications/tool-input-partial`、`tool-input`、`tool-result` 或 `tool-cancelled`。App 自己发起的调用只返回标准 response,不能再发送一次 tool-result notification。 + +### 9.7 Deployment capability、初始化时序与降级 + +1. `PERI_MCP_APPS` 只按存在性解释;空串、`0` 等值同样启用。 +2. profile 在进程内通过一次性缓存冻结,并在 MCP pool prewarm 前读取。 +3. 初始连接、OAuth 路径、静态重连和 Dynamic MCP 连接均复用同一 profile,在 MCP `initialize` 中传播 `io.modelcontextprotocol/ui` 与 HTML MIME。 +4. 环境变量不存在时不传播 extension,stdio Apps relay backend 不装配,Apps methods 返回 `capability_disabled`。 +5. ACP initialize 不解析或回显任何 MCP Apps capability。 +6. tool 无合法 `_meta.ui.resourceUri`、visibility 不含 `app` 或 resource MIME/body 非法时 fail closed;普通模型 MCP 路径保持可用。 +7. App `tools/call` 必须持有由初始 canonical MCP invocation 签发、`open` 按 server/tool/resource/generation 单次消费并绑定 ACP connection 的 Binding lease;调用经 `EffectiveToolDispatcher` 进入 effective view 与 Permission/HITL,禁止直调 MCP peer。TTL、session 最新 turn generation、cancellation、server generation 与 ACP EOF 共同负责失效/撤销。 + +### 9.8 资源与结果的缓存边界 + +- `resources/list` 仅可作为可选发现快照;UI-only resource 可以不出现在列表中。工具关联的权威 URI 来自 `Tool._meta.ui.resourceUri`。 +- `peri/mcp/resource` 必须保留 `contents[]`、`uri`、`mimeType`、`text|blob` 和 content `_meta.ui`;不能只投影为 HTML 字符串。 +- content item `_meta.ui` 覆盖 listing-level metadata;Peri 原样提供 `csp`、`permissions`、`domain`、`prefersBorder` 等字段,下游负责执行安全策略。 +- `_meta.ui.visibility` 默认按规范解释;`model` 控制是否进入模型 tool list,`app` 控制是否允许 Apps session 调用。 +- 缓存键至少包含 `connection/profile + server generation + serverId + resourceUri + negotiated version`;不得跨 server、generation 或 capability profile 复用。 +- 工具结果通常不可缓存:`structuredContent` 可能含用户数据或一次性状态;如未来缓存,必须按 App session、tool input 和 server identity 隔离,并明确 TTL。 +- 失败的 HTML/CSP 校验不得降级为把 HTML 注入 host DOM;只能关闭 App 并保留普通文本工具结果。 + +### 9.9 可观测性与测试断言 + +不记录 prompt、工具参数中的 secret、HTML 正文、OAuth token 或完整用户数据。允许记录不可逆的 `serverId`(若其本身不含敏感信息)、resource URI 的 scheme/host 摘要、App session hash、方法名、方向、耗时、结果大小和错误类别。 + +最小测试矩阵: + +| 场景 | 必须断言 | +| --- | --- | +| capability absent/present/malformed,或 MIME 不受支持 | absent 时 MCP initialize 不含 UI extension;malformed 安全拒绝;普通 MCP 不回归 | +| MCP 已 prewarm 且 profile 与 ACP capability 不一致 | 不在已初始化 peer 上补发 capability;延迟、独立连接或显式重连 | +| 两个 ACP connections 使用不同 capability | capability、App session、cache 与结果不串 connection | +| 两个 App 同时使用相同 L1 id | 由 connection owner + appSessionId + ACP L2 隔离 | +| App 指定另一个 `serverId` / `resourceUri` / 旧 generation | 被拒绝,server 不收到 `tools/call` | +| visibility 为 `model` 或 `app` | model/app tool surface 按 visibility 分流,默认值符合规范 | +| resource 为多 contents、`text` 或 `blob` | `uri`、`mimeType`、`text/blob`、content `_meta.ui` 完整往返 | +| `structuredContent` + `content` + `_meta` + `isError` | 字段逐项保留;`isError` 是正常 result,不变成 transport error | +| App 主动调用与模型调用 | 前者只返回标准 JSON-RPC response;后者只产生一次下游 input/result/cancelled 投影 | +| session teardown、stdio EOF、server generation 变化、timeout/cancel | 只清理对应 owner/generation;pending request 至多结算一次 | +| 不支持 Apps 的 server/client | 普通 MCP tool/resource/skills/subscription 行为保持不变 | + -## 10. 落地清单与前置依赖 | # | 改动 | 位置 | 依赖 | | --- | --- | --- | --- | -| 0 | **MCP Apps 能力声明**:client 初始化声明 `enable_extensions_with({"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}})` | `peri-middlewares/src/mcp/channel_handler.rs`(当前 `ClientCapabilities::default()`) | **无——所有 App 生态的前提**:不声明则 server 不下发 `ui://` 资源 | -| 1 | `peri/mcp/app`、`peri/mcp/resource` 方法分发(view → agent 方向) | ACP 命令分发处(与 `session/*` 同入口) | 无 | -| 2 | App Host:会话注册表 + 握手校验 + teardown | `peri-middlewares/src/mcp/`(新模块,与 `McpClientPool` 同层) | 1 | -| 3 | App Host:id 映射(L3 → L1/L2)与 `tools/call` 路由(复用 `McpClientPool` + HITL) | 同上 | 1、2 | -| 4 | `peri/mcp/resource`:`resources/read` 透传(复用 `resource_tool.rs`) | 同上 | 1 | -| 5 | 桥 JS:信封编解码 + L2 → L1 映射 + 版本分支 | TUI webview 方案(guide §9.5 方案 A) | 2、3 | -| 6 | 工具结果事件携带 `ui.resourceUri`(**确认未落地**:`tool_bridge.rs` 未读取 `Tool._meta`) | 事件链 tool_result | guide §9.4 | +| 0 | **读取冻结的 MCP Apps deployment profile**:stdio 启动时只读取一次 `PERI_MCP_APPS`,作为 Apps relay 与 MCP UI extension 的唯一启用来源 | stdio host 装配 / immutable capability profile | 无——所有 App 生态的前提 | +| 1 | **按 deployment profile 构造 MCP server capability**:profile enabled 时在 MCP client initialize 中发送 UI extension;disabled 时保持普通 MCP capabilities | `peri-middlewares` MCP 初始化与重连装配 | 0 | +| 2 | `peri/mcp/app`、`peri/mcp/resource` 方法分发(下游 UI → Peri 方向) | ACP 命令分发处(与 `session/*` 同入口) | 0、1 | +| 3 | App session:会话注册表、server/resource 绑定、握手校验与 teardown | `peri-acp`/`peri-middlewares` 的协议实现层 | 2 | +| 4 | id 映射与 `tools/call` 路由(复用 `McpClientPool` + 既有权限/HITL) | 同上 | 2、3 | +| 5 | `peri/mcp/resource`:`resources/read` 透传,保留 HTML/MIME/`_meta.ui` | 同上(复用现有 MCP resource 能力) | 1、2 | +| 6 | agent/model 侧工具结果关联 UI resource,并通过 ACP 定向通知下游 | 事件/结果投影层;不改变 TUI 路径 | 3、4 | +| 7 | **下游实现项(不属于 Peri)**:Web Host 的 iframe、sandbox、CSP、Permissions Policy、`postMessage` bridge、MCP Apps FE | 下游项目 | Peri ACP contract 稳定后 | **rmcp 侧支持情况(已调查,rmcp 3.1.2)**:无 MCP Apps 专用 handler(`ui/*` 消息在规范上不走 MCP 连接,由宿主侧 App Host 逻辑处理,见 §6);但全部透传基础已在——第 0 项能力声明(`ExtensionCapabilities`)、`on_custom_request` / `on_custom_notification` 扩展点(默认 `-32601` 拒绝,安全)、`send_custom_notification`(`mcp_notify.rs` 已用)、`Tool._meta`(`_meta.ui.resourceUri` 可透传)、`resources/read`(`resource_tool.rs` 已用)。 @@ -289,7 +417,6 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: ## 11. 未决问题 -1. **高频 App 事件的硬隔离**(§9.2):透传与事件链共用通道的容量上限未量化;流式 App 出现时需评估独立队列方案。 -2. **多 view 连接共存**:当前 TUI 与 ACP Server 一对一(MpscTransport)。未来 stdio 外部 IDE 与 TUI 并存时,App 会话注册表按连接隔离还是全局?(`peri/mcp/*` 信封暂不带 sessionId,届时需评估。) -3. **teardown 与重连的竞态**:server 重连期间 App 会话的处理策略(销毁重来 vs 挂起等待)未定。 -4. **`appInfo.name` ↔ `ui://` 资源匹配的严格度**:§6.3 当前取宽松校验,是否需要缓存资源列表做严格校验,取决于多 server 同名 App 的实际冲突概率。 +1. **高频 App 事件的硬隔离**:透传与事件链共用通道的容量上限未量化;流式 App 出现时需评估独立队列方案。 +2. **Host → App 双向 request**:若下游需要通过 ACP 让 Peri 触发下游 request,需为 `ui/resource-teardown` 等定义反向 ACP request/response contract;不能退化成 notification。 +3. **Lease 长期策略**:首版 TTL 为 5 分钟且与初始 turn cancellation 绑定;未来若要求跨 turn 长驻 App,需要新的 session-owned dispatcher lease,而不是延长 turn-local lease。 diff --git a/peri-acp-types/src/lib.rs b/peri-acp-types/src/lib.rs index b0b23d65..0c09c3ea 100644 --- a/peri-acp-types/src/lib.rs +++ b/peri-acp-types/src/lib.rs @@ -50,6 +50,7 @@ pub mod identity; pub mod interaction; pub mod lsp; pub mod mcp; +pub mod mcp_apps; pub mod mcp_skills; pub mod messages; pub mod meta_harness; diff --git a/peri-acp-types/src/mcp_apps.rs b/peri-acp-types/src/mcp_apps.rs new file mode 100644 index 00000000..088f294d --- /dev/null +++ b/peri-acp-types/src/mcp_apps.rs @@ -0,0 +1,256 @@ +//! MCP Apps Stable ACP relay contracts. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const MCP_APPS_PROTOCOL_VERSION: &str = "2026-01-26"; +pub const MCP_APPS_ENVELOPE_VERSION: &str = "1"; +pub const MCP_APPS_HTML_MIME: &str = "text/html;profile=mcp-app"; +pub const MCP_APPS_ENV: &str = "PERI_MCP_APPS"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpAppOpenRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub tool_name: String, + pub owner_session_id: String, + pub invocation_token: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppOpenResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpAppRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, + pub payload: JsonRpcRequest, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpResourceRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, + pub payload: JsonRpcResponse, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourceResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub resources: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub method: String, + pub params: CallToolParams, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CallToolParams { + pub name: String, + #[serde(default)] + pub arguments: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + String(String), + Number(i64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JsonRpcVersion { + #[serde(rename = "2.0")] + V2, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcResponse { + Result(JsonRpcResultResponse), + Error(JsonRpcErrorResponse), +} + +impl JsonRpcResponse { + pub fn id(&self) -> &JsonRpcId { + match self { + Self::Result(response) => &response.id, + Self::Error(response) => &response.id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsonRpcResultResponse { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub result: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsonRpcErrorResponse { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub error: JsonRpcError, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawResource { + pub uri: String, + #[serde(rename = "mimeType")] + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl RawResource { + pub fn is_valid_app_resource(&self, requested_uri: &str) -> bool { + self.uri == requested_uri + && self.uri.starts_with("ui://") + && self.mime_type == MCP_APPS_HTML_MIME + && self.text.is_some() != self.blob.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawCallToolResult { + #[serde(default)] + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_error: bool, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppSessionBinding { + pub app_session_id: String, + pub owner_connection_id: String, + pub owner_session_id: String, + pub server_id: String, + pub server_generation: u64, + pub resource_uri: String, + pub instantiating_tool: String, + pub apps_protocol_version: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpAppsErrorKind { + InvalidEnvelope, + UnsupportedEnvelopeVersion, + UnsupportedAppsVersion, + CapabilityDisabled, + UnknownServer, + ServerDisconnected, + StaleServerGeneration, + InvalidSession, + Forbidden, + ToolNotFound, + ToolNotAppVisible, + ResourceNotFound, + InvalidResource, + PolicyDenied, + Cancelled, + UnsupportedMethod, + UpstreamProtocolError, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("MCP Apps relay request failed")] +pub struct McpAppsRelayError { + pub kind: McpAppsErrorKind, +} + +#[async_trait::async_trait] +pub trait McpAppsRelayPort: Send + Sync { + fn close_connection(&self, owner_connection_id: &str); + fn close_session(&self, owner_session_id: &str); + fn begin_session_turn(&self, owner_session_id: &str); + + async fn open_app( + &self, + owner_connection_id: &str, + request: &McpAppOpenRequest, + ) -> Result<(String, AppSessionBinding), McpAppsRelayError>; + + async fn validate_binding( + &self, + binding: &AppSessionBinding, + ) -> Result; + + async fn read_resource( + &self, + binding: &AppSessionBinding, + ) -> Result<(String, Vec), McpAppsRelayError>; + + async fn call_tool( + &self, + binding: &AppSessionBinding, + request: JsonRpcRequest, + ) -> Result<(String, JsonRpcResponse), McpAppsRelayError>; +} + +#[cfg(test)] +#[path = "mcp_apps_test.rs"] +mod tests; diff --git a/peri-acp-types/src/mcp_apps_test.rs b/peri-acp-types/src/mcp_apps_test.rs new file mode 100644 index 00000000..af80b52d --- /dev/null +++ b/peri-acp-types/src/mcp_apps_test.rs @@ -0,0 +1,77 @@ +use super::*; +use serde_json::json; + +#[test] +fn inbound_app_envelope_rejects_mcp_protocol_version() { + let value = json!({ + "envelopeVersion": "1", + "appsProtocolVersion": MCP_APPS_PROTOCOL_VERSION, + "mcpProtocolVersion": "2025-11-25", + "serverId": "server", + "appSessionId": "app", + "resourceUri": "ui://app", + "payload": {"jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{}} + }); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn raw_result_roundtrip_preserves_unknown_fields() { + let value = json!({ + "content": [{"type":"resource", "resource":{"uri":"ui://app", "text":""}}], + "structuredContent": {"answer": 42}, + "_meta": {"vendor": {"opaque": true}}, + "isError": true, + "futureField": {"preserved": true} + }); + let decoded: RawCallToolResult = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), value); +} + +#[test] +fn json_rpc_response_requires_exactly_one_terminal_shape() { + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {}, + "error": {"code": -1, "message": "bad"} + })) + .is_err()); + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": 1 + })) + .is_err()); +} + +#[test] +fn call_tool_params_require_name_and_object_arguments() { + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": "request", + "method": "tools/call", + "params": {"arguments": {}} + })) + .is_err()); + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": "request", + "method": "tools/call", + "params": {"name": "tool", "arguments": []} + })) + .is_err()); +} + +#[test] +fn app_resource_requires_exact_uri_mime_and_one_body() { + let resource = RawResource { + uri: "ui://app".into(), + mime_type: MCP_APPS_HTML_MIME.into(), + text: Some("".into()), + blob: None, + meta: BTreeMap::from([("ui".into(), json!({"csp": {"connectDomains": []}}))]), + extra: BTreeMap::new(), + }; + assert!(resource.is_valid_app_resource("ui://app")); + assert!(!resource.is_valid_app_resource("ui://other")); +} diff --git a/peri-acp-types/src/tools.rs b/peri-acp-types/src/tools.rs index fbb951c5..e23f63d7 100644 --- a/peri-acp-types/src/tools.rs +++ b/peri-acp-types/src/tools.rs @@ -216,6 +216,10 @@ pub struct ToolContext<'a> { pub invocation_id: Option, /// 当前外层调用的取消令牌。 pub cancellation: tokio_util::sync::CancellationToken, + /// 当前 Agent session identity;仅 canonical dispatch 中存在。 + pub session_id: Option, + /// 当前 turn generation;用于撤销跨 turn 的宿主调用租约。 + pub turn_generation: Option, } impl<'a> ToolContext<'a> { @@ -226,6 +230,8 @@ impl<'a> ToolContext<'a> { effective_tool_dispatcher: None, invocation_id: None, cancellation: tokio_util::sync::CancellationToken::new(), + session_id: None, + turn_generation: None, } } @@ -240,6 +246,16 @@ impl<'a> ToolContext<'a> { self.cancellation = cancellation; self } + + pub fn with_session_identity( + mut self, + session_id: impl Into, + turn_generation: impl Into, + ) -> Self { + self.session_id = Some(session_id.into()); + self.turn_generation = Some(turn_generation.into()); + self + } } /// A target-specific canonical action bound before middleware/HITL. @@ -332,6 +348,12 @@ pub trait BaseTool: Send + Sync { false } + /// Whether this tool may be projected into the model-facing tool catalog. + /// Host-only tools remain dispatchable through the canonical catalog/HITL. + fn visible_to_model(&self) -> bool { + true + } + /// 短显示名(≤ 6 词,名词短语)。缺省时由 [`derive_title_from_name`] 推导。 fn title(&self) -> Option<&str> { None diff --git a/peri-acp/src/host/assemble.rs b/peri-acp/src/host/assemble.rs index f620054c..9135f220 100644 --- a/peri-acp/src/host/assemble.rs +++ b/peri-acp/src/host/assemble.rs @@ -137,6 +137,30 @@ pub fn build_session_manager( /// 边 2 assemble 路径);行为与迁移前三路径(launch / cli_print / stdio) /// 各自装配一致(cron tick 驱动、MCP 初始化、孤儿插件清理时机均复刻)。 pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig { + assemble_server_config_with_mcp_profile( + input, + peri_middlewares::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await +} + +/// stdio deployment variant. The assembly boundary derives the concrete MCP profile; +/// TUI/MPSC always use [`assemble_server_config`]. +pub async fn assemble_server_config_with_mcp_apps( + input: HostAssemblyInput, + apps_enabled: bool, +) -> AcpServerConfig { + assemble_server_config_with_mcp_profile( + input, + peri_middlewares::mcp::apps::deployment_profile(apps_enabled), + ) + .await +} + +async fn assemble_server_config_with_mcp_profile( + input: HostAssemblyInput, + mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile, +) -> AcpServerConfig { let (host_task_owner, host_task_spawner) = HostTaskOwner::new(); let (mcp_task_owner, mcp_task_spawner) = peri_middlewares::mcp::McpTaskOwner::new(); let HostAssemblyInput { @@ -201,8 +225,9 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig None } else { let pool = Arc::new( - peri_middlewares::mcp::McpClientPool::new_pending_with_spawner( + peri_middlewares::mcp::McpClientPool::new_pending_with_spawner_and_profile( mcp_task_spawner.clone(), + mcp_profile.clone(), ), ); let pool_clone = pool.clone(); @@ -334,8 +359,9 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig mcp_task_spawner.clone(), mcp_pool_concrete.clone().unwrap_or_else(|| { Arc::new( - peri_middlewares::mcp::McpClientPool::new_pending_with_spawner( + peri_middlewares::mcp::McpClientPool::new_pending_with_spawner_and_profile( mcp_task_spawner.clone(), + mcp_profile.clone(), ), ) }), @@ -348,8 +374,19 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig // McpSubscriptionPort(订阅通知 → 会话 inbox 唤醒)两个角色。 let mcp_pool: Option> = mcp_pool_concrete.clone().map(|p| p as Arc); - let mcp_subscription: Option> = - mcp_pool_concrete.map(|p| p as Arc); + let mcp_subscription: Option> = mcp_pool_concrete + .clone() + .map(|p| p as Arc); + let mcp_apps_relay: Option> = + if mcp_profile.apps_enabled() { + mcp_pool_concrete.clone().map(|pool| { + Arc::new(peri_middlewares::mcp::apps_relay::PoolMcpAppsRelay::new( + pool, + )) as Arc + }) + } else { + None + }; // ── 资源类/业务面端口默认实现(构造下沉:ACP Host = 部署单元)── let tool_search_index: Arc = @@ -458,6 +495,7 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig permission_mode, cron_scheduler, mcp_pool, + mcp_apps_relay, dynamic_mcp: Some(dynamic_mcp), oauth_event_tx: Some(oauth_event_tx), oauth_event_rx: Some(oauth_event_rx), diff --git a/peri-acp/src/host/connection.rs b/peri-acp/src/host/connection.rs new file mode 100644 index 00000000..96dcc89a --- /dev/null +++ b/peri-acp/src/host/connection.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use peri_acp_types::mcp_apps::AppSessionBinding; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnectionLifecycle { + Open, + Closing, + Closed, +} + +pub(crate) struct ConnectionContext { + id: String, + lifecycle: ConnectionLifecycle, + initialized: bool, + apps_enabled: bool, + app_sessions: HashMap, + cancellation: tokio_util::sync::CancellationToken, +} + +impl ConnectionContext { + pub(crate) fn new(apps_enabled: bool) -> Self { + Self { + id: uuid::Uuid::now_v7().to_string(), + lifecycle: ConnectionLifecycle::Open, + initialized: false, + apps_enabled, + app_sessions: HashMap::new(), + cancellation: tokio_util::sync::CancellationToken::new(), + } + } + + pub(crate) fn commit_initialize(&mut self) { + if !self.initialized && self.lifecycle == ConnectionLifecycle::Open { + self.initialized = true; + } + } + + pub(crate) fn apps_enabled(&self) -> bool { + self.initialized && self.lifecycle == ConnectionLifecycle::Open && self.apps_enabled + } + + #[allow(dead_code)] + pub(crate) fn insert_app_session(&mut self, binding: AppSessionBinding) -> bool { + if self.apps_enabled() && binding.owner_connection_id == self.id { + self.app_sessions + .insert(binding.app_session_id.clone(), binding); + true + } else { + false + } + } + + pub(crate) fn app_session(&self, id: &str) -> Option<&AppSessionBinding> { + self.apps_enabled() + .then(|| self.app_sessions.get(id)) + .flatten() + } + + pub(crate) fn snapshot_for_request(&self) -> Self { + Self { + id: self.id.clone(), + lifecycle: self.lifecycle, + initialized: self.initialized, + apps_enabled: self.apps_enabled, + app_sessions: self.app_sessions.clone(), + cancellation: self.cancellation.clone(), + } + } + + pub(crate) fn cancellation(&self) -> tokio_util::sync::CancellationToken { + self.cancellation.clone() + } + + pub(crate) fn begin_close(&mut self) { + self.lifecycle = ConnectionLifecycle::Closing; + self.cancellation.cancel(); + self.app_sessions.clear(); + } + + #[allow(dead_code)] + pub(crate) fn finish_close(&mut self) { + self.lifecycle = ConnectionLifecycle::Closed; + self.app_sessions.clear(); + } + + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +#[cfg(test)] +#[path = "connection_test.rs"] +mod tests; diff --git a/peri-acp/src/host/connection_test.rs b/peri-acp/src/host/connection_test.rs new file mode 100644 index 00000000..766f63a9 --- /dev/null +++ b/peri-acp/src/host/connection_test.rs @@ -0,0 +1,33 @@ +use super::*; +use peri_acp_types::mcp_apps::{AppSessionBinding, MCP_APPS_PROTOCOL_VERSION}; + +#[test] +fn deployment_capability_is_immutable_and_close_invalidates_sessions() { + let mut connection = ConnectionContext::new(true); + assert!(!connection.apps_enabled()); + connection.commit_initialize(); + connection.commit_initialize(); + assert!(connection.apps_enabled()); + + connection.insert_app_session(AppSessionBinding { + app_session_id: "app".into(), + owner_connection_id: connection.id().into(), + owner_session_id: String::new(), + server_id: "server".into(), + server_generation: 1, + resource_uri: "ui://app".into(), + instantiating_tool: "open".into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + }); + assert!(connection.app_session("app").is_some()); + connection.begin_close(); + assert!(connection.app_session("app").is_none()); + connection.finish_close(); +} + +#[test] +fn absent_deployment_capability_stays_disabled() { + let mut connection = ConnectionContext::new(false); + connection.commit_initialize(); + assert!(!connection.apps_enabled()); +} diff --git a/peri-acp/src/host/mcp_apps.rs b/peri-acp/src/host/mcp_apps.rs new file mode 100644 index 00000000..fd7d6078 --- /dev/null +++ b/peri-acp/src/host/mcp_apps.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use peri_acp_types::mcp_apps::{ + AppSessionBinding, McpAppOpenRequest, McpAppOpenResponse, McpAppRequest, McpAppResponse, + McpAppsErrorKind, McpAppsRelayError, McpAppsRelayPort, McpResourceRequest, McpResourceResponse, + MCP_APPS_ENVELOPE_VERSION, MCP_APPS_PROTOCOL_VERSION, +}; +use serde_json::Value; + +use super::connection::ConnectionContext; +use crate::transport::types::AcpError; + +pub(crate) async fn handle_request( + method: &str, + params: &Value, + connection: &mut ConnectionContext, + relay: Option<&Arc>, +) -> Result { + if !connection.apps_enabled() { + return Err(outer_error(McpAppsErrorKind::CapabilityDisabled)); + } + let relay = relay.ok_or_else(|| outer_error(McpAppsErrorKind::CapabilityDisabled))?; + match method { + "peri/mcp/open" => handle_open(params, connection, relay).await, + "peri/mcp/resource" => handle_resource(params, connection, relay).await, + "peri/mcp/app" => handle_app(params, connection, relay).await, + _ => Err(outer_error(McpAppsErrorKind::UnsupportedMethod)), + } +} + +async fn handle_open( + params: &Value, + connection: &mut ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpAppOpenRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + let (mcp_protocol_version, binding) = relay + .open_app(connection.id(), &request) + .await + .map_err(map_relay_error)?; + if binding.owner_connection_id != connection.id() { + return Err(outer_error(McpAppsErrorKind::InvalidSession)); + } + let response = McpAppOpenResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: binding.server_id.clone(), + app_session_id: binding.app_session_id.clone(), + resource_uri: binding.resource_uri.clone(), + }; + if !connection.insert_app_session(binding) { + relay.close_connection(connection.id()); + return Err(outer_error(McpAppsErrorKind::InvalidSession)); + } + serialize(response) +} + +async fn handle_resource( + params: &Value, + connection: &ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpResourceRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + let binding = connection + .app_session(&request.app_session_id) + .filter(|binding| { + binding.server_id == request.server_id + && binding.resource_uri == request.resource_uri + && binding.apps_protocol_version == request.apps_protocol_version + }) + .ok_or_else(|| outer_error(McpAppsErrorKind::InvalidSession))?; + let cancellation = connection.cancellation(); + let mcp_protocol_version = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.validate_binding(binding) => result.map_err(map_relay_error)?, + }; + let (resource_protocol_version, resources) = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.read_resource(binding) => result.map_err(map_relay_error)?, + }; + if resource_protocol_version != mcp_protocol_version { + return Err(outer_error(McpAppsErrorKind::StaleServerGeneration)); + } + if !resources + .iter() + .any(|resource| resource.is_valid_app_resource(&request.resource_uri)) + { + return Err(outer_error(McpAppsErrorKind::InvalidResource)); + } + serialize(McpResourceResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: request.server_id, + resources, + }) +} + +async fn handle_app( + params: &Value, + connection: &ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpAppRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + if request.payload.method != "tools/call" { + return Err(outer_error(McpAppsErrorKind::UnsupportedMethod)); + } + let binding = connection + .app_session(&request.app_session_id) + .filter(|binding| { + binding.server_id == request.server_id + && binding.resource_uri == request.resource_uri + && binding.apps_protocol_version == request.apps_protocol_version + }) + .ok_or_else(|| outer_error(McpAppsErrorKind::InvalidSession))?; + let cancellation = connection.cancellation(); + let mcp_protocol_version = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.validate_binding(binding) => result.map_err(map_relay_error)?, + }; + let request_id = request.payload.id.clone(); + let (response_protocol_version, payload) = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.call_tool(binding, request.payload) => result.map_err(map_relay_error)?, + }; + if response_protocol_version != mcp_protocol_version { + return Err(outer_error(McpAppsErrorKind::StaleServerGeneration)); + } + if payload.id() != &request_id { + return Err(outer_error(McpAppsErrorKind::UpstreamProtocolError)); + } + serialize(McpAppResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: request.server_id, + app_session_id: request.app_session_id, + resource_uri: request.resource_uri, + payload, + }) +} + +fn validate_versions(envelope: &str, apps: &str) -> Result<(), AcpError> { + if envelope != MCP_APPS_ENVELOPE_VERSION { + return Err(outer_error(McpAppsErrorKind::UnsupportedEnvelopeVersion)); + } + if apps != MCP_APPS_PROTOCOL_VERSION { + return Err(outer_error(McpAppsErrorKind::UnsupportedAppsVersion)); + } + Ok(()) +} + +fn decode(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|_| outer_error(McpAppsErrorKind::InvalidEnvelope)) +} + +fn serialize(value: T) -> Result { + serde_json::to_value(value).map_err(|_| AcpError::new(-32603, "MCP Apps relay request failed")) +} + +fn map_relay_error(error: McpAppsRelayError) -> AcpError { + outer_error(error.kind) +} + +fn outer_error(kind: McpAppsErrorKind) -> AcpError { + let kind = + serde_json::to_value(kind).unwrap_or(Value::String("upstream_protocol_error".into())); + AcpError::new(-32000, "MCP Apps relay request failed") + .with_data(serde_json::json!({"kind": kind})) +} + +#[allow(dead_code)] +pub(crate) fn initial_binding( + owner_connection_id: String, + owner_session_id: String, + server_id: String, + server_generation: u64, + resource_uri: String, + instantiating_tool: String, +) -> AppSessionBinding { + AppSessionBinding { + app_session_id: uuid::Uuid::now_v7().to_string(), + owner_connection_id, + owner_session_id, + server_id, + server_generation, + resource_uri, + instantiating_tool, + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + } +} + +#[cfg(test)] +#[path = "mcp_apps_test.rs"] +mod tests; diff --git a/peri-acp/src/host/mcp_apps_test.rs b/peri-acp/src/host/mcp_apps_test.rs new file mode 100644 index 00000000..079671fd --- /dev/null +++ b/peri-acp/src/host/mcp_apps_test.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn version_validation_is_fail_closed() { + assert!(validate_versions(MCP_APPS_ENVELOPE_VERSION, MCP_APPS_PROTOCOL_VERSION).is_ok()); + let error = validate_versions("2", MCP_APPS_PROTOCOL_VERSION).unwrap_err(); + assert_eq!(error.data.unwrap()["kind"], "unsupported_envelope_version"); +} + +#[test] +fn initial_binding_uses_stable_apps_version() { + let binding = initial_binding( + "connection".into(), + "session".into(), + "server".into(), + 7, + "ui://app".into(), + "open".into(), + ); + assert_eq!(binding.server_generation, 7); + assert_eq!(binding.apps_protocol_version, MCP_APPS_PROTOCOL_VERSION); +} diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index 5feac56e..db4977e8 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -42,12 +42,14 @@ use peri_acp_types::event_data::PredictionAction; pub mod assemble; pub(crate) mod compact_config; +mod connection; mod continuation; pub mod controller_ports; #[cfg(test)] #[path = "executor_flow_test.rs"] mod executor_flow_tests; pub mod lease; +mod mcp_apps; mod notify; mod prediction_projection; mod prompt; @@ -124,6 +126,8 @@ pub struct AcpServerConfig { pub permission_mode: Arc, pub cron_scheduler: Option>, pub mcp_pool: Option>, + /// Optional stdio-only MCP Apps backend. Absence keeps the capability fail closed. + pub mcp_apps_relay: Option>, pub dynamic_mcp: Option>, /// OAuth 授权事件通道(host 级,跨 session):装配点创建 (tx, rx) 并注入 /// tx(MCP 授权回调经此转发 AcpEvent),run_acp_server take rx 后 spawn @@ -500,6 +504,10 @@ async fn run_acp_server_inner( ), ); + let connection = Arc::new(tokio::sync::Mutex::new(connection::ConnectionContext::new( + cfg.stdio_command_filter && cfg.mcp_apps_relay.is_some(), + ))); + let connection_cancellation = connection.lock().await.cancellation(); while let Some(msg) = transport.recv().await { match msg { IncomingMessage::Request { id, method, params } => { @@ -507,13 +515,20 @@ async fn run_acp_server_inner( // Spawn long-running prompt execution so the server loop // continues processing session/cancel notifications. let prompt_session_id = extract_session_id(¶ms, "").to_string(); + if !prompt_session_id.is_empty() { + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.begin_session_turn(&prompt_session_id); + } + } let sessions = sessions.clone(); let transport = Arc::clone(&transport); let prompt_locks = prompt_locks.clone(); let cfg = Arc::clone(&cfg); let cont_tx = cont_tx.clone(); let prompt_spawner = cfg.host_task_spawner.clone(); - let _ = prompt_spawner.spawn( + let rejected_transport = Arc::clone(&transport); + let rejected_id = id.clone(); + let spawn_result = prompt_spawner.spawn( task_scope::HostTaskOwnerKind::Session, task_scope::HostTaskKind::Prompt, async move { @@ -528,17 +543,112 @@ async fn run_acp_server_inner( &cont_tx, ) .await; - let _ = transport.send_response(id, result).await; + if let Err(error) = transport.send_response(id, result).await { + tracing::warn!(%error, "prompt terminal response send failed"); + return; + } if !prompt_session_id.is_empty() { send_session_info_update(transport.as_ref(), &prompt_session_id) .await; } }, ); + if spawn_result.is_err() { + if let Err(error) = rejected_transport + .send_response( + rejected_id, + Err(crate::transport::types::AcpError::new( + -32800, + "request cancelled", + )), + ) + .await + { + tracing::warn!(%error, "rejected prompt response send failed"); + } + } + } else if matches!( + method.as_str(), + "peri/mcp/open" | "peri/mcp/app" | "peri/mcp/resource" + ) { + let transport = Arc::clone(&transport); + let relay = cfg.mcp_apps_relay.clone(); + let connection = Arc::clone(&connection); + let app_spawner = cfg.host_task_spawner.clone(); + let connection_cancellation = connection_cancellation.clone(); + let rejected_transport = Arc::clone(&transport); + let rejected_id = id.clone(); + let spawn_result = app_spawner.spawn( + task_scope::HostTaskOwnerKind::Connection, + task_scope::HostTaskKind::McpAppsRelay, + async move { + let result = tokio::select! { + _ = connection_cancellation.cancelled() => { + Err(crate::transport::types::AcpError::new(-32800, "request cancelled")) + } + result = async { + match method.as_str() { + "peri/mcp/open" => { + let mut connection = connection.lock().await; + mcp_apps::handle_request( + &method, + ¶ms, + &mut connection, + relay.as_ref(), + ) + .await + } + _ => { + let mut request_connection = { + let connection = connection.lock().await; + connection.snapshot_for_request() + }; + mcp_apps::handle_request( + &method, + ¶ms, + &mut request_connection, + relay.as_ref(), + ) + .await + } + } + } => result, + }; + if let Err(error) = transport.send_response(id, result).await { + tracing::warn!(%error, "MCP Apps terminal response send failed"); + } + }, + ); + if spawn_result.is_err() { + let _ = rejected_transport + .send_response( + rejected_id, + Err(crate::transport::types::AcpError::new( + -32800, + "request cancelled", + )), + ) + .await; + } } else { - let mut sessions = sessions.lock().await; - let result = - handle_request(&method, ¶ms, &cfg, &mut sessions, &transport).await; + let closed_session_id = + matches!(method.as_str(), "session/close" | "session/delete") + .then(|| extract_session_id(¶ms, "").to_string()) + .filter(|session_id| !session_id.is_empty()); + let result = { + let mut sessions = sessions.lock().await; + handle_request(&method, ¶ms, &cfg, &mut sessions, &transport).await + }; + if method == "initialize" && result.is_ok() { + connection.lock().await.commit_initialize(); + } + if result.is_ok() { + if let (Some(session_id), Some(relay)) = + (closed_session_id.as_deref(), cfg.mcp_apps_relay.as_ref()) + { + relay.close_session(session_id); + } + } let new_session_id = (method == "session/new") .then(|| { result @@ -563,6 +673,14 @@ async fn run_acp_server_inner( } } IncomingMessage::Notification { method, params } => { + if method == "session/cancel" { + let session_id = extract_session_id(¶ms, ""); + if !session_id.is_empty() { + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.close_session(session_id); + } + } + } // session/cancel 可能需要在锁外补发 continuation 请求 // (race 兜底:bg 结果已 route 为 Defer,但通知可能在 cancel // 置位前被 scheduler 跳过)。unbounded send 虽不阻塞,仍统一 @@ -582,6 +700,12 @@ async fn run_acp_server_inner( } // Transport EOF is the host's single ownership transaction. + connection_cancellation.cancel(); + let connection_id = connection.lock().await.id().to_string(); + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.close_connection(&connection_id); + } + connection.lock().await.begin_close(); task_owner.begin_shutdown(); if let Some(dynamic_mcp) = cfg.dynamic_mcp.as_ref() { dynamic_mcp.begin_shutdown(); diff --git a/peri-acp/src/host/requests_test.rs b/peri-acp/src/host/requests_test.rs index 8b4d0e5e..c68f5e1e 100644 --- a/peri-acp/src/host/requests_test.rs +++ b/peri-acp/src/host/requests_test.rs @@ -130,6 +130,7 @@ fn make_server_config( permission_mode: SharedPermissionMode::new(PermissionMode::Bypass), cron_scheduler: None, mcp_pool: None, + mcp_apps_relay: None, dynamic_mcp: None, oauth_event_tx: None, oauth_event_rx: None, diff --git a/peri-acp/src/host/stdio/mod.rs b/peri-acp/src/host/stdio/mod.rs index a61023cc..bfd23b2b 100644 --- a/peri-acp/src/host/stdio/mod.rs +++ b/peri-acp/src/host/stdio/mod.rs @@ -141,8 +141,9 @@ async fn assemble_stdio_config(input: StdioInput) -> anyhow::Result anyhow::Result, input: Dupl // ── 测试 ────────────────────────────────────────────────────────────────── +/// host task scope 已关闭时,prompt request 仍必须收到一次 terminal error response。 +#[tokio::test] +async fn test_rejected_prompt_task_returns_terminal_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let cfg = test_config(&tmp); + cfg.host_task_owner + .as_ref() + .expect("test config should own host task scope") + .begin_shutdown(); + let (transport, mut input_write, mut output_read) = duplex_transport(); + let server_task = tokio::spawn(host::run_acp_server(Arc::new(transport), cfg)); + + write_line( + &mut input_write, + &json!({ + "jsonrpc": "2.0", + "id": "prompt-rejected", + "method": "session/prompt", + "params": { "sessionId": "missing", "prompt": [] } + }) + .to_string(), + ) + .await; + + let response: Value = serde_json::from_str(&read_line(&mut output_read).await).unwrap(); + assert_eq!(response["id"], "prompt-rejected"); + assert_eq!(response["error"]["code"], -32800); + assert_eq!(response["error"]["message"], "request cancelled"); + assert!(response.get("result").is_none()); + + drop(input_write); + server_task.await.expect("server task 不应 panic"); +} + /// initialize → session/new → AvailableCommandsUpdate 通知:stdout 侧完整断言。 /// 证明 StdioTransport 可承载 run_acp_server 的生命周期链路(wire 兼容 live 证明)。 #[tokio::test] diff --git a/peri-acp/src/host/task_scope.rs b/peri-acp/src/host/task_scope.rs index b49ca7e0..2ed48747 100644 --- a/peri-acp/src/host/task_scope.rs +++ b/peri-acp/src/host/task_scope.rs @@ -16,6 +16,7 @@ pub(crate) enum HostTaskOwnerKind { Startup, Host, Session, + Connection, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -28,6 +29,7 @@ pub(crate) enum HostTaskKind { Prompt, Prediction, LegacyCancelHook, + McpAppsRelay, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/peri-acp/src/transport/stdio.rs b/peri-acp/src/transport/stdio.rs index df936210..cece50e3 100644 --- a/peri-acp/src/transport/stdio.rs +++ b/peri-acp/src/transport/stdio.rs @@ -109,6 +109,9 @@ impl StdioTransport { let (incoming_tx, incoming_rx) = mpsc::unbounded_channel(); let router = RequestRouter::new(); let pump_router = router.clone(); + let writer: Arc>>> = + Arc::new(Mutex::new(BufWriter::new(Box::new(writer)))); + let pump_writer = Arc::clone(&writer); let cancel_hook = Arc::new(std::sync::Mutex::new(cancel_hook)); let pump_cancel_hook = Arc::clone(&cancel_hook); @@ -152,10 +155,46 @@ impl StdioTransport { continue; } - let mut envelope: JsonRpcEnvelope = match serde_json::from_str(&line) { - Ok(e) => e, - Err(e) => { - tracing::error!(error = %e, "Failed to parse JSON-RPC from stdin"); + let raw: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, "Stdio transport: malformed JSON-RPC input"); + if send_protocol_error( + &pump_writer, + &pump_router, + Value::Null, + -32700, + "Parse error", + ) + .await + .is_err() + { + break; + } + continue; + } + }; + let invalid_id = raw + .get("id") + .filter(|id| is_domain_id(id)) + .cloned() + .unwrap_or(Value::Null); + let mut envelope: JsonRpcEnvelope = match serde_json::from_value(raw) { + Ok(envelope) => envelope, + Err(error) => { + tracing::warn!(%error, "Stdio transport: invalid JSON-RPC envelope"); + if send_protocol_error( + &pump_writer, + &pump_router, + invalid_id, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } continue; } }; @@ -164,6 +203,31 @@ impl StdioTransport { let result_val = envelope.result.take(); let error_val = envelope.error.take(); + if envelope.jsonrpc != "2.0" + || (has_method && (result_val.is_some() || error_val.is_some())) + || (!has_method && result_val.is_some() == error_val.is_some()) + { + let id = envelope + .id + .as_ref() + .filter(|id| is_domain_id(id)) + .cloned() + .unwrap_or(Value::Null); + if send_protocol_error( + &pump_writer, + &pump_router, + id, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } + continue; + } + // JSON-RPC 2.0 §2.2:Request 的 id 成员存在但为 null 时视为 // 通知(客户端无兴趣于对应响应,等同「无 id」)——进入 // `(None, true)` 通知分支,而非压 0 成请求(决策点 7 收口)。 @@ -203,8 +267,20 @@ impl StdioTransport { if !is_domain_id(&id) { tracing::warn!( id = %id, - "Ignoring JSON-RPC request with out-of-domain id" + "Rejecting JSON-RPC request with out-of-domain id" ); + if send_protocol_error( + &pump_writer, + &pump_router, + Value::Null, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } continue; } let method = envelope.method.unwrap(); @@ -246,9 +322,7 @@ impl StdioTransport { Self { incoming_rx: tokio::sync::Mutex::new(incoming_rx), router, - writer: Arc::new(Mutex::new(BufWriter::new( - Box::new(writer) as Box - ))), + writer, cancel_hook, } } @@ -323,6 +397,24 @@ impl AcpTransport for StdioTransport { // ── helpers ────────────────────────────────────────────────────────────────── +async fn send_protocol_error( + writer: &Arc>>>, + router: &RequestRouter, + id: Value, + code: i64, + message: &'static str, +) -> Result<(), AcpError> { + let envelope = JsonRpcEnvelope { + jsonrpc: "2.0".to_string(), + id: Some(id), + method: None, + params: None, + result: None, + error: Some(AcpError::new(code, message)), + }; + write_envelope(writer, router, &envelope).await +} + async fn write_envelope( writer: &Arc>>>, router: &RequestRouter, diff --git a/peri-acp/src/transport/stdio_test.rs b/peri-acp/src/transport/stdio_test.rs index 5d5979ab..7378e630 100644 --- a/peri-acp/src/transport/stdio_test.rs +++ b/peri-acp/src/transport/stdio_test.rs @@ -158,10 +158,10 @@ async fn test_pump_parses_three_message_kinds_and_eof_closes() { ); } -/// 空行被跳过、非法 JSON 行仅 error 日志后继续——不中断后续报文解析。 +/// 空行被跳过;非法 JSON 返回 parse error 后继续解析后续报文。 #[tokio::test] -async fn test_invalid_json_line_skipped_keeps_parsing() { - let (transport, mut input, _output) = duplex_transport(); +async fn test_invalid_json_returns_error_and_keeps_parsing() { + let (transport, mut input, mut output) = duplex_transport(); write_line(&mut input, "").await; write_line(&mut input, "this is not json {").await; write_line( @@ -171,6 +171,10 @@ async fn test_invalid_json_line_skipped_keeps_parsing() { .await; drop(input); + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["id"], Value::Null); + assert_eq!(error["error"]["code"], -32700); + match recv(&transport).await { Some(IncomingMessage::Request { id, method, .. }) => { assert_eq!(id, RequestId::Number(1)); @@ -643,6 +647,98 @@ async fn test_pump_rejects_out_of_domain_ids_and_null_id_becomes_notification() assert!(recv(&transport).await.is_none(), "EOF 后 pump 退出"); } +/// 非法 JSON 必须产生标准 JSON-RPC parse error,且不能阻断后续合法请求。 +#[tokio::test] +async fn test_malformed_json_returns_parse_error_and_pump_continues() { + let (transport, mut input, mut output) = duplex_transport(); + write_line(&mut input, r#"{"jsonrpc":"2.0","id":1,"#).await; + write_line( + &mut input, + r#"{"jsonrpc":"2.0","id":2,"method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["jsonrpc"], "2.0"); + assert!(error["id"].is_null()); + assert_eq!(error["error"]["code"], -32700); + assert_eq!(error["error"]["message"], "Parse error"); + assert!(error.get("result").is_none()); + + match recv(&transport).await { + Some(IncomingMessage::Request { id, method, .. }) => { + assert_eq!(id, RequestId::Number(2)); + assert_eq!(method, "m/req"); + } + other => panic!("parse error 后合法请求应继续进入 host,实际 {other:?}"), + } +} + +/// 可解析但不符合 JSON-RPC request envelope 的输入必须返回 Invalid Request。 +#[tokio::test] +async fn test_invalid_request_envelope_returns_invalid_request_error() { + let (_transport, mut input, mut output) = duplex_transport(); + write_line( + &mut input, + r#"{"jsonrpc":"1.0","id":"bad-1","method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["jsonrpc"], "2.0"); + assert_eq!(error["id"], "bad-1"); + assert_eq!(error["error"]["code"], -32600); + assert_eq!(error["error"]["message"], "Invalid Request"); + assert!(error.get("result").is_none()); +} + +/// 域外 request id 无法安全关联,必须以 id=null 返回 Invalid Request。 +#[tokio::test] +async fn test_out_of_domain_request_id_returns_invalid_request_error() { + let (_transport, mut input, mut output) = duplex_transport(); + write_line( + &mut input, + r#"{"jsonrpc":"2.0","id":true,"method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert!(error["id"].is_null()); + assert_eq!(error["error"]["code"], -32600); + assert_eq!(error["error"]["message"], "Invalid Request"); +} + +/// Response 必须恰好包含 result/error 之一;两者并存不得结算 pending request。 +#[tokio::test] +async fn test_response_with_result_and_error_is_rejected() { + let (transport, mut input, mut output) = duplex_transport(); + let transport = Arc::new(transport); + let request = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { transport.send_request("m/echo", json!({})).await }) + }; + let sent: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + + write_line( + &mut input, + &json!({ + "jsonrpc": "2.0", + "id": sent["id"], + "result": "ignored", + "error": { "code": -32000, "message": "boom" } + }) + .to_string(), + ) + .await; + drop(input); + + let result = tokio::time::timeout(Duration::from_secs(5), request) + .await + .expect("EOF 后 pending request 应及时结束") + .expect("request task 不应 panic"); + assert!(result.is_err(), "非法 response 不得结算为业务响应"); +} + // ── legacy type:cancel(批 3 §7 #10 移植)────────────────────────────────── /// pump 对 `{"type":"cancel"}` 行(非 JSON-RPC)拦截:注入的 hook 收到原始行, diff --git a/peri-agent/src/agent/stages/reason.rs b/peri-agent/src/agent/stages/reason.rs index 82cada5d..434f8c63 100644 --- a/peri-agent/src/agent/stages/reason.rs +++ b/peri-agent/src/agent/stages/reason.rs @@ -155,7 +155,7 @@ pub async fn run_reason(input: ReasonInput) -> AgentResult { .collect(); let tool_refs: Vec<&dyn crate::tools::BaseTool> = tools_owned .iter() - .filter(|t| t.is_direct()) + .filter(|t| t.is_direct() && t.visible_to_model()) .map(|t| t.as_ref()) .collect(); // 工具数量与名称追踪(调试用;默认 filter 下不写盘) diff --git a/peri-agent/src/agent/stages/tool_dispatch.rs b/peri-agent/src/agent/stages/tool_dispatch.rs index 5bdbd017..b4c62b97 100644 --- a/peri-agent/src/agent/stages/tool_dispatch.rs +++ b/peri-agent/src/agent/stages/tool_dispatch.rs @@ -614,11 +614,21 @@ async fn dispatch_concurrent( let ctx_param = crate::tools::ToolContext::new(&messages, &cwd) .with_effective_tool_dispatcher( Arc::new(StageEffectiveToolDispatcher::new( - dispatch_context, + dispatch_context.clone(), dispatch_catalog, )), raw_call.id.clone(), cancel.clone(), + ) + .with_session_identity( + dispatch_context + .session + .session_context + .read() + .get("session_id") + .cloned() + .unwrap_or_else(|| dispatch_context.session.agent_id.to_string()), + dispatch_context.session.turn.turn_id.to_string(), ); match tool { Some(t) => t.invoke(input, ctx_param).await.map_err(|e| { diff --git a/peri-agent/src/session/tool_catalog.rs b/peri-agent/src/session/tool_catalog.rs index 19d4f331..58d0dd17 100644 --- a/peri-agent/src/session/tool_catalog.rs +++ b/peri-agent/src/session/tool_catalog.rs @@ -271,7 +271,7 @@ fn finalize( ) -> Result { let direct_definitions = tools .values() - .filter(|entry| entry.tool.is_direct()) + .filter(|entry| entry.tool.is_direct() && entry.tool.visible_to_model()) .map(|entry| entry.tool.definition()) .collect(); let mut aliases = BTreeMap::new(); diff --git a/peri-middlewares/src/assembly.rs b/peri-middlewares/src/assembly.rs index daefef15..49503022 100644 --- a/peri-middlewares/src/assembly.rs +++ b/peri-middlewares/src/assembly.rs @@ -542,6 +542,7 @@ impl MiddlewareChainAssembler for ProductionChainAssembler { Arc::clone(pool) }; let mw = McpMiddleware::new(Arc::clone(&effective_pool)) + .with_tool_pool(Arc::clone(pool)) .with_skill_discovery( ctx.mcp_skill_registry.clone(), ctx.cancel.clone(), diff --git a/peri-middlewares/src/attribution/mod.rs b/peri-middlewares/src/attribution/mod.rs index 2bac0c21..c0284c74 100644 --- a/peri-middlewares/src/attribution/mod.rs +++ b/peri-middlewares/src/attribution/mod.rs @@ -16,7 +16,9 @@ mod state; use std::{ collections::HashMap, + process::Stdio, sync::{Arc, Mutex}, + time::Duration, }; use async_trait::async_trait; @@ -30,6 +32,8 @@ pub use state::AttributionState; use crate::tool_search::core_tools::{TOOL_EDIT, TOOL_WRITE}; +const GIT_BRANCH_TIMEOUT: Duration = Duration::from_secs(1); + /// Git 留名中间件 /// /// 注册在 `FilesystemMiddleware` 之后,hook 其 Write/Edit 工具调用。 @@ -77,12 +81,23 @@ impl GitAttributionMiddleware { } } - async fn current_branch(cwd: &str) -> Option { - let output = tokio::process::Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(cwd) - .output() + fn spawn_branch_command(mut command: tokio::process::Command) -> Option { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .ok() + } + + async fn current_branch_from_child( + child: tokio::process::Child, + timeout: Duration, + ) -> Option { + let output = tokio::time::timeout(timeout, child.wait_with_output()) .await + .ok()? .ok()?; if !output.status.success() { return None; @@ -91,6 +106,22 @@ impl GitAttributionMiddleware { let branch = branch.trim(); (!branch.is_empty()).then(|| branch.to_string()) } + + async fn current_branch_with_command( + command: tokio::process::Command, + timeout: Duration, + ) -> Option { + let child = Self::spawn_branch_command(command)?; + Self::current_branch_from_child(child, timeout).await + } + + async fn current_branch(cwd: &str) -> Option { + let mut command = tokio::process::Command::new("git"); + command + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(cwd); + Self::current_branch_with_command(command, GIT_BRANCH_TIMEOUT).await + } } #[async_trait] diff --git a/peri-middlewares/src/attribution/mod_test.rs b/peri-middlewares/src/attribution/mod_test.rs index 1ad4daa9..699a2ba2 100644 --- a/peri-middlewares/src/attribution/mod_test.rs +++ b/peri-middlewares/src/attribution/mod_test.rs @@ -1,7 +1,58 @@ //! Tests for mod_attrib +use std::{ + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + use super::*; +const FIXTURE_READY_ENV: &str = "PERI_ATTRIBUTION_FIXTURE_READY"; +const FIXTURE_RELEASE_ENV: &str = "PERI_ATTRIBUTION_FIXTURE_RELEASE"; +const FIXTURE_SENTINEL_ENV: &str = "PERI_ATTRIBUTION_FIXTURE_SENTINEL"; + +#[cfg(unix)] +fn successful_branch_command() -> tokio::process::Command { + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "printf ' feature/windows\\n'"]); + command +} + +#[cfg(windows)] +fn successful_branch_command() -> tokio::process::Command { + let mut command = tokio::process::Command::new("cmd.exe"); + command.args(["/D", "/C", "echo feature/windows "]); + command +} + +async fn wait_for_fixture_ready(child: &mut tokio::process::Child, ready: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if ready.exists() { + return; + } + if let Some(status) = child.try_wait().expect("应能查询 fixture 子进程状态") { + panic!("fixture 在 READY 前意外退出:{status}"); + } + assert!(Instant::now() < deadline, "fixture 未在期限内写入 READY"); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn assert_sentinel_remains_absent(sentinel: &Path) { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + assert!( + !sentinel.exists(), + "超时后的 fixture 仍存活并写入了 SENTINEL" + ); + if Instant::now() >= deadline { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + #[test] fn test_git_attribution_reset_clears_pending() { let mw = GitAttributionMiddleware::new("test-model"); @@ -33,3 +84,87 @@ fn test_branch_drift_reports_each_change_once() { ); assert_eq!(mw.observe_branch("feature".to_string()), None); } + +#[tokio::test] +async fn test_current_branch_with_command_trims_successful_output() { + let result = tokio::time::timeout( + Duration::from_secs(5), + GitAttributionMiddleware::current_branch_with_command( + successful_branch_command(), + Duration::from_secs(1), + ), + ) + .await + .expect("分支命令应在外层测试期限内完成"); + assert_eq!(result, Some("feature/windows".to_string())); +} + +#[test] +#[ignore] +fn current_branch_hanging_process_fixture() { + let Some(ready) = std::env::var_os(FIXTURE_READY_ENV).map(PathBuf::from) else { + return; + }; + let Some(release) = std::env::var_os(FIXTURE_RELEASE_ENV).map(PathBuf::from) else { + return; + }; + let Some(sentinel) = std::env::var_os(FIXTURE_SENTINEL_ENV).map(PathBuf::from) else { + return; + }; + + std::fs::write(&ready, b"ready").expect("fixture 应能写入 READY"); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if release.exists() { + std::fs::write(&sentinel, b"escaped").expect("存活的 fixture 应能写入 SENTINEL"); + return; + } + if Instant::now() >= deadline { + std::fs::write(&sentinel, b"guard-expired") + .expect("fixture guard 到期时应写入 SENTINEL"); + return; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// [回归测试] Git 分支探测超时后必须终止直接子进程,不能让它脱离等待继续运行。 +/// +/// 历史背景:Windows ACP 的 `git rev-parse` 曾永久不退出,attribution 在同步 +/// `before_agent` 路径无限等待;仅给 future 加 timeout 会留下仍在运行的子进程。 +#[tokio::test] +async fn test_current_branch_with_command_timeout_kills_process() { + tokio::time::timeout(Duration::from_secs(15), async { + let temp_dir = tempfile::tempdir().expect("应能创建 fixture 临时目录"); + let ready = temp_dir.path().join("ready"); + let release = temp_dir.path().join("release"); + let sentinel = temp_dir.path().join("sentinel"); + let current_exe = std::env::current_exe().expect("应能定位当前测试可执行文件"); + let mut command = tokio::process::Command::new(current_exe); + command + .args([ + "--exact", + "--ignored", + "attribution::tests::current_branch_hanging_process_fixture", + "--nocapture", + "--test-threads=1", + ]) + .env(FIXTURE_READY_ENV, &ready) + .env(FIXTURE_RELEASE_ENV, &release) + .env(FIXTURE_SENTINEL_ENV, &sentinel); + let mut child = GitAttributionMiddleware::spawn_branch_command(command) + .expect("应能启动 fixture 子进程"); + wait_for_fixture_ready(&mut child, &ready).await; + + let result = + GitAttributionMiddleware::current_branch_from_child(child, Duration::from_millis(100)) + .await; + assert_eq!(result, None); + tokio::time::sleep(Duration::from_millis(100)).await; + std::fs::write(&release, b"release").expect("应能写入 RELEASE"); + assert_sentinel_remains_absent(&sentinel).await; + assert!(!sentinel.exists(), "fixture 不应在测试结束前写入 SENTINEL"); + }) + .await + .expect("回归测试生命周期应在外层期限内完成"); +} diff --git a/peri-middlewares/src/mcp/apps.rs b/peri-middlewares/src/mcp/apps.rs new file mode 100644 index 00000000..cbe1e85a --- /dev/null +++ b/peri-middlewares/src/mcp/apps.rs @@ -0,0 +1,419 @@ +use std::{ + collections::{BTreeSet, HashMap}, + sync::Arc, + time::{Duration, Instant}, +}; + +use parking_lot::Mutex; +use peri_acp_types::tools::EffectiveToolDispatcher; + +use async_trait::async_trait; +use serde_json::{Map, Value}; +use thiserror::Error; + +pub const MCP_APPS_VERSION: &str = "2026-01-26"; +pub const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app"; +pub const MCP_UI_EXTENSION: &str = "io.modelcontextprotocol/ui"; +const BINDING_LEASE_TTL: Duration = Duration::from_secs(300); +const RAW_RESULT_TTL: Duration = Duration::from_secs(120); +const MAX_PENDING_LEASES: usize = 1024; +const MAX_RAW_RESULTS: usize = 1024; + +#[derive(Clone)] +pub struct McpAppBindingLease { + pub owner_session_id: String, + pub owner_connection_id: Option, + pub turn_generation: String, + pub server_id: String, + pub server_generation: u64, + pub resource_uri: String, + pub instantiating_tool: String, + pub invocation_token: String, + pub allowed_tools: HashMap, + pub dispatcher: Arc, + pub cancellation: tokio_util::sync::CancellationToken, + expires_at: Instant, +} + +impl McpAppBindingLease { + // Identity, routing, dispatcher and revocation fields are all mandatory security inputs. + #[allow(clippy::too_many_arguments)] + pub fn new( + owner_session_id: String, + turn_generation: String, + server_id: String, + server_generation: u64, + resource_uri: String, + instantiating_tool: String, + invocation_token: String, + allowed_tools: HashMap, + dispatcher: Arc, + cancellation: tokio_util::sync::CancellationToken, + ) -> Self { + Self { + owner_session_id, + owner_connection_id: None, + turn_generation, + server_id, + server_generation, + resource_uri, + instantiating_tool, + invocation_token, + allowed_tools, + dispatcher, + cancellation, + expires_at: Instant::now() + BINDING_LEASE_TTL, + } + } + + pub fn is_valid(&self) -> bool { + Instant::now() < self.expires_at && !self.cancellation.is_cancelled() + } +} + +#[derive(Default)] +pub struct McpAppBindingLeaseRegistry { + leases: Mutex>>, + current_turns: Mutex>, + raw_results: Mutex>, +} + +impl McpAppBindingLeaseRegistry { + fn cleanup(&self) { + let mut leases = self.leases.lock(); + leases.retain(|_, values| { + values.retain(McpAppBindingLease::is_valid); + !values.is_empty() + }); + while leases.values().map(Vec::len).sum::() > MAX_PENDING_LEASES { + let Some(key) = leases.keys().next().cloned() else { + break; + }; + if let Some(values) = leases.get_mut(&key) { + values.remove(0); + if values.is_empty() { + leases.remove(&key); + } + } + } + drop(leases); + + let now = Instant::now(); + let mut raw_results = self.raw_results.lock(); + raw_results.retain(|_, (expires_at, _)| now < *expires_at); + while raw_results.len() > MAX_RAW_RESULTS { + let Some(key) = raw_results.keys().next().cloned() else { + break; + }; + raw_results.remove(&key); + } + } + + pub fn begin_session_turn(&self, owner_session_id: &str) { + self.revoke_session(owner_session_id); + } + + pub fn revoke_session(&self, owner_session_id: &str) { + self.current_turns.lock().remove(owner_session_id); + let mut leases = self.leases.lock(); + leases.retain(|_, values| { + values.retain(|lease| { + if lease.owner_session_id == owner_session_id { + lease.cancellation.cancel(); + false + } else { + lease.is_valid() + } + }); + !values.is_empty() + }); + } + + pub fn issue(&self, mut lease: McpAppBindingLease) { + self.cleanup(); + lease.expires_at = Instant::now() + BINDING_LEASE_TTL; + self.current_turns.lock().insert( + lease.owner_session_id.clone(), + lease.turn_generation.clone(), + ); + let key = (lease.server_id.clone(), lease.instantiating_tool.clone()); + self.leases.lock().entry(key).or_default().push(lease); + self.cleanup(); + } + + pub fn is_current_turn(&self, lease: &McpAppBindingLease) -> bool { + lease.is_valid() + && self + .current_turns + .lock() + .get(&lease.owner_session_id) + .is_some_and(|turn| turn == &lease.turn_generation) + } + + pub fn record_raw_result(&self, invocation_id: &str, result: RawCallToolResult) { + self.cleanup(); + self.raw_results.lock().insert( + invocation_id.to_string(), + (Instant::now() + RAW_RESULT_TTL, result), + ); + self.cleanup(); + } + + pub fn take_raw_result(&self, invocation_id: &str) -> Option { + self.cleanup(); + self.raw_results + .lock() + .remove(invocation_id) + .map(|(_, result)| result) + } + + pub fn purge_raw_results_for_connection(&self, owner_connection_id: &str) { + let prefix = format!("mcp-app:{owner_connection_id}:"); + self.raw_results + .lock() + .retain(|invocation_id, _| !invocation_id.starts_with(&prefix)); + } + + #[allow(clippy::too_many_arguments)] + pub fn consume( + &self, + server_id: &str, + tool_name: &str, + generation: u64, + resource_uri: &str, + owner_session_id: &str, + invocation_token: &str, + owner_connection_id: &str, + ) -> Option { + self.cleanup(); + let key = (server_id.to_string(), tool_name.to_string()); + let current_turns = self.current_turns.lock().clone(); + let mut leases = self.leases.lock(); + let values = leases.get_mut(&key)?; + let index = values.iter().position(|lease| { + lease.is_valid() + && lease.owner_session_id == owner_session_id + && lease.invocation_token == invocation_token + && current_turns + .get(&lease.owner_session_id) + .is_some_and(|turn| turn == &lease.turn_generation) + && lease.server_generation == generation + && lease.resource_uri == resource_uri + })?; + let mut lease = values.remove(index); + lease.owner_connection_id = Some(owner_connection_id.to_string()); + if values.is_empty() { + leases.remove(&key); + } + Some(lease) + } +} + +pub const MCP_APPS_ENV: &str = "PERI_MCP_APPS"; + +pub fn deployment_profile(apps_enabled: bool) -> McpCapabilityProfile { + if apps_enabled { + McpCapabilityProfile::negotiated([MCP_APP_MIME_TYPE]) + } else { + McpCapabilityProfile::disabled() + } +} + +/// 由 ACP connection 注入的不可变 MCP capability profile。 +/// +/// 默认值关闭 Apps;只有显式协商出的受支持 MIME 才会进入 profile。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct McpCapabilityProfile { + apps_mime_types: BTreeSet, +} + +impl McpCapabilityProfile { + pub fn disabled() -> Self { + Self::default() + } + + pub fn negotiated<'a>(mime_types: impl IntoIterator) -> Self { + let apps_mime_types = mime_types + .into_iter() + .filter(|mime| *mime == MCP_APP_MIME_TYPE) + .map(str::to_owned) + .collect(); + Self { apps_mime_types } + } + + pub fn apps_enabled(&self) -> bool { + !self.apps_mime_types.is_empty() + } + + pub fn apps_mime_types(&self) -> impl Iterator { + self.apps_mime_types.iter().map(String::as_str) + } + + pub(crate) fn ui_extension(&self) -> Option> { + self.apps_enabled().then(|| { + Map::from_iter([( + "mimeTypes".to_string(), + Value::Array( + self.apps_mime_types() + .map(|mime| Value::String(mime.to_string())) + .collect(), + ), + )]) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ToolVisibility { + pub model: bool, + pub app: bool, +} + +impl ToolVisibility { + /// 缺失时兼容 model + app;任何 malformed、空数组或未知值均关闭两面。 + pub fn from_tool_meta(meta: Option<&Value>) -> Self { + let Some(meta) = meta else { + return Self { + model: true, + app: true, + }; + }; + let Some(meta) = meta.as_object() else { + return Self { + model: false, + app: false, + }; + }; + let Some(ui) = meta.get("ui") else { + return Self { + model: true, + app: true, + }; + }; + let Some(ui) = ui.as_object() else { + return Self { + model: false, + app: false, + }; + }; + let Some(visibility) = ui.get("visibility") else { + return Self { + model: true, + app: true, + }; + }; + let Some(values) = visibility.as_array() else { + return Self { + model: false, + app: false, + }; + }; + if values.is_empty() + || values + .iter() + .any(|value| !matches!(value.as_str(), Some("model") | Some("app"))) + { + return Self { + model: false, + app: false, + }; + } + Self { + model: values.iter().any(|value| value == "model"), + app: values.iter().any(|value| value == "app"), + } + } +} + +/// 读取并 canonicalize `_meta.ui.resourceUri`;legacy key 仅作兼容输入。 +/// canonical 与 legacy 冲突或值类型错误时 fail closed。 +pub fn canonical_resource_uri(meta: Option<&Value>) -> Option { + let meta = meta?.as_object()?; + let canonical = meta + .get("ui") + .and_then(Value::as_object) + .and_then(|ui| ui.get("resourceUri")); + let legacy = meta.get("ui/resourceUri"); + match (canonical, legacy) { + (Some(left), Some(right)) if left != right => None, + (Some(value), _) | (_, Some(value)) => value + .as_str() + .filter(|uri| uri.starts_with("ui://")) + .map(str::to_owned), + (None, None) => None, + } +} + +pub fn tool_visibility(tool: &rmcp::model::Tool) -> ToolVisibility { + let value = serde_json::to_value(tool).unwrap_or(Value::Null); + ToolVisibility::from_tool_meta(value.get("_meta")) +} + +pub fn tool_resource_uri(tool: &rmcp::model::Tool) -> Option { + let value = serde_json::to_value(tool).ok()?; + canonical_resource_uri(value.get("_meta")) +} + +pub fn raw_tool(tool: &rmcp::model::Tool) -> RawMcpTool { + serde_json::to_value(tool).unwrap_or(Value::Null) +} + +pub fn raw_resource(resource: &rmcp::model::Resource) -> RawMcpResource { + serde_json::to_value(resource).unwrap_or(Value::Null) +} + +/// Raw MCP payload stays as JSON until the legacy model projection boundary. +pub type RawMcpTool = Value; +pub type RawMcpResource = Value; +pub type RawCallToolResult = Value; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum McpAppsInvocationError { + #[error("MCP Apps canonical invocation seam is unavailable")] + Unavailable, + #[error("MCP App tool is not visible to this session")] + Forbidden, + #[error("MCP App tool invocation failed")] + InvocationFailed, +} + +/// 只能由 session-local canonical dispatcher/HITL 实现;不得以 MCP peer 实现此 seam。 +#[async_trait] +pub trait McpAppsInvocationSeam: Send + Sync { + async fn call_tool( + &self, + effective_tool_name: &str, + arguments: Value, + ) -> Result; +} + +#[derive(Clone, Default)] +pub struct McpAppsInvoker { + seam: Option>, +} + +impl McpAppsInvoker { + pub fn unavailable() -> Self { + Self::default() + } + + pub fn with_seam(seam: Arc) -> Self { + Self { seam: Some(seam) } + } + + pub async fn call_tool( + &self, + effective_tool_name: &str, + arguments: Value, + ) -> Result { + let seam = self + .seam + .as_ref() + .ok_or(McpAppsInvocationError::Unavailable)?; + seam.call_tool(effective_tool_name, arguments).await + } +} + +#[cfg(test)] +#[path = "apps_test.rs"] +mod tests; diff --git a/peri-middlewares/src/mcp/apps_relay.rs b/peri-middlewares/src/mcp/apps_relay.rs new file mode 100644 index 00000000..daf92872 --- /dev/null +++ b/peri-middlewares/src/mcp/apps_relay.rs @@ -0,0 +1,284 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_trait::async_trait; +use parking_lot::Mutex; +use peri_acp_types::{ + mcp_apps::{ + AppSessionBinding, JsonRpcRequest, JsonRpcResponse, JsonRpcResultResponse, + McpAppOpenRequest, McpAppsErrorKind, McpAppsRelayError, McpAppsRelayPort, RawResource, + }, + tools::{EffectiveToolCall, EffectiveToolErrorCode}, +}; + +use super::{ + apps::{tool_resource_uri, tool_visibility}, + client::McpClientPool, +}; + +const MAX_ACTIVE_LEASES: usize = 1024; + +/// Production MCP Apps relay backed by the deployment-scoped pool. +/// +/// Resource/catalog access and App `tools/call` are backed by the same deployment pool. +/// Tool calls require a consumed, connection-bound canonical dispatcher lease. +pub struct PoolMcpAppsRelay { + pool: Arc, + active_leases: Mutex>, +} + +impl PoolMcpAppsRelay { + pub fn new(pool: Arc) -> Self { + Self { + pool, + active_leases: Mutex::new(HashMap::new()), + } + } + + fn cleanup_active_leases(&self) { + let mut leases = self.active_leases.lock(); + leases.retain(|_, lease| lease.is_valid()); + while leases.len() > MAX_ACTIVE_LEASES { + let Some(key) = leases.keys().next().cloned() else { + break; + }; + if let Some(lease) = leases.remove(&key) { + lease.cancellation.cancel(); + } + } + } + + fn handle( + &self, + server_id: &str, + ) -> Result, McpAppsRelayError> { + self.pool + .get_client(server_id) + .filter(|handle| handle.peer.is_some()) + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected)) + } +} + +#[async_trait] +impl McpAppsRelayPort for PoolMcpAppsRelay { + fn close_connection(&self, owner_connection_id: &str) { + self.pool + .app_binding_leases + .purge_raw_results_for_connection(owner_connection_id); + let mut active = self.active_leases.lock(); + active.retain(|_, lease| { + let keep = lease.owner_connection_id.as_deref() != Some(owner_connection_id); + if !keep { + lease.cancellation.cancel(); + } + keep + }); + } + + fn close_session(&self, owner_session_id: &str) { + self.pool + .app_binding_leases + .revoke_session(owner_session_id); + let mut active = self.active_leases.lock(); + active.retain(|_, lease| { + let keep = lease.owner_session_id != owner_session_id; + if !keep { + lease.cancellation.cancel(); + } + keep + }); + } + + fn begin_session_turn(&self, owner_session_id: &str) { + self.close_session(owner_session_id); + } + + async fn open_app( + &self, + owner_connection_id: &str, + request: &McpAppOpenRequest, + ) -> Result<(String, AppSessionBinding), McpAppsRelayError> { + let handle = self.handle(&request.server_id)?; + let tool = handle + .tools + .iter() + .find(|tool| tool.name.as_ref() == request.tool_name) + .ok_or_else(|| relay_error(McpAppsErrorKind::ToolNotFound))?; + if !tool_visibility(tool).app { + return Err(relay_error(McpAppsErrorKind::ToolNotAppVisible)); + } + let resource_uri = tool_resource_uri(tool) + .ok_or_else(|| relay_error(McpAppsErrorKind::InvalidResource))?; + let generation = self.pool.handle_generation(&handle); + let lease = self + .pool + .app_binding_leases + .consume( + &request.server_id, + &request.tool_name, + generation, + &resource_uri, + &request.owner_session_id, + &request.invocation_token, + owner_connection_id, + ) + .ok_or_else(|| relay_error(McpAppsErrorKind::PolicyDenied))?; + let app_session_id = uuid::Uuid::now_v7().to_string(); + let binding = AppSessionBinding { + app_session_id: app_session_id.clone(), + owner_connection_id: owner_connection_id.to_string(), + owner_session_id: lease.owner_session_id.clone(), + server_id: request.server_id.clone(), + server_generation: generation, + resource_uri, + instantiating_tool: request.tool_name.clone(), + apps_protocol_version: request.apps_protocol_version.clone(), + }; + self.cleanup_active_leases(); + self.active_leases.lock().insert(app_session_id, lease); + self.cleanup_active_leases(); + let protocol = handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .map(|info| info.protocol_version.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Ok((protocol, binding)) + } + + async fn validate_binding( + &self, + binding: &AppSessionBinding, + ) -> Result { + self.cleanup_active_leases(); + let handle = self.handle(&binding.server_id)?; + if self.pool.handle_generation(&handle) != binding.server_generation { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .map(|info| info.protocol_version.to_string()) + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected)) + } + + async fn read_resource( + &self, + binding: &AppSessionBinding, + ) -> Result<(String, Vec), McpAppsRelayError> { + let handle = self.handle(&binding.server_id)?; + if self.pool.handle_generation(&handle) != binding.server_generation { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + let peer = handle + .peer + .as_ref() + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected))?; + let result = peer + .read_resource(rmcp::model::ReadResourceRequestParams::new( + binding.resource_uri.clone(), + )) + .await + .map_err(|_| relay_error(McpAppsErrorKind::ResourceNotFound))?; + let raw: Vec = serde_json::to_value(result) + .ok() + .and_then(|value| value.get("contents")?.as_array().cloned()) + .and_then(|values| { + values + .into_iter() + .map(serde_json::from_value) + .collect::, _>>() + .ok() + }) + .filter(|values| !values.is_empty()) + .ok_or_else(|| relay_error(McpAppsErrorKind::InvalidResource))?; + let protocol = peer + .peer_info() + .map(|info| info.protocol_version.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Ok((protocol, raw)) + } + + async fn call_tool( + &self, + binding: &AppSessionBinding, + request: JsonRpcRequest, + ) -> Result<(String, JsonRpcResponse), McpAppsRelayError> { + self.cleanup_active_leases(); + let protocol = self.validate_binding(binding).await?; + let lease = self + .active_leases + .lock() + .get(&binding.app_session_id) + .cloned() + .filter(super::apps::McpAppBindingLease::is_valid) + .filter(|lease| self.pool.app_binding_leases.is_current_turn(lease)) + .ok_or_else(|| relay_error(McpAppsErrorKind::PolicyDenied))?; + if lease.owner_session_id != binding.owner_session_id + || lease.server_generation != binding.server_generation + { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + let effective_tool_name = lease + .allowed_tools + .get(&request.params.name) + .cloned() + .ok_or_else(|| relay_error(McpAppsErrorKind::Forbidden))?; + let invocation_id = format!( + "mcp-app:{}:{}", + binding.owner_connection_id, + uuid::Uuid::new_v4() + ); + let result = lease + .dispatcher + .dispatch( + EffectiveToolCall { + invocation_id: invocation_id.clone(), + tool_name: effective_tool_name, + input: serde_json::Value::Object(request.params.arguments), + parent_invocation_id: None, + }, + lease.cancellation.child_token(), + ) + .await; + let response = match result { + Ok(_) => { + let raw = self + .pool + .app_binding_leases + .take_raw_result(&invocation_id) + .ok_or_else(|| relay_error(McpAppsErrorKind::UpstreamProtocolError))?; + JsonRpcResponse::Result(JsonRpcResultResponse { + jsonrpc: request.jsonrpc, + id: request.id, + result: raw, + }) + } + Err(error) => { + // MCP `isError: true` is still a protocol-successful raw CallToolResult. + // McpToolBridge records it before projecting the model-facing error text. + if let Some(raw) = self.pool.app_binding_leases.take_raw_result(&invocation_id) { + JsonRpcResponse::Result(JsonRpcResultResponse { + jsonrpc: request.jsonrpc, + id: request.id, + result: raw, + }) + } else { + let kind = match error.code { + EffectiveToolErrorCode::Cancelled => McpAppsErrorKind::Cancelled, + EffectiveToolErrorCode::PermissionDenied + | EffectiveToolErrorCode::UserRejected => McpAppsErrorKind::PolicyDenied, + EffectiveToolErrorCode::UnknownTool => McpAppsErrorKind::ToolNotFound, + _ => McpAppsErrorKind::UpstreamProtocolError, + }; + return Err(relay_error(kind)); + } + } + }; + Ok((protocol, response)) + } +} + +fn relay_error(kind: McpAppsErrorKind) -> McpAppsRelayError { + McpAppsRelayError { kind } +} diff --git a/peri-middlewares/src/mcp/apps_test.rs b/peri-middlewares/src/mcp/apps_test.rs new file mode 100644 index 00000000..5f75d418 --- /dev/null +++ b/peri-middlewares/src/mcp/apps_test.rs @@ -0,0 +1,264 @@ +use super::*; +use async_trait::async_trait; +use peri_acp_types::tools::{ + EffectiveToolCall, EffectiveToolDefinition, EffectiveToolDispatcher, EffectiveToolError, +}; + +struct FakeDispatcher; + +#[async_trait] +impl EffectiveToolDispatcher for FakeDispatcher { + async fn dispatch( + &self, + _call: EffectiveToolCall, + _cancel: tokio_util::sync::CancellationToken, + ) -> Result { + Ok("ok".into()) + } + + fn tools(&self) -> Vec { + Vec::new() + } +} + +#[test] +fn binding_lease_is_single_consume_and_cancel_aware() { + let registry = McpAppBindingLeaseRegistry::default(); + let cancel = tokio_util::sync::CancellationToken::new(); + registry.issue(McpAppBindingLease::new( + "session".into(), + "turn".into(), + "server".into(), + 7, + "ui://app".into(), + "tool".into(), + "token-1".into(), + HashMap::from([("tool".into(), "mcp__server__tool".into())]), + Arc::new(FakeDispatcher), + cancel.clone(), + )); + assert!(registry + .consume( + "server", + "tool", + 8, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://other", + "session", + "token-1", + "connection" + ) + .is_none()); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "wrong-token", + "connection" + ) + .is_none()); + let consumed = registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection", + ) + .expect("matching lease should be consumed once"); + assert!(registry.is_current_turn(&consumed)); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); + + registry.issue(McpAppBindingLease::new( + "session".into(), + "turn-2".into(), + "server".into(), + 7, + "ui://app".into(), + "tool".into(), + "token-1".into(), + HashMap::from([("tool".into(), "mcp__server__tool".into())]), + Arc::new(FakeDispatcher), + cancel.clone(), + )); + assert!(!registry.is_current_turn(&consumed)); + cancel.cancel(); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); +} + +#[test] +fn connection_cleanup_purges_only_owned_raw_results() { + let registry = McpAppBindingLeaseRegistry::default(); + registry.record_raw_result( + "mcp-app:connection-a:one", + serde_json::json!({"content": []}), + ); + registry.record_raw_result( + "mcp-app:connection-b:two", + serde_json::json!({"content": []}), + ); + + registry.purge_raw_results_for_connection("connection-a"); + + assert!(registry + .take_raw_result("mcp-app:connection-a:one") + .is_none()); + assert!(registry + .take_raw_result("mcp-app:connection-b:two") + .is_some()); +} + +#[test] +fn deployment_presence_enables_apps_regardless_of_value() { + assert!(deployment_profile(true).apps_enabled()); + assert!(!deployment_profile(false).apps_enabled()); +} + +#[test] +fn profile_only_negotiates_supported_mime() { + let profile = + McpCapabilityProfile::negotiated(["text/plain", MCP_APP_MIME_TYPE, MCP_APP_MIME_TYPE]); + assert_eq!( + profile.apps_mime_types().collect::>(), + [MCP_APP_MIME_TYPE] + ); +} + +#[test] +fn negotiated_profile_builds_ui_extension() { + let profile = McpCapabilityProfile::negotiated([MCP_APP_MIME_TYPE]); + assert_eq!( + profile.ui_extension(), + Some(Map::from_iter([( + "mimeTypes".to_string(), + serde_json::json!([MCP_APP_MIME_TYPE]), + )])) + ); +} + +#[test] +fn disabled_profile_has_no_ui_extension() { + assert!(McpCapabilityProfile::disabled().ui_extension().is_none()); +} + +#[test] +fn visibility_missing_defaults_to_model_and_app() { + assert_eq!( + ToolVisibility::from_tool_meta(None), + ToolVisibility { + model: true, + app: true + } + ); +} + +#[test] +fn app_only_visibility_excludes_model() { + let meta = serde_json::json!({"ui": {"visibility": ["app"]}}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: true + } + ); +} + +#[test] +fn malformed_ui_metadata_fails_closed() { + let meta = serde_json::json!({"ui": "invalid"}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: false + } + ); +} + +#[test] +fn unknown_visibility_fails_closed() { + let meta = serde_json::json!({"ui": {"visibility": ["app", "future"]}}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: false + } + ); +} + +#[test] +fn conflicting_resource_uri_fails_closed() { + let meta = serde_json::json!({ + "ui": {"resourceUri": "ui://canonical"}, + "ui/resourceUri": "ui://legacy" + }); + assert_eq!(canonical_resource_uri(Some(&meta)), None); +} + +#[test] +fn legacy_resource_uri_is_canonicalized() { + let meta = serde_json::json!({"ui/resourceUri": "ui://app/index.html"}); + assert_eq!( + canonical_resource_uri(Some(&meta)).as_deref(), + Some("ui://app/index.html") + ); +} + +#[tokio::test] +async fn invocation_without_canonical_seam_is_unavailable() { + let error = McpAppsInvoker::unavailable() + .call_tool("mcp__server__tool", serde_json::json!({})) + .await + .unwrap_err(); + assert_eq!(error, McpAppsInvocationError::Unavailable); +} + +#[test] +fn raw_result_round_trip_preserves_standard_and_unknown_fields() { + let raw = serde_json::json!({ + "content": [{"type": "resource", "resource": {"uri": "ui://app", "mimeType": MCP_APP_MIME_TYPE, "text": ""}}], + "structuredContent": {"answer": 42}, + "isError": false, + "_meta": {"ui": {"domain": "example"}, "unknown": {"nested": true}} + }); + let encoded = serde_json::to_string(&raw).unwrap(); + let decoded: RawCallToolResult = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, raw); +} diff --git a/peri-middlewares/src/mcp/channel_handler.rs b/peri-middlewares/src/mcp/channel_handler.rs index 83afd768..479adaa6 100644 --- a/peri-middlewares/src/mcp/channel_handler.rs +++ b/peri-middlewares/src/mcp/channel_handler.rs @@ -16,11 +16,25 @@ use rmcp::{ /// 根据 `method` 字段路由到 channel 消息推送或权限响应处理。 pub struct ChannelHandler { pub state: Arc, + capability_profile: super::apps::McpCapabilityProfile, } impl ChannelHandler { pub fn new(state: Arc) -> Self { - Self { state } + Self { + state, + capability_profile: super::apps::McpCapabilityProfile::disabled(), + } + } + + pub(crate) fn with_capability_profile( + &self, + capability_profile: super::apps::McpCapabilityProfile, + ) -> Self { + Self { + state: Arc::clone(&self.state), + capability_profile, + } } } @@ -94,7 +108,7 @@ impl ChannelHandler { impl ClientHandler for ChannelHandler { fn get_info(&self) -> InitializeRequestParams { - super::client::mcpp_client_info() + super::client::mcpp_client_info_for_profile(&self.capability_profile) } // rmcp trait 要求返回 impl Future,无法改为 async fn diff --git a/peri-middlewares/src/mcp/client.rs b/peri-middlewares/src/mcp/client.rs index 56cc1ca4..e411d042 100644 --- a/peri-middlewares/src/mcp/client.rs +++ b/peri-middlewares/src/mcp/client.rs @@ -145,12 +145,18 @@ pub fn redact_mcp_error(input: &str) -> String { pub(crate) const SERVER_CACHE_VERSION_EXTENSION: &str = "io.mcpp/server-cache-version"; -pub(crate) fn mcpp_client_info() -> InitializeRequestParams { - let mut capabilities = ClientCapabilities::default(); - capabilities.extensions = Some(std::collections::BTreeMap::from([( +pub(crate) fn mcpp_client_info_for_profile( + profile: &super::apps::McpCapabilityProfile, +) -> InitializeRequestParams { + let mut extensions = std::collections::BTreeMap::from([( SERVER_CACHE_VERSION_EXTENSION.to_string(), serde_json::Map::new(), - )])); + )]); + if let Some(extension) = profile.ui_extension() { + extensions.insert(super::apps::MCP_UI_EXTENSION.to_string(), extension); + } + let mut capabilities = ClientCapabilities::default(); + capabilities.extensions = Some(extensions); InitializeRequestParams::new(capabilities, Implementation::from_build_env()) } @@ -352,6 +358,9 @@ pub struct McpClientPool { service_shutdown: tokio::sync::Mutex, pub(crate) task_spawner: super::task_scope::McpTaskSpawner, pub(crate) clients: parking_lot::RwLock>>, + handle_generations: + parking_lot::Mutex, u64)>>>, + next_handle_generation: std::sync::atomic::AtomicU64, pub(crate) services: parking_lot::Mutex>, pub(crate) configs: parking_lot::RwLock>, pub(crate) cache_versions: parking_lot::RwLock>, @@ -385,6 +394,10 @@ pub struct McpClientPool { pub(crate) session_inboxes: parking_lot::RwLock>, /// 跨进程的 MCP Resource Cache;是否写入由响应 scope 与安全上下文共同决定。 pub(crate) resource_cache: super::resource_cache::McpResourceCache, + /// 进程启动时冻结的 deployment capability profile;初始连接和重连复用。 + pub(crate) capability_profile: super::apps::McpCapabilityProfile, + /// 初始模型 MCP tool invocation 签发、`peri/mcp/open` 单次消费的租约。 + pub(crate) app_binding_leases: Arc, } enum ServiceShutdownState { @@ -454,6 +467,32 @@ async fn close_services(services: Vec<(String, McpServiceWrapper)>) -> McpPoolSh } impl McpClientPool { + pub(crate) fn handle_generation(&self, handle: &Arc) -> u64 { + self.handle_generations + .lock() + .get(&handle.name) + .and_then(|entries| { + entries.iter().find_map(|(candidate, generation)| { + candidate + .upgrade() + .filter(|candidate| Arc::ptr_eq(candidate, handle)) + .map(|_| *generation) + }) + }) + .unwrap_or(0) + } + + fn advance_handle_generation(&self, handle: &Arc) -> u64 { + let generation = self + .next_handle_generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut generations = self.handle_generations.lock(); + let entries = generations.entry(handle.name.clone()).or_default(); + entries.retain(|(candidate, _)| candidate.strong_count() > 0); + entries.push((Arc::downgrade(handle), generation)); + generation + } + fn config_allows_persistent_cache(config: &McpServerConfig) -> bool { // `private` 只可在匿名上下文复用。任意静态 header、HTTP query 与 // stdio env 都可能携带 Cookie、API key 或服务自定义凭据,保守禁用。 @@ -820,12 +859,24 @@ impl McpClientPool { } pub fn new_pending_with_spawner(spawner: super::task_scope::McpTaskSpawner) -> Self { + Self::new_pending_with_spawner_and_profile( + spawner, + super::apps::McpCapabilityProfile::disabled(), + ) + } + + pub fn new_pending_with_spawner_and_profile( + spawner: super::task_scope::McpTaskSpawner, + capability_profile: super::apps::McpCapabilityProfile, + ) -> Self { Self { lifecycle: std::sync::atomic::AtomicU8::new(0), lifecycle_registration: parking_lot::Mutex::new(()), service_shutdown: tokio::sync::Mutex::new(ServiceShutdownState::Idle), task_spawner: spawner, clients: parking_lot::RwLock::new(HashMap::new()), + handle_generations: parking_lot::Mutex::new(HashMap::new()), + next_handle_generation: std::sync::atomic::AtomicU64::new(1), services: parking_lot::Mutex::new(HashMap::new()), configs: parking_lot::RwLock::new(HashMap::new()), cache_versions: parking_lot::RwLock::new(HashMap::new()), @@ -839,6 +890,8 @@ impl McpClientPool { active_oauth_flows: parking_lot::Mutex::new(HashMap::new()), session_inboxes: parking_lot::RwLock::new(HashMap::new()), resource_cache: super::resource_cache::McpResourceCache::new(), + capability_profile, + app_binding_leases: Arc::new(super::apps::McpAppBindingLeaseRegistry::default()), } } @@ -1163,23 +1216,22 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); - pool.clients.write().insert( - name.to_string(), - Arc::new(McpClientHandle { - name: name.to_string(), - version: None, - cache_version: None, - peer: None, - tools: vec![], - resources: vec![], - status: ClientStatus::Failed(reason.clone()), - oauth_status: OAuthStatus::default(), - source, - url, - skills_capable: false, - channel_capable: false, - }), - ); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![], + resources: vec![], + status: ClientStatus::Failed(reason.clone()), + oauth_status: OAuthStatus::default(), + source, + url, + skills_capable: false, + channel_capable: false, + }); + pool.advance_handle_generation(&handle); + pool.clients.write().insert(name.to_string(), handle); old_status }; pool.record_status_change(name, old_status.as_ref()); @@ -1210,23 +1262,22 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); - pool.clients.write().insert( - name.to_string(), - Arc::new(McpClientHandle { - name: name.to_string(), - version: None, - cache_version: None, - peer: None, - tools: vec![], - resources: vec![], - status: ClientStatus::Failed(reason), - oauth_status: OAuthStatus::NeedsAuthorization, - source, - url, - skills_capable: false, - channel_capable: false, - }), - ); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![], + resources: vec![], + status: ClientStatus::Failed(reason), + oauth_status: OAuthStatus::NeedsAuthorization, + source, + url, + skills_capable: false, + channel_capable: false, + }); + pool.advance_handle_generation(&handle); + pool.clients.write().insert(name.to_string(), handle); old_status }; pool.record_status_change(name, old_status.as_ref()); @@ -1463,6 +1514,7 @@ impl McpClientPool { if !self.is_open() { return Err(service); } + self.advance_handle_generation(&handle); self.services.lock().insert(name.clone(), service); self.clients.write().insert(name, handle); Ok(()) diff --git a/peri-middlewares/src/mcp/client/transport.rs b/peri-middlewares/src/mcp/client/transport.rs index ce67b59a..3d110885 100644 --- a/peri-middlewares/src/mcp/client/transport.rs +++ b/peri-middlewares/src/mcp/client/transport.rs @@ -53,6 +53,7 @@ pub(crate) async fn serve_client_auto( transport: T, channel_handler: Option<&Arc>, protocol_version: Option<&McpProtocolVersion>, + capability_profile: &crate::mcp::apps::McpCapabilityProfile, timeout: std::time::Duration, ) -> Result, tokio::time::error::Elapsed> where @@ -61,7 +62,10 @@ where { match connection_mode(protocol_version, channel_handler.is_some()) { ConnectionMode::DiscoverChannel => { - let handler = channel_handler.expect("channel mode requires handler"); + let handler = channel_handler + .expect("channel mode requires handler") + .with_capability_profile(capability_profile.clone()); + let handler = Arc::new(handler); tokio::time::timeout( timeout, rmcp::service::serve_client_with_lifecycle( @@ -78,7 +82,7 @@ where ConnectionMode::DiscoverDefault => tokio::time::timeout( timeout, rmcp::service::serve_client_with_lifecycle( - super::mcpp_client_info(), + super::mcpp_client_info_for_profile(capability_profile), transport, ClientLifecycleMode::Discover { preferred_versions: vec![ProtocolVersion::V_2026_07_28], @@ -88,7 +92,10 @@ where .await .map(|inner| inner.map(McpServiceWrapper::Default)), ConnectionMode::LegacyChannel => { - let handler = channel_handler.expect("channel mode requires handler"); + let handler = channel_handler + .expect("channel mode requires handler") + .with_capability_profile(capability_profile.clone()); + let handler = Arc::new(handler); tokio::time::timeout( timeout, rmcp::service::serve_client(handler.clone(), transport), @@ -98,7 +105,10 @@ where } ConnectionMode::LegacyDefault => tokio::time::timeout( timeout, - rmcp::service::serve_client(super::mcpp_client_info(), transport), + rmcp::service::serve_client( + super::mcpp_client_info_for_profile(capability_profile), + transport, + ), ) .await .map(|inner| inner.map(McpServiceWrapper::Default)), @@ -209,7 +219,11 @@ mod tests { use super::*; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - async fn observe_first_request(protocol_version: Option<&McpProtocolVersion>) -> String { + async fn observe_first_request( + protocol_version: Option<&McpProtocolVersion>, + channel: bool, + capability_profile: &crate::mcp::apps::McpCapabilityProfile, + ) -> serde_json::Value { let (client_io, server_io) = tokio::io::duplex(8192); let server = tokio::spawn(async move { let (read, mut write) = tokio::io::split(server_io); @@ -244,33 +258,66 @@ mod tests { let notification: serde_json::Value = serde_json::from_str(&initialized).unwrap(); assert_eq!(notification["method"], "notifications/initialized"); } - method + request }); + let channel_handler = channel.then(|| { + Arc::new(ChannelHandler::new( + peri_agent::interaction::ChannelState::new(), + )) + }); let _service = serve_client_auto( client_io, - None, + channel_handler.as_ref(), protocol_version, + capability_profile, std::time::Duration::from_secs(2), ) .await .expect("握手不应超时") .expect("握手应成功"); - let method = server.await.unwrap(); - method + server.await.unwrap() } #[tokio::test] async fn none_starts_with_initialize_and_accepts_2025_11_25_response() { - assert_eq!(observe_first_request(None).await, "initialize"); + let request = observe_first_request( + None, + false, + &crate::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await; + assert_eq!(request["method"], "initialize"); } #[tokio::test] async fn explicit_2026_07_28_transport_starts_with_discover() { - assert_eq!( - observe_first_request(Some(&McpProtocolVersion::V2026_07_28)).await, - "server/discover" - ); + let request = observe_first_request( + Some(&McpProtocolVersion::V2026_07_28), + false, + &crate::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await; + assert_eq!(request["method"], "server/discover"); + } + + #[tokio::test] + async fn enabled_profile_is_advertised_in_both_channel_modes() { + let profile = + crate::mcp::apps::McpCapabilityProfile::negotiated([crate::mcp::MCP_APP_MIME_TYPE]); + for protocol_version in [None, Some(&McpProtocolVersion::V2026_07_28)] { + let request = observe_first_request(protocol_version, true, &profile).await; + let extensions = if request["method"] == "server/discover" { + &request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"] + ["extensions"] + } else { + &request["params"]["capabilities"]["extensions"] + }; + assert!( + extensions[crate::mcp::MCP_UI_EXTENSION].is_object(), + "request: {request}" + ); + } } #[test] diff --git a/peri-middlewares/src/mcp/client_oauth.rs b/peri-middlewares/src/mcp/client_oauth.rs index 63c46132..5d63988d 100644 --- a/peri-middlewares/src/mcp/client_oauth.rs +++ b/peri-middlewares/src/mcp/client_oauth.rs @@ -168,7 +168,7 @@ impl McpClientPool { let result = tokio::time::timeout( HTTP_CONNECT_TIMEOUT, rmcp::service::serve_client( - super::client::mcpp_client_info(), + super::client::mcpp_client_info_for_profile(&self.capability_profile), build_authed_transport(&url, &headers, auth_manager), ), ) diff --git a/peri-middlewares/src/mcp/client_test.rs b/peri-middlewares/src/mcp/client_test.rs index 85c83526..79e8d50a 100644 --- a/peri-middlewares/src/mcp/client_test.rs +++ b/peri-middlewares/src/mcp/client_test.rs @@ -48,6 +48,34 @@ impl Drop for TestDropSignal { } } +#[test] +fn server_generation_advances_explicitly_on_each_committed_connection() { + let pool = McpClientPool::new_pending(); + let first = connected_test_handle("server"); + let second = connected_test_handle("server"); + let service = || { + let (entered, _entered_rx) = tokio::sync::oneshot::channel(); + controlled_service( + entered, + Arc::new(tokio::sync::Notify::new()), + Arc::new(std::sync::atomic::AtomicUsize::new(0)), + ) + }; + + assert!(pool + .try_commit_connection("server".into(), Arc::clone(&first), service()) + .is_ok()); + let first_generation = pool.handle_generation(&first); + assert!(pool + .try_commit_connection("server".into(), Arc::clone(&second), service()) + .is_ok()); + let second_generation = pool.handle_generation(&second); + + assert!(first_generation > 0); + assert!(second_generation > first_generation); + assert_eq!(pool.handle_generation(&first), first_generation); +} + #[test] fn test_pool_get_all_clients_filters_disconnected() { let pool = McpClientPool::new_empty(); diff --git a/peri-middlewares/src/mcp/dynamic/registry_test.rs b/peri-middlewares/src/mcp/dynamic/registry_test.rs index 705267ed..7f663c6e 100644 --- a/peri-middlewares/src/mcp/dynamic/registry_test.rs +++ b/peri-middlewares/src/mcp/dynamic/registry_test.rs @@ -890,17 +890,28 @@ async fn drain_timeout_never_reports_unloaded_and_can_be_retried() { DynamicMcpResponse::Accepted(accepted) => accepted.operation_id, response => panic!("unexpected response: {response:?}"), }; - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let status = registry - .execute( - "session-a", - CanonicalDynamicMcpAction::Status(DynamicMcpStatusRequest { - operation_id: Some(operation_id), - ..Default::default() - }), - ) - .await - .unwrap(); + let status = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let status = registry + .execute( + "session-a", + CanonicalDynamicMcpAction::Status(DynamicMcpStatusRequest { + operation_id: Some(operation_id.clone()), + ..Default::default() + }), + ) + .await + .unwrap(); + if matches!(status, DynamicMcpResponse::Status(ref value) + if value.operations[0].state == DynamicMcpOperationState::Failed) + { + break status; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("drain timeout operation did not settle"); assert!(matches!(status, DynamicMcpResponse::Status(ref value) if value.operations[0].state == DynamicMcpOperationState::Failed && value.operations[0].error.as_ref().is_some_and(|failure| failure.code == DynamicMcpErrorCode::ShutdownIncomplete))); diff --git a/peri-middlewares/src/mcp/dynamic/staged_connection.rs b/peri-middlewares/src/mcp/dynamic/staged_connection.rs index 878715d9..662f1c38 100644 --- a/peri-middlewares/src/mcp/dynamic/staged_connection.rs +++ b/peri-middlewares/src/mcp/dynamic/staged_connection.rs @@ -379,7 +379,14 @@ pub async fn prepare_single_server( "Dynamic MCP stdio process could not be started", ) })?; - serve_client_auto(transport, None, config.protocol_version.as_ref(), timeout).await + serve_client_auto( + transport, + None, + config.protocol_version.as_ref(), + &oauth_pool.capability_profile, + timeout, + ) + .await } CanonicalDynamicMcpTransport::StreamableHttp { url, headers } => { let headers = resolve_headers(headers, resolver).await?; @@ -462,6 +469,7 @@ pub async fn prepare_single_server( build_authed_transport(url, &headers, auth_manager), None, config.protocol_version.as_ref(), + &oauth_pool.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/initialize.rs b/peri-middlewares/src/mcp/initialize.rs index 08a612d2..c90e0595 100644 --- a/peri-middlewares/src/mcp/initialize.rs +++ b/peri-middlewares/src/mcp/initialize.rs @@ -115,6 +115,7 @@ impl McpClientPool { transport, channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await @@ -160,6 +161,7 @@ impl McpClientPool { build_http_transport(url, headers), channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await @@ -367,8 +369,14 @@ impl McpClientPool { ref env, } => match spawn_stdio_transport(command, args, env) { Ok(t) => { - serve_client_auto(t, channel_handler.as_ref(), protocol_version, timeout) - .await + serve_client_auto( + t, + channel_handler.as_ref(), + protocol_version, + &pool.capability_profile, + timeout, + ) + .await } Err(e) => { Self::insert_failed(&pool, name, format!("stdio 失败: {e}")); @@ -411,6 +419,7 @@ impl McpClientPool { build_http_transport(url, headers), channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/middleware.rs b/peri-middlewares/src/mcp/middleware.rs index b2415ad4..888dc81c 100644 --- a/peri-middlewares/src/mcp/middleware.rs +++ b/peri-middlewares/src/mcp/middleware.rs @@ -22,7 +22,12 @@ use super::{ /// MCP 中间件 —— 将所有已连接 MCP 服务器的工具和资源注入 ReAct 循环, /// 并向模型通报 MCP 连接状态(首 turn 概览 + 运行中上下线变化)。 pub struct McpMiddleware { + /// Session-projected MCP view used by resources, discovery, and status reporting. pool: Arc, + /// Deployment-owned pool used to build static MCP tool bridges. Static bridges must retain + /// deployment identity such as handle generations and MCP Apps binding leases; dynamic + /// tools are overlaid separately by `SessionToolCatalog`. + tool_pool: Arc, /// 会话级 MCP skill 远端注册表(None = 未装配 session 透传;DiscoverMCP /// 的 skill 域查询读它)。 registry: Option>, @@ -40,6 +45,7 @@ pub struct McpMiddleware { impl McpMiddleware { pub fn new(pool: Arc) -> Self { Self { + tool_pool: Arc::clone(&pool), pool, registry: None, command_registry: None, @@ -48,6 +54,13 @@ impl McpMiddleware { } } + /// Use a deployment-owned pool for static tool bridges while retaining the session-projected + /// pool for resources and discovery. + pub fn with_tool_pool(mut self, tool_pool: Arc) -> Self { + self.tool_pool = tool_pool; + self + } + /// 注入 skill 发现装配(session 级 registry + cancel token;assembly 槽位 /// 调用)。不调用时保持无发现行为(既有测试/print 模式兼容)。 pub fn with_skill_discovery( @@ -322,7 +335,7 @@ impl Middleware for McpMiddleware { } fn collect_tools(&self, _cwd: &str) -> Vec> { - let mut tools = build_tool_bridges(&self.pool); + let mut tools = build_tool_bridges(&self.tool_pool); tools.push(Box::new(McpResourceTool::new( Arc::clone(&self.pool), diff --git a/peri-middlewares/src/mcp/middleware_test.rs b/peri-middlewares/src/mcp/middleware_test.rs index d40110b3..1e0f83d7 100644 --- a/peri-middlewares/src/mcp/middleware_test.rs +++ b/peri-middlewares/src/mcp/middleware_test.rs @@ -27,6 +27,30 @@ fn test_collect_tools_empty_pool() { assert_eq!(tools[1].name(), "DiscoverMCP"); } +#[test] +fn static_tool_bridges_use_deployment_pool_not_session_projection() { + let deployment_pool = Arc::new(McpClientPool::new_empty()); + deployment_pool.clients.write().insert( + "static".to_string(), + make_connected_handle_with_tool("static", "instantiate_app"), + ); + let projected_pool = Arc::new(McpClientPool::new_empty()); + projected_pool.clients.write().insert( + "dynamic".to_string(), + make_connected_handle_with_tool("dynamic", "shadow_tool"), + ); + + let mw = McpMiddleware::new(Arc::clone(&projected_pool)) + .with_tool_pool(Arc::clone(&deployment_pool)); + let names = ::collect_tools(&mw, "/tmp") + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert!(names.contains(&"mcp__static__instantiate_app".to_string())); + assert!(!names.contains(&"mcp__dynamic__shadow_tool".to_string())); +} + // ─── first_turn_reminder:首 turn 概览 ─────────────────────────────────────── /// 空池(无任何服务器配置)→ None(零噪音) @@ -54,6 +78,28 @@ fn make_connected_handle(name: &str, tools: usize) -> Arc { }) } +fn make_connected_handle_with_tool(name: &str, tool_name: &str) -> Arc { + let tool = rmcp::model::Tool::new( + tool_name.to_string(), + "fixture".to_string(), + serde_json::Map::new(), + ); + Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![tool], + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) +} + /// 混合状态概览:connected 带工具数、failed 带错误、disabled 计数 #[test] fn test_overview_mixed_statuses() { diff --git a/peri-middlewares/src/mcp/mod.rs b/peri-middlewares/src/mcp/mod.rs index 7ffc1b23..785983e5 100644 --- a/peri-middlewares/src/mcp/mod.rs +++ b/peri-middlewares/src/mcp/mod.rs @@ -1,4 +1,6 @@ pub mod agent_registry; +pub mod apps; +pub mod apps_relay; pub mod auth_store; pub mod callback_server; pub mod channel_handler; @@ -22,6 +24,12 @@ pub mod tool_bridge; pub mod transport; pub use agent_registry::{ActivatedMcpAgent, McpAgentMetadata, McpAgentRegistry}; +pub use apps::{ + canonical_resource_uri, raw_resource, raw_tool, tool_resource_uri, tool_visibility, + McpAppsInvocationError, McpAppsInvocationSeam, McpAppsInvoker, McpCapabilityProfile, + RawCallToolResult, RawMcpResource, RawMcpTool, ToolVisibility, MCP_APPS_VERSION, + MCP_APP_MIME_TYPE, MCP_UI_EXTENSION, +}; pub use auth_store::{AuthStoreError, FileCredentialStore, PerServerCredentialStore}; pub use callback_server::{parse_code_from_url, CallbackError, OAuthCallbackServer}; pub use channel_handler::ChannelHandler; diff --git a/peri-middlewares/src/mcp/reconnect.rs b/peri-middlewares/src/mcp/reconnect.rs index 6c2c8a6c..4fc0d7bf 100644 --- a/peri-middlewares/src/mcp/reconnect.rs +++ b/peri-middlewares/src/mcp/reconnect.rs @@ -84,7 +84,16 @@ impl McpClientPool { let result = match &tc { TransportConfig::Stdio { command, args, env } => { match spawn_stdio_transport(command, args, env) { - Ok(t) => serve_client_auto(t, None, protocol_version, timeout).await, + Ok(t) => { + serve_client_auto( + t, + None, + protocol_version, + &self.capability_profile, + timeout, + ) + .await + } Err(e) => { McpClientPool::insert_failed(self, server_name, format!("stdio 失败: {e}")); return Err(McpPoolError::ConnectionFailed { @@ -145,6 +154,7 @@ impl McpClientPool { build_authed_transport(url, headers, am), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -153,6 +163,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -164,6 +175,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -174,6 +186,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/tool_bridge.rs b/peri-middlewares/src/mcp/tool_bridge.rs index f809d1ba..2e2d3caa 100644 --- a/peri-middlewares/src/mcp/tool_bridge.rs +++ b/peri-middlewares/src/mcp/tool_bridge.rs @@ -36,7 +36,10 @@ pub struct McpToolBridge { full_name: String, description: String, input_schema: serde_json::Value, + model_visible: bool, + server_generation: u64, client: Arc, + binding_leases: Option>, admission: Option, } @@ -56,6 +59,37 @@ fn sanitize_name_component(name: &str) -> String { .collect() } +fn app_allowed_tools( + server_name: &str, + resource_uri: &str, + tools: &[Tool], + dispatcher: &dyn peri_acp_types::tools::EffectiveToolDispatcher, +) -> std::collections::HashMap { + let dispatcher_tools = dispatcher + .tools() + .into_iter() + .map(|tool| tool.name) + .collect::>(); + tools + .iter() + .filter(|tool| { + super::apps::tool_visibility(tool).app + && super::apps::tool_resource_uri(tool).as_deref() == Some(resource_uri) + }) + .filter_map(|tool| { + let name = tool.name.to_string(); + let effective = format!( + "mcp__{}__{}", + sanitize_name_component(server_name), + sanitize_name_component(&name) + ); + dispatcher_tools + .contains(&effective) + .then_some((name, effective)) + }) + .collect() +} + impl McpToolBridge { pub fn new(server_name: &str, tool: &Tool, client: Arc) -> Self { let tool_name = tool.name.to_string(); @@ -77,7 +111,10 @@ impl McpToolBridge { full_name, description, input_schema, + model_visible: super::apps::tool_visibility(tool).model, + server_generation: 0, client, + binding_leases: None, admission: None, } } @@ -109,10 +146,26 @@ impl McpToolBridge { description, input_schema: serde_json::to_value(&*tool.input_schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())), + model_visible: super::apps::tool_visibility(tool).model, + server_generation: 0, client, + binding_leases: None, admission: Some(admission), }) } + + pub fn with_server_generation(mut self, generation: u64) -> Self { + self.server_generation = generation; + self + } + + pub fn with_binding_leases( + mut self, + registry: Arc, + ) -> Self { + self.binding_leases = Some(registry); + self + } } fn valid_name_component(name: &str) -> bool { @@ -144,10 +197,14 @@ impl BaseTool for McpToolBridge { None } + fn visible_to_model(&self) -> bool { + self.model_visible + } + async fn invoke( &self, input: serde_json::Value, - _ctx: peri_agent::tools::ToolContext<'_>, + ctx: peri_agent::tools::ToolContext<'_>, ) -> Result> { let _permit = match &self.admission { Some(gate) => Some(gate.try_acquire().map_err(|_| { @@ -189,7 +246,7 @@ impl BaseTool for McpToolBridge { reason: e.to_string(), })?; - // 4. 处理 is_error 标志 + // 4. 处理 is_error 标志。失败的实例化调用不得签发 App lease。 if result.is_error.unwrap_or(false) { let error_text = format_contents(&result.content); let lines: Vec<&str> = error_text.lines().collect(); @@ -210,6 +267,57 @@ impl BaseTool for McpToolBridge { })); } + if let ( + Some(registry), + Some(dispatcher), + Some(session_id), + Some(turn_generation), + Some(invocation_id), + ) = ( + self.binding_leases.as_ref(), + ctx.effective_tool_dispatcher.clone(), + ctx.session_id.clone(), + ctx.turn_generation.clone(), + ctx.invocation_id.clone(), + ) { + if invocation_id.starts_with("mcp-app:") { + if let Ok(raw_result) = serde_json::to_value(&result) + .and_then(serde_json::from_value::) + { + registry.record_raw_result( + &invocation_id, + serde_json::to_value(raw_result).unwrap_or(serde_json::Value::Null), + ); + } + } else if let Some(resource_uri) = self + .client + .tools + .iter() + .find(|tool| tool.name.as_ref() == self.tool_name) + .filter(|tool| super::apps::tool_visibility(tool).app) + .and_then(super::apps::tool_resource_uri) + { + let allowed_tools = app_allowed_tools( + &self.server_name, + &resource_uri, + &self.client.tools, + dispatcher.as_ref(), + ); + registry.issue(super::apps::McpAppBindingLease::new( + session_id, + turn_generation, + self.server_name.clone(), + self.server_generation, + resource_uri, + self.tool_name.clone(), + invocation_id, + allowed_tools, + dispatcher, + ctx.cancellation.clone(), + )); + } + } + // 5. 格式化返回(截断超大输出) let formatted = format_contents(&result.content); let lines: Vec<&str> = formatted.lines().collect(); @@ -262,12 +370,13 @@ fn format_contents(contents: &[ContentBlock]) -> String { pub fn build_tool_bridges(pool: &McpClientPool) -> Vec> { let mut bridges: Vec> = Vec::new(); for client in pool.get_all_clients() { + let generation = pool.handle_generation(&client); for tool in &client.tools { - bridges.push(Box::new(McpToolBridge::new( - &client.name, - tool, - Arc::clone(&client), - ))); + bridges.push(Box::new( + McpToolBridge::new(&client.name, tool, Arc::clone(&client)) + .with_server_generation(generation) + .with_binding_leases(Arc::clone(&pool.app_binding_leases)), + )); } } bridges diff --git a/peri-middlewares/src/mcp/tool_bridge_test.rs b/peri-middlewares/src/mcp/tool_bridge_test.rs index 09a51208..efb7e0a5 100644 --- a/peri-middlewares/src/mcp/tool_bridge_test.rs +++ b/peri-middlewares/src/mcp/tool_bridge_test.rs @@ -115,6 +115,90 @@ fn test_format_content_mixed() { assert_eq!(format_contents(&contents), "line1\nline2"); } +struct CatalogDispatcher(Vec); + +#[async_trait::async_trait] +impl peri_acp_types::tools::EffectiveToolDispatcher for CatalogDispatcher { + async fn dispatch( + &self, + _call: peri_acp_types::tools::EffectiveToolCall, + _cancel: tokio_util::sync::CancellationToken, + ) -> Result { + Ok(String::new()) + } + + fn tools(&self) -> Vec { + self.0.clone() + } +} + +#[test] +fn app_allowed_tools_intersects_resource_visibility_and_canonical_catalog() { + let tool = |name: &str, resource: &str, visibility: &str| { + serde_json::from_value::(serde_json::json!({ + "name": name, + "description": name, + "inputSchema": {"type": "object"}, + "_meta": {"ui": {"resourceUri": resource, "visibility": [visibility]}} + })) + .unwrap() + }; + let tools = vec![ + tool("app_only", "ui://app", "app"), + tool("other_resource", "ui://other", "app"), + tool("model_only", "ui://app", "model"), + ]; + let dispatcher = CatalogDispatcher(vec![ + peri_acp_types::tools::EffectiveToolDefinition { + name: "mcp__server__app_only".into(), + description: String::new(), + parameters: serde_json::json!({}), + }, + peri_acp_types::tools::EffectiveToolDefinition { + name: "mcp__server__other_resource".into(), + description: String::new(), + parameters: serde_json::json!({}), + }, + ]); + + assert_eq!( + app_allowed_tools("server", "ui://app", &tools, &dispatcher), + std::collections::HashMap::from([("app_only".into(), "mcp__server__app_only".into())]) + ); +} + +#[test] +fn test_build_tool_bridges_filters_app_only_tool_from_model_catalog() { + let pool = McpClientPool::new_empty(); + let tool: Tool = serde_json::from_value(serde_json::json!({ + "name": "app_only", + "description": "App only", + "inputSchema": {"type": "object"}, + "_meta": {"ui": {"visibility": ["app"]}} + })) + .unwrap(); + pool.clients.write().insert( + "apps".to_string(), + Arc::new(McpClientHandle { + name: "apps".to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![tool], + resources: vec![], + status: ClientStatus::Connected, + oauth_status: Default::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }), + ); + let bridges = build_tool_bridges(&pool); + assert_eq!(bridges.len(), 1); + assert!(!bridges[0].visible_to_model()); +} + #[test] fn test_build_tool_bridges_empty_pool() { let pool = McpClientPool::new_empty(); diff --git a/peri-resources/src/sessions/filesystem.rs b/peri-resources/src/sessions/filesystem.rs index dd0456d2..0c246e69 100644 --- a/peri-resources/src/sessions/filesystem.rs +++ b/peri-resources/src/sessions/filesystem.rs @@ -16,6 +16,10 @@ use peri_acp_types::{ /// 进程内计数器:保证并发写同一目标时临时文件名唯一。 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); +/// 序列化 meta.json 的 read-modify-write,避免 transcript writer 与生命周期 +/// 状态更新互相覆盖。FilesystemThreadStore 仅用于测试,进程级锁足够。 +static META_UPDATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// 原子写入 JSON 文件:先写同目录唯一临时文件再 rename。 /// /// tokio `fs::write` 是「截断 + 写数据」两步(经 spawn_blocking 独立线程), @@ -130,6 +134,7 @@ impl ThreadStore for FilesystemThreadStore { if msgs.is_empty() { return Ok(()); } + let _guard = META_UPDATE_LOCK.lock().await; let path = self.messages_path(id); let mut file = tokio::fs::OpenOptions::new() .create(true) @@ -145,7 +150,8 @@ impl ThreadStore for FilesystemThreadStore { } file.flush().await?; - // 更新 meta 的 message_count 和 updated_at + // 更新 meta 的 message_count 和 updated_at。与状态更新共用锁,避免基于 + // 旧快照写回时把 done/error/cancelled 覆盖成 active。 let mut meta = self.load_meta(id).await?; meta.message_count += msgs.len(); meta.updated_at = Utc::now(); @@ -281,6 +287,7 @@ impl ThreadStore for FilesystemThreadStore { // 关键约束:参数字符串必须经 FromStr 解析,非法值直接返回错误,不静默 fallback let status = AgentStatus::from_str(status) .with_context(|| format!("非法 agent_status 值: {status:?}"))?; + let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(id).await?; meta.agent_status = status; meta.updated_at = Utc::now(); @@ -288,6 +295,7 @@ impl ThreadStore for FilesystemThreadStore { } async fn invalidate_context_cache(&self, thread_id: &ThreadId) -> Result<()> { + let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(thread_id).await?; meta.cached_context = None; self.update_meta(thread_id, meta).await @@ -330,6 +338,7 @@ impl ThreadStore for FilesystemThreadStore { thread_id: &ThreadId, message_id: &peri_acp_types::messages::MessageId, ) -> Result<()> { + let _guard = META_UPDATE_LOCK.lock().await; // 重写 messages.jsonl:保留 message_id 所在位置(含)之前所有行。 let path = self.messages_path(thread_id); if !path.exists() { diff --git a/peri-resources/src/sessions/filesystem_test.rs b/peri-resources/src/sessions/filesystem_test.rs index c6d070bd..69c56163 100644 --- a/peri-resources/src/sessions/filesystem_test.rs +++ b/peri-resources/src/sessions/filesystem_test.rs @@ -82,6 +82,32 @@ async fn test_message_count_updates() { assert_eq!(loaded.message_count, 1); } +#[tokio::test] +async fn test_concurrent_append_preserves_terminal_status() { + let dir = tempdir().unwrap(); + let store: Arc = Arc::new(FilesystemThreadStore::new(dir.path())); + let id = store.create_thread(make_meta("/test")).await.unwrap(); + let append_store = Arc::clone(&store); + let append_id = id.clone(); + let status_store = Arc::clone(&store); + let status_id = id.clone(); + + let (append_result, status_result) = tokio::join!( + async move { + append_store + .append_messages(&append_id, &[BaseMessage::human("msg")]) + .await + }, + async move { status_store.update_thread_status(&status_id, "error").await } + ); + append_result.unwrap(); + status_result.unwrap(); + + let loaded = store.load_meta(&id).await.unwrap(); + assert_eq!(loaded.message_count, 1); + assert_eq!(loaded.agent_status, AgentStatus::Error); +} + #[tokio::test] async fn test_title_extracted_from_first_human() { let dir = tempdir().unwrap(); diff --git a/peri-tui/src/kit/acp_events/render.rs b/peri-tui/src/kit/acp_events/render.rs index 69537fda..41280635 100644 --- a/peri-tui/src/kit/acp_events/render.rs +++ b/peri-tui/src/kit/acp_events/render.rs @@ -194,8 +194,8 @@ fn group_input_fingerprint(segment: &im::Vector) -> (u64, bool) { let last = segment.len().saturating_sub(1); for (i, vm) in segment.iter().enumerate() { let is_trailing_bubble = i == last && matches!(vm, TuiRenderUnit::TuiAssistantBubble(_)); - h = if is_trailing_bubble { - tui_hash_combine(h, TRAILING_BUBBLE_MARKER) + let entry_hash = if is_trailing_bubble { + TRAILING_BUBBLE_MARKER } else { match vm { TuiRenderUnit::TuiToolCard(t) => { @@ -227,6 +227,7 @@ fn group_input_fingerprint(segment: &im::Vector) -> (u64, bool) { } } }; + h = tui_hash_combine(h, entry_hash); } h = tui_hash_combine(h, segment.len() as u64); // [S2 单一事实源] 焦点键——焦点工具免疫随焦点变化切换(命中/未命中必须 diff --git a/peri-tui/src/kit/acp_events_test/snapshot_test.rs b/peri-tui/src/kit/acp_events_test/snapshot_test.rs index eca74810..e7761178 100644 --- a/peri-tui/src/kit/acp_events_test/snapshot_test.rs +++ b/peri-tui/src/kit/acp_events_test/snapshot_test.rs @@ -139,6 +139,52 @@ fn test_snapshot_todo_summary_before_final_answer() { .all(|vm| !matches!(vm, TuiRenderUnit::TuiTodoSummary(_))), "回答后无 todo 摘要" ); + crate::kit::atoms::TODO_ITEMS.state().write().clear(); +} + +/// [回归] todo 摘要位于 subagent group 之后时,快照分组缓存的指纹必须包含 +/// group 本身;否则 child 内容变化但末尾摘要不变会误命中旧缓存。 +#[test] +#[serial] +fn test_snapshot_cache_tracks_subagent_before_todo_summary() { + let mut state = make_fold_test_state(); + *crate::kit::atoms::TODO_ITEMS.state().write() = vec![crate::kit::message_area::TodoItem { + status: crate::kit::message_area::TodoStatus::InProgress, + content: "subagent fingerprint regression sentinel".into(), + }]; + + dispatch_and_notify( + &mut state, + &AcpEventData::SubagentStarted { + agent_id: "cache-agent".into(), + agent_name: "researcher".into(), + is_background: false, + }, + ); + dispatch_and_notify( + &mut state, + &AcpEventData::TextChunk(TuiTextChunk { + text: "child text".into(), + message_id: Some("cache-child".into()), + agent_id: Some("cache-agent".into()), + }), + ); + + let snap = VIEW_MODELS.state().read().clone(); + assert_eq!(snap.items.len(), 2, "group 后应保留 todo 摘要"); + match &snap.items[0] { + TuiRenderUnit::TuiSubAgentGroup(group) => { + assert_eq!(group.agent_id, "cache-agent"); + assert_eq!( + group.view_models.len(), + 1, + "child 更新后不得复用启动时的空 group 缓存" + ); + } + other => panic!("expected TuiSubAgentGroup at [0], got {other:?}"), + } + + crate::kit::atoms::TODO_ITEMS.state().write().clear(); } /// §7 工具分组:相邻成功 Generic 工具压成 TuiCollapsedGroup(标题含隐藏数); diff --git a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs index 8e1a984b..3f49222c 100644 --- a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs +++ b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs @@ -43,9 +43,9 @@ fn test_dispatch_subagent_streaming_updates_current_turn_group() { }), ); - let snapshot = VIEW_MODELS.state().read().clone(); - assert_eq!(snapshot.items.len(), 1); - match &snapshot.items[0] { + let current_turn = state.current_turn.view_models().clone(); + assert_eq!(current_turn.len(), 1); + match ¤t_turn[0] { TuiRenderUnit::TuiSubAgentGroup(group) => { assert_eq!(group.agent_id, "agent-1"); assert_eq!(group.view_models.len(), 1); @@ -102,8 +102,8 @@ fn test_subagent_stopped_freezes_child_trailing_bubble() { ); // 流式期间:子 turn trailing bubble 持有 started_at(Running 形态)。 - let running_snap = VIEW_MODELS.state().read().clone(); - let b = match &running_snap.items[0] { + let running_vms = state.current_turn.view_models().clone(); + let b = match &running_vms[0] { TuiRenderUnit::TuiSubAgentGroup(g) => match &g.view_models[0] { TuiRenderUnit::TuiAssistantBubble(b) => b, other => panic!("expected child TuiAssistantBubble, got {other:?}"), @@ -127,8 +127,8 @@ fn test_subagent_stopped_freezes_child_trailing_bubble() { // stop 后:started_at 清除 + duration_ms 冻结(详情面板不再显示增长中的 // `◐ Thinking… Ns`)。 - let snap = VIEW_MODELS.state().read().clone(); - let b = match &snap.items[0] { + let stopped_vms = state.current_turn.view_models().clone(); + let b = match &stopped_vms[0] { TuiRenderUnit::TuiSubAgentGroup(g) => match &g.view_models[0] { TuiRenderUnit::TuiAssistantBubble(b) => b, other => panic!("expected child TuiAssistantBubble, got {other:?}"), @@ -618,7 +618,7 @@ fn test_prompt_submitted_sets_loading() { } /// 同步 sub-agent 的 ToolStarted/ToolEnded 事件应路由到 SubAgentAccumulator, -/// 并反映在 VIEW_MODELS 的 TuiSubAgentGroup 中。 +/// 并反映在当前 turn 的 TuiSubAgentGroup 中。 #[test] #[serial] fn test_dispatch_sync_subagent_tool_routed_to_group() { @@ -676,10 +676,10 @@ fn test_dispatch_sync_subagent_tool_routed_to_group() { }), ); - let snapshot = VIEW_MODELS.state().read().clone(); - // items 中应有 1 个 TuiSubAgentGroup - assert_eq!(snapshot.items.len(), 1, "items 应包含 1 个元素"); - match &snapshot.items[0] { + let current_turn = state.current_turn.view_models().clone(); + // current_turn 中应有 1 个 TuiSubAgentGroup + assert_eq!(current_turn.len(), 1, "current_turn 应包含 1 个元素"); + match ¤t_turn[0] { TuiRenderUnit::TuiSubAgentGroup(group) => { assert_eq!(group.agent_id, "sync-1"); assert!( diff --git a/side-projects/mcp-apps/check-peri.ts b/side-projects/mcp-apps/check-peri.ts new file mode 100644 index 00000000..82ca9376 --- /dev/null +++ b/side-projects/mcp-apps/check-peri.ts @@ -0,0 +1,421 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; + +const projectDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(projectDir, "../.."); +const fixtureServer = path.join(projectDir, "stdio-server.ts"); +const tsxCli = path.join(projectDir, "node_modules", "tsx", "dist", "cli.mjs"); +const serverId = "official-apps-fixture"; +const toolName = "get-time"; +const effectiveToolName = `mcp__${serverId}__${toolName}`; +const invocationToken = "fixture-model-call-1"; +const resourceUri = "ui://get-time/mcp-app.html"; +const envelopeVersion = "1"; +const appsProtocolVersion = "2026-01-26"; +const appRequestId = "app-call-1"; + +interface JsonRpcResponse { + id?: number; + result?: Record; + error?: { code: number; message: string; data?: { kind?: string } }; +} + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function findToolCallId(value: unknown, title: string): string | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const found = findToolCallId(item, title); + if (found) return found; + } + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const object = value as Record; + if (object.title === title && typeof object.toolCallId === "string") return object.toolCallId; + for (const child of Object.values(object)) { + const found = findToolCallId(child, title); + if (found) return found; + } + return undefined; +} + +function hasCompletedToolCall(value: unknown, toolCallId: string): boolean { + if (Array.isArray(value)) return value.some((item) => hasCompletedToolCall(item, toolCallId)); + if (!value || typeof value !== "object") return false; + const object = value as Record; + if (object.toolCallId === toolCallId && object.status === "completed") return true; + return Object.values(object).some((child) => hasCompletedToolCall(child, toolCallId)); +} + +class AcpClient { + private readonly pending = new Map< + number, + { resolve: (response: JsonRpcResponse) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout } + >(); + private readonly notifications: Record[] = []; + private readonly waiters: Array<{ + predicate: (message: Record) => boolean; + resolve: (message: Record) => void; + timeout: NodeJS.Timeout; + }> = []; + private readonly lines: readline.Interface; + private stderr = ""; + + constructor(readonly process: ChildProcessWithoutNullStreams) { + process.stderr.on("data", (chunk) => { + this.stderr += String(chunk); + }); + this.lines = readline.createInterface({ input: process.stdout }); + this.lines.on("line", (line) => { + let response: JsonRpcResponse; + try { + response = JSON.parse(line) as JsonRpcResponse; + } catch { + return; + } + if (typeof response.id !== "number") { + const notification = response as Record; + this.notifications.push(notification); + for (let index = this.waiters.length - 1; index >= 0; index -= 1) { + const waiter = this.waiters[index]; + if (!waiter.predicate(notification)) continue; + clearTimeout(waiter.timeout); + this.waiters.splice(index, 1); + waiter.resolve(notification); + } + return; + } + const pending = this.pending.get(response.id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(response.id); + pending.resolve(response); + }); + process.once("exit", (code, signal) => { + const error = new Error(`Peri exited: code=${String(code)} signal=${String(signal)}\n${this.stderr}`); + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + }); + } + + request(id: number, method: string, params: unknown): Promise { + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`timed out waiting for ACP response ${id}\n${this.stderr}`)); + }, 120_000); + this.pending.set(id, { resolve, reject, timeout }); + }); + this.process.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + return response; + } + + waitForNotification( + predicate: (message: Record) => boolean, + description: string, + ): Promise> { + const existing = this.notifications.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) this.waiters.splice(index, 1); + reject(new Error(`timed out waiting for ${description}\n${this.stderr}`)); + }, 120_000); + this.waiters.push({ predicate, resolve, timeout }); + }); + } + + diagnostics() { + return this.stderr; + } + + close() { + this.lines.close(); + this.process.stdin.end(); + this.process.kill("SIGTERM"); + } +} + +async function startModelServer() { + let calls = 0; + const server = http.createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + request.resume(); + request.on("end", () => { + calls += 1; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const chunk = + calls === 1 + ? { + id: "fixture-model-tool-call", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: invocationToken, + function: { name: effectiveToolName, arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + } + : { + id: "fixture-model-complete", + choices: [{ delta: { content: "fixture complete" }, finish_reason: "stop" }], + }; + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + response.end("data: [DONE]\n\n"); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + invariant(address && typeof address === "object", "mock model server did not bind a TCP port"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + calls: () => calls, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + }; +} + +async function writeWorkspace(workspace: string, modelBaseUrl: string) { + await fs.writeFile( + path.join(workspace, ".mcp.json"), + JSON.stringify({ + mcpServers: { + [serverId]: { command: process.execPath, args: [tsxCli, fixtureServer] }, + }, + }), + ); + const settingsPath = path.join(workspace, "settings.json"); + await fs.writeFile( + settingsPath, + JSON.stringify({ + config: { + active_alias: "sonnet", + providers: [ + { + id: "fixture-model", + type: "openai", + apiKey: "fixture-not-a-secret", + baseUrl: modelBaseUrl, + models: { sonnet: "fixture-model" }, + }, + ], + profiles: { + sonnet: { provider: "fixture-model", model: "fixture-model", effort: "medium", max_tokens: 1024 }, + }, + }, + }), + ); + return settingsPath; +} + +function startPeri(workspace: string, settingsPath: string, appsEnabled: boolean) { + const env = { ...process.env }; + if (appsEnabled) env.PERI_MCP_APPS = ""; + else delete env.PERI_MCP_APPS; + delete env.ANTHROPIC_API_KEY; + delete env.OPENAI_API_KEY; + const child = spawn( + "cargo", + [ + "run", + "-q", + "-p", + "peri-tui", + "--", + "--config-file", + settingsPath, + "acp", + "--cwd", + workspace, + ], + { cwd: repoRoot, env, stdio: ["pipe", "pipe", "pipe"] }, + ); + return new AcpClient(child); +} + +function expectResult(response: JsonRpcResponse, context: string): Record { + invariant(response.result, `${context} failed: ${JSON.stringify(response.error)}`); + return response.result; +} + +async function runSuccessfulRelay(workspace: string, settingsPath: string) { + const client = startPeri(workspace, settingsPath, true); + try { + expectResult( + await client.request(1, "initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-apps-e2e", version: "1.0.0" }, + }), + "initialize", + ); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + const session = expectResult(await client.request(2, "session/new", { cwd: workspace }), "session/new"); + const sessionId = session.sessionId; + invariant(typeof sessionId === "string", "session/new did not return sessionId"); + + const toolStarted = client.waitForNotification( + (message) => findToolCallId(message, effectiveToolName) !== undefined, + "canonical MCP ToolCall notification", + ); + const promptResponse = client.request(3, "session/prompt", { + sessionId, + prompt: [{ type: "text", text: "Call the MCP Apps fixture tool exactly once." }], + }); + const startedMessage = await toolStarted; + const observedInvocationToken = findToolCallId(startedMessage, effectiveToolName); + invariant(typeof observedInvocationToken === "string", "ToolCall notification did not expose toolCallId"); + invariant( + observedInvocationToken === invocationToken, + "ACP wire changed the MCP tool invocation token", + ); + const toolCompleted = client.waitForNotification( + (message) => hasCompletedToolCall(message, observedInvocationToken), + "completed canonical MCP ToolCallUpdate notification", + ); + await toolCompleted; + + const openResponse = await client.request(4, "peri/mcp/open", { + envelopeVersion, + appsProtocolVersion, + serverId, + ownerSessionId: sessionId, + invocationToken: observedInvocationToken, + toolName, + }); + const opened = expectResult( + openResponse, + "peri/mcp/open", + ); + invariant(typeof opened.appSessionId === "string", "open response is missing appSessionId"); + invariant(opened.resourceUri === resourceUri, "open response changed resourceUri"); + const appSessionId = opened.appSessionId; + + const resource = expectResult( + await client.request(5, "peri/mcp/resource", { + envelopeVersion, + appsProtocolVersion, + serverId, + appSessionId, + resourceUri, + }), + "peri/mcp/resource", + ); + const resources = resource.resources; + invariant(Array.isArray(resources) && resources.length === 1, "resource relay did not preserve contents[]"); + const html = resources[0] as Record; + invariant(html.uri === resourceUri, "resource relay changed uri"); + invariant(html.mimeType === "text/html;profile=mcp-app", "resource relay changed MIME"); + invariant(typeof html.text === "string" && html.text.includes(""), "resource relay lost HTML text"); + const resourceMeta = html._meta as { ui?: { csp?: { connectDomains?: unknown[]; resourceDomains?: unknown[] } } }; + invariant(resourceMeta.ui?.csp?.connectDomains?.length === 0, "resource relay lost _meta.ui.csp.connectDomains"); + invariant(resourceMeta.ui?.csp?.resourceDomains?.length === 0, "resource relay lost _meta.ui.csp.resourceDomains"); + + const app = expectResult( + await client.request(6, "peri/mcp/app", { + envelopeVersion, + appsProtocolVersion, + serverId, + appSessionId, + resourceUri, + payload: { + jsonrpc: "2.0", + id: appRequestId, + method: "tools/call", + params: { name: toolName, arguments: {} }, + }, + }), + "peri/mcp/app", + ); + const payload = app.payload as { id?: unknown; result?: Record }; + invariant(payload.id === appRequestId, "App JSON-RPC request id did not round-trip"); + invariant(payload.result, "App tools/call is missing result"); + invariant(payload.result.isError !== true, "App tools/call returned isError=true"); + const content = payload.result.content; + invariant(Array.isArray(content) && content.length === 1, "App tools/call lost content[]"); + invariant( + (content[0] as { type?: string; text?: string }).type === "text" && + typeof (content[0] as { text?: string }).text === "string", + "App tools/call lost text fallback", + ); + const structured = payload.result.structuredContent as { iso?: string } | undefined; + invariant(typeof structured?.iso === "string" && !Number.isNaN(Date.parse(structured.iso)), "App tools/call lost structuredContent.iso"); + invariant( + (payload.result._meta as { fixture?: string } | undefined)?.fixture === "peri-mcp-apps", + "App tools/call lost result _meta", + ); + expectResult(await promptResponse, "session/prompt"); + + return { sessionId, appSessionId, resourceCount: resources.length, appRequestId }; + } finally { + client.close(); + } +} + +async function runDisabledProbe(workspace: string, settingsPath: string) { + const client = startPeri(workspace, settingsPath, false); + try { + expectResult( + await client.request(10, "initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-apps-disabled-check", version: "1.0.0" }, + }), + "disabled initialize", + ); + const opened = await client.request(11, "peri/mcp/open", { + envelopeVersion, + appsProtocolVersion, + serverId, + ownerSessionId: "disabled", + invocationToken: "disabled", + toolName, + }); + invariant(opened.error?.data?.kind === "capability_disabled", "missing PERI_MCP_APPS did not disable relay"); + return opened.error.data.kind; + } finally { + client.close(); + } +} + +const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "peri-mcp-apps-e2e-")); +const model = await startModelServer(); +try { + const settingsPath = await writeWorkspace(workspace, model.baseUrl); + const success = await runSuccessfulRelay(workspace, settingsPath); + invariant(model.calls() >= 2, "canonical session/prompt did not execute the model/tool loop"); + const disabledProbe = await runDisabledProbe(workspace, settingsPath); + process.stdout.write(JSON.stringify({ ok: true, ...success, modelCalls: model.calls(), disabledProbe }, null, 2) + "\n"); +} finally { + await model.close(); + await fs.rm(workspace, { recursive: true, force: true }); +} diff --git a/side-projects/mcp-apps/check.ts b/side-projects/mcp-apps/check.ts new file mode 100644 index 00000000..259dd6d5 --- /dev/null +++ b/side-projects/mcp-apps/check.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const RESOURCE_URI = "ui://get-time/mcp-app.html"; +const MIME = "text/html;profile=mcp-app"; +const projectDir = path.dirname(fileURLToPath(import.meta.url)); + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +const client = new Client( + { name: "peri-mcp-apps-check", version: "1.0.0" }, + { + capabilities: { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [MIME], + }, + }, + }, + }, +); + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(projectDir, "node_modules", "tsx", "dist", "cli.mjs"), "stdio-server.ts"], + cwd: projectDir, + stderr: "pipe", +}); + +try { + await client.connect(transport); + + const tools = await client.listTools(); + const getTime = tools.tools.find((tool) => tool.name === "get-time"); + invariant(getTime, "tools/list did not return get-time"); + + const meta = getTime._meta as + | { ui?: { resourceUri?: string; visibility?: string[] } } + | undefined; + invariant( + meta?.ui?.resourceUri === RESOURCE_URI, + "get-time is missing _meta.ui.resourceUri", + ); + invariant( + meta.ui.visibility?.includes("app") === true, + "get-time is not app-visible", + ); + + const resource = await client.readResource({ uri: RESOURCE_URI }); + invariant(resource.contents.length > 0, "resources/read returned no contents"); + const content = resource.contents[0]; + invariant(content.uri === RESOURCE_URI, "resource URI did not round-trip"); + invariant(content.mimeType === MIME, "resource MIME is not MCP Apps HTML"); + invariant("text" in content && content.text.includes(""), "resource is not bundled HTML"); + + const result = await client.callTool({ name: "get-time", arguments: {} }); + const structured = result.structuredContent as { iso?: string } | undefined; + invariant(typeof structured?.iso === "string", "tools/call is missing structuredContent.iso"); + invariant(!Number.isNaN(Date.parse(structured.iso)), "structuredContent.iso is not an ISO date"); + const contentBlocks = Array.isArray(result.content) ? result.content : []; + invariant(contentBlocks.length > 0, "tools/call is missing text fallback content"); + invariant(result.isError !== true, "tools/call returned isError=true"); + + process.stdout.write( + JSON.stringify( + { + ok: true, + tool: getTime.name, + resourceUri: content.uri, + mimeType: content.mimeType, + structuredContent: structured, + }, + null, + 2, + ) + "\n", + ); +} finally { + await client.close(); +} diff --git a/side-projects/mcp-apps/package.json b/side-projects/mcp-apps/package.json index 0deb7efc..491c4ffd 100644 --- a/side-projects/mcp-apps/package.json +++ b/side-projects/mcp-apps/package.json @@ -7,11 +7,16 @@ "scripts": { "chat": "bun run chat/server.ts", "get-time": "bunx tsx server.ts", + "get-time:stdio": "bunx tsx stdio-server.ts", + "check": "npm run build && bunx tsx check.ts", + "check:peri": "npm run build && bunx tsx check-peri.ts", + "typecheck": "bunx tsc --noEmit", "build": "INPUT=mcp-app.html bunx vite build" }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.30.0" + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/side-projects/mcp-apps/stdio-server.ts b/side-projects/mcp-apps/stdio-server.ts new file mode 100644 index 00000000..10a64d56 --- /dev/null +++ b/side-projects/mcp-apps/stdio-server.ts @@ -0,0 +1,82 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +export const RESOURCE_URI = "ui://get-time/mcp-app.html"; + +const server = new McpServer({ + name: "Peri MCP Apps Fixture", + version: "1.0.0", +}); + +registerAppTool( + server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time for the MCP Apps fixture.", + inputSchema: {}, + outputSchema: { iso: z.string().datetime() }, + _meta: { + ui: { + resourceUri: RESOURCE_URI, + visibility: ["model", "app"], + }, + }, + }, + async () => { + const iso = new Date().toISOString(); + return { + content: [{ type: "text", text: iso }], + structuredContent: { iso }, + _meta: { fixture: "peri-mcp-apps" }, + }; + }, +); + +registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { + mimeType: RESOURCE_MIME_TYPE, + _meta: { + ui: { + prefersBorder: true, + }, + }, + }, + async () => { + const html = await fs.readFile( + path.join(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + prefersBorder: true, + csp: { + connectDomains: [], + resourceDomains: [], + }, + }, + }, + }, + ], + }; + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/spec/issues/2026-08-27-mcp-apps-stdio-relay.md b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md new file mode 100644 index 00000000..ba1e5573 --- /dev/null +++ b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md @@ -0,0 +1,186 @@ +# MCP Apps Stable stdio relay + +**状态**:Implemented(环境 capability、resource relay、Binding lease + canonical HITL tools/call 已接通) +**优先级**:高 +**类型**:协议适配 / ACP stdio / MCP capability +**更新日期**:2026-08-27 + +## 最终决策 + +```text +PERI_MCP_APPS 环境变量存在 + → 当前进程使用 immutable Apps deployment profile + → MCP pool prewarm / reconnect / Dynamic MCP initialize 声明 + io.modelcontextprotocol/ui + text/html;profile=mcp-app + → stdio 装配 MCP Apps relay backend + +环境变量不存在 + → 不声明 extension + → 不装配 relay + → peri/mcp/* fail closed +``` + +环境变量**只看是否存在,值不解析**;空串、`0` 等均表示启用。进程内 profile 通过 `OnceLock` 冻结。完全移除 ACP `peri.mcpApps` capability 的解析、协商和回显。 + +## 范围 + +### Peri 实现 + +- 环境 deployment profile。 +- MCP initialize/reconnect capability propagation。 +- `_meta.ui.resourceUri` canonical/legacy 兼容读取与冲突 fail closed。 +- `_meta.ui.visibility` model/app 解释;malformed fail closed。 +- `peri/mcp/open`:按 server/tool 建立 connection-owned resource binding。 +- `peri/mcp/resource`:读取并返回 `ui://` HTML resource,保留 `text|blob` 与 `_meta`。 +- `peri/mcp/app` typed envelope;`tools/call` 经单次消费 Binding lease 进入 canonical `EffectiveToolDispatcher` 与 Permission/HITL。 +- ACP EOF 清理该 connection 的 binding 和 pending relay。 + +### 明确不实现 + +- Web Host、iframe、sandbox、CSP/Permissions Policy 执行、浏览器 `postMessage`、MCP Apps FE SDK。 +- `peri-tui` Apps 能力或渲染。 +- ACP MCP Apps capability。 +- 任意 MCP proxy。 +- 绕过 effective tool view / Permission / HITL 的直接 `Peer::call_tool()`。 + +## Wire contract + +### Open + +```json +{ + "method": "peri/mcp/open", + "params": { + "envelopeVersion": "1", + "appsProtocolVersion": "2026-01-26", + "serverId": "server", + "toolName": "open_dashboard", + "ownerSessionId": "agent-session", + "invocationToken": "opaque-invocation-token" + } +} +``` + +Peri 校验:`ownerSessionId` 与 `invocationToken` 两个 lease 字段均为必填,并与待消费 lease 精确匹配;同时校验 server 已连接、tool 存在、visibility 包含 `app`、tool metadata 含合法且无冲突的 `ui://` resource URI。成功返回 opaque `appSessionId`、resource URI 和 MCP core version。 + +### Resource + +`peri/mcp/resource` 必须携带 `appSessionId + serverId + resourceUri`。Peri 校验 connection owner、server generation、resource binding,然后执行 `resources/read`。首版要求: + +- URI 精确匹配且为 `ui://`; +- MIME 为 `text/html;profile=mcp-app`; +- `text` / `blob` 恰有一个; +- `_meta` 与未知字段保留。 + +### App tools/call + +DTO 与 request/response id 分层已接通。初始模型 MCP tool invocation 在 `ToolContext` 中取得 canonical dispatcher、session/turn identity 与 cancellation;仅当 tool 同时 app-visible 且绑定合法 `ui://` resource 时签发短期 lease。`peri/mcp/open` 必须携带该 invocation 的 opaque correlation token 与 owner session identity,并单次消费精确匹配 server/tool/resource/generation/session/token 的 lease;同 connection 内并发 open 也不能消费彼此 lease。后续 `tools/call` 通过 lease dispatcher 执行 canonical full tool name。 + +allowed tool set 是“同 server、同 resource URI、visibility 包含 `app`”与 lease 签发时 canonical effective catalog 的交集。它包含 app-only tool,但 app-only tool 不进入模型 definitions;调用仍进入同一 `EffectiveToolDispatcher`、Permission 与 HITL 路径。 + +MCP bridge 在专用 `mcp-app:` invocation identity 下将 raw `CallToolResult` 写入无日志 side channel;dispatcher 完成后 relay 取走并构造 Apps JSON-RPC response,完整保留 `content`、`structuredContent`、`_meta`、`isError` 和未知字段,不把 dispatcher 的文本结果伪装成 raw result。 + +## Binding lease + +```text +InitialMcpAppBindingLease { + agent_session_id, + turn_generation, + server_id, + server_generation, + resource_uri, + instantiating_tool, + opaque_invocation_token, + app_visible_effective_tool_set, + effective_dispatcher, + cancellation, + expires_at +} +``` + +租约由 canonical initial invocation 签发,`peri/mcp/open` 按 server/tool/resource/generation/session/opaque token 单次消费并绑定 ACP connection;TTL 为 5 分钟。registry 记录 session 的最新 turn generation,较旧 pending/active lease 均失效;turn/session cancellation、server generation 变化、过期同样使租约无效。pending/active lease 与 raw result side channel 均有 TTL、容量上限和 opportunistic cleanup;raw result invocation identity 含 connection owner,ACP EOF 撤销该 connection 的 active relay lease、清除 connection-owned binding 和未取走 raw result。 + +server generation 在每次 pool connection commit/失败替换时显式递增;验证只查 server name 对应的 generation,不使用 `Arc` 地址或指针相等推断。stdio 装配点读取一次环境并构造 immutable profile;pool、initial/reconnect 和 Dynamic MCP connector 复用同一 profile,TUI/MPSC 入口固定 disabled 且不读取/传播环境。Apps requests 由 connection-owned task 异步执行;除 `open` 的短临界提交外不跨网络 await 持 connection 锁,EOF cancellation 与 response 发送竞争后每个已接收 request 至多产生一个 terminal response。 + +## 成功路径故障记录 + +### 现象 + +官方风格 fixture 已能驱动模型调用 App 实例化工具并收到 completed tool update,但随后 `peri/mcp/open` 无法消费对应 Binding lease。逐项排除 prompt 完成时序、invocation token、普通 cleanup 与 session-turn revoke 后,registry identity 诊断确认:tool bridge 在 registry A 签发,relay 在 registry B 消费。 + +### 根因 + +启用 Dynamic MCP 时,`McpMiddleware` 使用 `CheckedSessionMcpProjection` 持有的 session projection pool。该 pool 是独立 `McpClientPool`,仅复制 effective handles,不共享 deployment pool 的 `app_binding_leases` 与 handle generation identity。因此 static MCP tool bridge 从 projection pool 构建后,签发的 lease 无法被绑定 deployment pool 的 `PoolMcpAppsRelay` 消费。 + +这不是 stale catalog:dynamic tools 本来就由 `SessionToolCatalog` capability overlay 注入;问题是 static bridge 错用了 projection pool。 + +### 修复 + +`McpMiddleware` 明确区分两个视图: + +- session projection pool 继续负责 resources、discovery 与 status; +- deployment-owned pool 专门构建 static MCP tool bridges,保留真实 server generation 与 Apps lease registry; +- dynamic tools 仍由 `SessionToolCatalog` overlay 注入,保持 session 隔离和 shadow 语义。 + +同时保留 initial invocation cancellation 对 lease 的约束;未采用“lease 自持独立 cancellation token”的临时实验。失败的 MCP `CallToolResult` 仍先按 `isError` 结算,不得签发 App lease。 + +## 安全不变量 + +1. 环境变量不存在时,extension 与 relay 都不存在。 +2. Apps profile 在 prewarm 前冻结,初始连接和重连一致。 +3. binding 属于 ACP connection,不能跨 connection 使用。 +4. server handle replacement 改变 generation,旧 binding 返回 `stale_server_generation`。 +5. App visibility 不含 `app` 时拒绝 open。 +6. `isError: true` 属正常 `CallToolResult`,不得映射成 transport error。 +7. error 层级由 ACP outer error 与 payload JSON-RPC error 的结构位置区分。 +8. 不记录 HTML 正文、tool 参数、OAuth token、header、stdio env 或其他 secret。 + +## 验证 + +已覆盖: + +- env profile presence/absence;环境变量存在且值为空时启用,不存在时 fail closed; +- initial/reconnect/channel MCP initialize 的 UI extension 构造; +- visibility 与 resource URI canonicalization; +- raw resource/result roundtrip; +- connection-owned binding、session close 与 EOF 清理; +- Apps envelope/version/JSON-RPC result/error 校验; +- static MCP bridge 使用 deployment pool,而 resources/discovery 保持 session projection; +- 官方风格 stdio fixture 的 `tools/list → resources/read → tools/call`; +- 真实 Peri successful path:模型实例化工具 → completed update → `peri/mcp/open` → resource → App `tools/call`; +- disabled deployment probe 返回 `capability_disabled`。 + +本次验收证据: + +```text +cargo test -p peri-middlewares --lib + 1587 passed +cargo test -p peri-acp --lib mcp_apps + 2 passed +cargo clippy -p peri-middlewares -p peri-acp --all-targets -- -D warnings + passed +cd side-projects/mcp-apps && npm run check:peri + ok=true, resourceCount=1, modelCalls=3, disabledProbe=capability_disabled +cargo fmt --all -- --check + passed +git diff --check + passed +``` + +后续增强项不阻塞 stdio successful path: + +- Binding lease 的 HITL deny、expiry 与 server reconnect wire-level fixture; +- 更高并发下多个 App session 的隔离压力测试。 + +## 目标命令 + +```bash +cargo fmt --check +cargo test -p peri-acp-types --lib mcp_apps +cargo test -p peri-middlewares --lib mcp::apps +cargo test -p peri-middlewares --lib mcp::tool_bridge +cargo test -p peri-acp --lib +cargo check -p peri-acp-types -p peri-middlewares -p peri-acp +cargo clippy -p peri-acp-types -p peri-middlewares -p peri-acp --all-targets -- -D warnings +git diff --check +``` diff --git a/spec/issues/2026-08-29-windows-acp-prompt-silently-hangs.md b/spec/issues/2026-08-29-windows-acp-prompt-silently-hangs.md new file mode 100644 index 00000000..fb21aeff --- /dev/null +++ b/spec/issues/2026-08-29-windows-acp-prompt-silently-hangs.md @@ -0,0 +1,55 @@ +# Windows 上 ACP 发送提示词后静默卡死 + +**状态**:Fixed +**优先级**:高 +**创建日期**:2026-08-29 + +## 问题描述 + +Windows 10 上运行 `peri acp` 时,ACP 客户端能够建立连接并显示权限模式、模型和推理等级,但发送提示词后会永久静默、没有任何响应。期望 ACP session 正常启动并返回 agent 输出。 + +## 症状详情 + +- ACP 握手和 session 配置展示正常。 +- 任意 ACP 客户端发送提示词后均无输出,session 一直处于等待状态。 +- 现场定位显示 peri 正在等待 `GitAttributionMiddleware::current_branch()` 启动的 `git rev-parse --abbrev-ref HEAD` 子进程结束。 +- 同一环境中,peri TUI 和其他依赖 Git 的应用未出现 Git 卡死。 +- 用户提供了卡死界面截图: + +## 复现条件 + +- **复现频率**:必现 +- **触发步骤**: + 1. 使用任意 ACP 客户端连接 `peri acp`。 + 2. 向已建立的 session 发送提示词。 + 3. 观察 session 静默等待且无 agent 输出。 +- **环境**:Windows 10;Rust 1.95.0;Peri 3.9.6;Git 2.52.0.windows.1(Scoop 管理) + +## 涉及文件 + +- `peri-middlewares/src/attribution/mod.rs` —— `GitAttributionMiddleware::current_branch()` 启动并等待 Git 子进程。 +- `peri-middlewares/src/attribution/mod_test.rs` —— 覆盖正常分支输出与超时后直接子进程终止请求。 +- `docs/code-index/peri-middlewares.md` —— 记录 attribution 的有界 best-effort Git 探测语义。 + +## 根因 + +已确认的缺陷是 `GitAttributionMiddleware::before_agent()` 在模型执行前同步等待一个可选的 Git 分支探测,且该等待没有 deadline;只要直接子进程不退出,整个 agent 阶段就会永久阻塞。原实现继承 stdin,且 future 被取消时没有请求终止直接子进程,这两点是进程隔离与生命周期上的薄弱处,但尚无证据证明它们是 Scoop Git 只在独立 ACP 模式卡住的内部原因。 + +本次修复将异步等待限制为一秒,并将该命令保持为失败不影响 turn 的 best-effort 观测。这个预算不包含同步 spawn/调度开销;`kill_on_drop` 请求终止直接子进程,但不承诺同步 reap 或清理任意后代进程。 + +## 状态变更记录 + +| 日期 | 从 | 到 | 操作人 | 说明 | +|------|-----|-----|--------|------| +| 2026-08-29 | — | Open | agent | 创建 | +| 2026-08-29 | Open | Fixed | agent | 修复:限制 Git 分支探测等待并隔离 stdin,超时时请求终止直接子进程;待 Windows/Scoop 实机验证 | + +## 修复记录 + +### 修复 #1(2026-08-29) + +- **操作人**:agent +- **用户原意**:`peri acp` 建立 session 后应能处理提示词并返回 agent 输出,不能被 attribution 的 Git 子进程静默卡死。 +- **修复内容**:为 Git 分支探测增加私有的一秒异步等待预算;子进程使用 null stdin、piped stdout/stderr 与 `kill_on_drop(true)`;保留正常输出的 UTF-8 校验与 trim 语义;新增跨平台正常输出测试和 READY/RELEASE/SENTINEL 真实子进程生命周期回归;同步更新 middleware code index。 +- **涉及 commit**:见包含本修复的提交(用户随后明确要求提交并推送) +- **验证状态**:待验证。两个精确回归各 1 条通过;attribution suite 27 passed / 1 intentional ignored;`cargo check`、`cargo build`、crate/workspace clippy、fmt 与 `git diff --check` 通过。完整 `peri-middlewares --lib` 在 sandbox 内外分别有 24/21 个非 attribution 既有或环境敏感失败,故该门禁记为 blocked。Windows 10 / Scoop Git 的真实 ACP prompt、`where git` shim 解析及残留后代进程检查尚未执行,不能标记 Verified。