Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/code-index/peri-middlewares.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + <system-reminder>` 经 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 工厂 |

## 子系统(按目录)
Expand Down
178 changes: 89 additions & 89 deletions docs/design/mcp-connector-guide-v2.md

Large diffs are not rendered by default.

299 changes: 213 additions & 86 deletions docs/design/mcp-multiplexing.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions peri-acp-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
256 changes: 256 additions & 0 deletions peri-acp-types/src/mcp_apps.rs
Original file line number Diff line number Diff line change
@@ -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<RawResource>,
}

#[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<String, Value>,
}

#[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<Value>,
}

#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
#[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}

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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<Value>,
#[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_error: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}

#[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<String, McpAppsRelayError>;

async fn read_resource(
&self,
binding: &AppSessionBinding,
) -> Result<(String, Vec<RawResource>), McpAppsRelayError>;

async fn call_tool(
&self,
binding: &AppSessionBinding,
request: JsonRpcRequest,
) -> Result<(String, JsonRpcResponse), McpAppsRelayError>;
}

#[cfg(test)]
#[path = "mcp_apps_test.rs"]
mod tests;
77 changes: 77 additions & 0 deletions peri-acp-types/src/mcp_apps_test.rs
Original file line number Diff line number Diff line change
@@ -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::<McpAppRequest>(value).is_err());
}

#[test]
fn raw_result_roundtrip_preserves_unknown_fields() {
let value = json!({
"content": [{"type":"resource", "resource":{"uri":"ui://app", "text":"<html/>"}}],
"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::<JsonRpcResponse>(json!({
"jsonrpc": "2.0",
"id": 1,
"result": {},
"error": {"code": -1, "message": "bad"}
}))
.is_err());
assert!(serde_json::from_value::<JsonRpcResponse>(json!({
"jsonrpc": "2.0",
"id": 1
}))
.is_err());
}

#[test]
fn call_tool_params_require_name_and_object_arguments() {
assert!(serde_json::from_value::<JsonRpcRequest>(json!({
"jsonrpc": "2.0",
"id": "request",
"method": "tools/call",
"params": {"arguments": {}}
}))
.is_err());
assert!(serde_json::from_value::<JsonRpcRequest>(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("<html/>".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"));
}
Loading