08 - ACP 协议与 JSON-RPC 声明式路由
学习目标
- 理解 ACP(Agent Client Protocol)是什么,以及它与 MCP 协议的差异
- 掌握 Goose 的两种 ACP 传输模式:HTTP/WebSocket 与 Stdio
- 理解
#[custom_methods]过程宏如何生成handle_custom_request分发器和custom_method_schemasJSON Schema 生成器 - 掌握方法名匹配机制
<RequestType>::matches_method的工作方式 - 识别方法名冲突、类型不匹配、未知方法处理等常见陷阱
- 评估过程宏路由 vs 手动匹配、ACP JSON-RPC vs REST API 等设计取舍
前置知识
- JSON-RPC 2.0 协议(method、params、id、result、error)
- Rust 过程宏(proc_macro)基础
- Axum HTTP 框架与 WebSocket 升级
- 参考:
/common/api-gateway/04-acp-jsonrpc-routing(ACP 路由专题) - 参考:
/common/mcp/01-overview(MCP 协议与生态集成)
项目实践
ACP 协议概述
ACP(Agent Client Protocol)是 Goose 内部使用的 JSON-RPC 2.0 协议,用于客户端(Goose CLI 或 Electron UI)与 Agent 后端(goosed 服务器)之间的通信。ACP 在 MCP 协议之上扩展了会话管理、配置管理、工具调用、资源管理等 Agent 专属能力。
ACP 与 MCP 的核心差异:
| 特性 | MCP | ACP |
|---|---|---|
| 定位 | 模型-工具协议,标准化 AI 模型与外部工具的交互 | Agent-客户端协议,标准化客户端与 Agent 后端的交互 |
| 传输 | Stdio / HTTP / SSE | HTTP + WebSocket(goose serve)/ Stdio(goose acp) |
| 核心方法 | tools/call、resources/read、prompts/get | session/prompt、session/load、session/new + 扩展方法 |
| 扩展机制 | 工具/资源/提示注册 | JSON-RPC custom_methods + #[custom_methods] 过程宏 |
| 会话模型 | 无状态(工具调用) | 有状态(session 生命周期:新建、加载、关闭、分叉) |
两种传输模式
HTTP/WebSocket 模式(goose serve)
通过 goose serve 命令启动后端服务,在 axum Router 上挂载 /acp 路由:
GET /acp → WebSocket 升级 或 HTTP 事件流POST /acp → JSON-RPC 请求处理DELETE /acp → 连接清理GET /health, /status → 健康检查传输层关键细节:
- WebSocket 连接通过
Acp-Connection-Id和Acp-Session-Id头部标识 - HTTP POST 请求以
application/json提交 JSON-RPC 请求 - 支持 SSE 事件流(
text/event-stream)用于服务端推送 - CORS 层暴露
Acp-Connection-Id和Acp-Session-Id头部
Stdio 模式(goose acp)
通过 goose acp 命令以 Stdio 传输启动,进程的标准输入读取 JSON-RPC 请求,标准输出写入响应。适用于嵌入式场景(如被其他 IDE 或编辑器进程启动)。
#[custom_methods] 过程宏
Goose 的 ACP 扩展方法通过 goose-acp-macros crate 中的 #[custom_methods] 过程宏实现声明式路由。
使用方式
在 custom_dispatch.rs 中,#[custom_methods] 标记一个 impl 块,块内的方法通过 #[custom_method(RequestType)] 注解声明为 JSON-RPC 方法处理器:
#[custom_methods]impl GooseAcpAgent { #[custom_method(AddExtensionRequest)] async fn dispatch_add_extension( &self, req: AddExtensionRequest, ) -> Result<AddExtensionResponse, agent_client_protocol::Error> { self.on_add_extension(req).await }
#[custom_method(GetExtensionsRequest)] async fn dispatch_get_extensions( &self, ) -> Result<GetExtensionsResponse, agent_client_protocol::Error> { self.on_get_extensions().await }
// ... 更多方法}生成的代码
过程宏在编译期生成两个方法,追加到 impl 块中:
1. handle_custom_request 分发器
async fn handle_custom_request( &self, method: &str, params: serde_json::Value,) -> Result<serde_json::Value, agent_client_protocol::Error> { // 为每个 #[custom_method(RequestType)] 生成一个 if 分支 if <AddExtensionRequest as JsonRpcMessage>::matches_method(method) { let req = serde_json::from_value(params) .map_err(|e| Error::invalid_params().data(e.to_string()))?; let result = self.dispatch_add_extension(req).await?; return serde_json::to_value(&result) .map_err(|e| Error::internal_error().data(e.to_string())); }
if <GetExtensionsRequest as JsonRpcMessage>::matches_method(method) { let result = self.dispatch_get_extensions().await?; return serde_json::to_value(&result) .map_err(|e| Error::internal_error().data(e.to_string())); }
// 所有方法都不匹配时 Err(Error::method_not_found())}2. custom_method_schemas Schema 生成器
pub fn custom_method_schemas( generator: &mut schemars::SchemaGenerator,) -> Vec<CustomMethodSchema> { vec![ CustomMethodSchema { method: <AddExtensionRequest as JsonRpcMessage>::method(&Default::default()).to_string(), params_schema: Some(generator.subschema_for::<AddExtensionRequest>()), params_type_name: Some("AddExtensionRequest".to_string()), response_schema: Some(generator.subschema_for::<AddExtensionResponse>()), response_type_name: Some("AddExtensionResponse".to_string()), }, // ... 每个 custom_method 对应一个 entry ]}方法名匹配机制
每个请求类型实现 agent_client_protocol::JsonRpcMessage trait,其中 matches_method(&self, method: &str) -> bool 用于运行时方法名匹配。
请求类型通过 derive 宏获得 #[request(method = "goose/extensions/add")] 属性,方法名在编译期从类型属性中提取,消除了请求结构体与处理器之间的方法名重复。
方法分类
Goose 的自定义方法涵盖多个功能域:
| 功能域 | 方法示例 | 说明 |
|---|---|---|
| 扩展管理 | goose/extensions/add | 动态添加/删除/启用 MCP 扩展 |
| 工具调用 | goose/tools/call | 直接调用 Agent 工具 |
| 资源读取 | goose/resources/read | 读取扩展提供的资源 |
| 会话管理 | goose/sessions/delete | 删除/重命名/归档/分叉会话 |
| 配置管理 | goose/config/extensions/* | 持久化扩展配置开关 |
| Provider 管理 | goose/providers/* | 列举/创建/更新/删除 LLM Provider |
| 偏好设置 | goose/preferences/* | 读写用户偏好 |
| 默认配置 | goose/defaults/* | 读写默认配置 |
| 导入导出 | goose/sessions/import | 会话/源的导入导出 |
| 听写 | goose/dictation/* | 语音听写模型管理 |
请求分发管线
完整的请求从客户端到处理器的流:
关键流转步骤:
- Transport 层接收原始 JSON,解析为 JSON-RPC 格式(区分 request / notification / response)
- MatchDispatchFrom 链式匹配标准 ACP 方法(initialize、new_session、prompt 等)
- 标准方法未匹配时进入
.otherwise()分支,调用dispatch_custom_request - dispatch_custom_request 透传 method 字符串和 params JSON Value
- handle_custom_request(过程宏生成)遍历所有
matches_method分支 - 匹配到后反序列化 params,调用对应 handler,序列化返回值
- 均未匹配时返回
Error::method_not_found()
问题与规避
方法名冲突
如果两个不同的 RequestType 注册了相同的 method 字符串,先注册的会优先匹配,后注册的永远不会被调用。
规避措施:
- 方法名在
#[request(method = "...")]属性中编译期定义,而非运行时注册 - 代码审查时注意检查方法名唯一性
custom_method_schemas生成的 Schema 列表可用于运行时检测方法名冲突
类型不匹配
Handler 参数的类型必须与客户端发送的 params JSON 结构兼容。如果 serde_json::from_value 反序列化失败,会返回 Error::invalid_params()。
规避措施:
- 请求类型必须实现
Deserialize(通过 serde derive) #[request(method = "...")]属性确保类型同时实现JsonRpcRequest/JsonRpcMessage- 反序列化错误信息包含具体原因,通过
Error.data()传递给客户端
未知方法处理
当 handle_custom_request 中所有 matches_method 分支均不匹配时,返回 Error::method_not_found()。这是 JSON-RPC 2.0 标准错误码。
规避措施:
- 客户端应预先通过
custom_method_schemas获取可用方法列表 - 服务端返回
method_not_found时客户端应记录警告日志
过程宏编译错误
#[custom_methods] 对 handler 签名有严格要求:
- 返回类型必须是
Result<T, agent_client_protocol::Error> - 参数除了
&self外最多一个(自动从 JSON params 反序列化) #[custom_method(RequestType)]中的RequestType必须实现JsonRpcMessage
规避措施:编译期过程宏会尽早暴露签名不匹配问题,不会出现运行时 panic。
设计取舍
过程宏路由 vs 手动 match
| 方案 | 优势 | 劣势 |
|---|---|---|
| 过程宏路由(Goose 选择) | 声明式:方法名从类型属性提取,无重复 编译期检查:签名不匹配在编译时报错 自动生成 JSON Schema | 宏代码调试困难;错误信息可能不够直观 |
| 手动 match | 代码透明,易于调试 | 方法名在类型定义和 match 分支两处重复,容易不一致;需要手动维护 Schema |
Goose 选择过程宏路由,核心动机是消除方法名重复。在拥有 30+ 自定义方法的项目中,手动维护 method 字符串与 handler 的对应关系极易出错。
ACP JSON-RPC vs REST API
| 方案 | 优势 | 劣势 |
|---|---|---|
| JSON-RPC(Goose 选择) | 统一端点(/acp),方法名自描述 原生支持通知(notification)和批量请求 与 MCP 生态兼容(同为 JSON-RPC 家族) | 不支持 REST 风格的资源路径语义 |
| REST API | 资源语义清晰,HTTP 方法映射自然 | 需要多个路由;Agent 操作(prompt、cancel)不是纯 CRUD,REST 语义牵强 |
ACP 选择 JSON-RPC 是因为 Agent 操作本质上是远程过程调用而非资源 CRUD。session/prompt(发送提示等待回复)、session/cancel(取消当前任务)等操作天然适合 RPC 语义。同时与 MCP 协议保持一致,降低学习成本。
链式分发 vs 集中式路由表
Goose 使用 MatchDispatchFrom 链式匹配(.if_request(...).await.if_request(...).await.otherwise(...)),而非集中式路由表。
优势:
- 每个处理器可以决定是否响应、是否
spawn异步任务 - 部分处理器(如
InitializeRequest)内联运行以确保状态初始化完成 - 部分处理器(如
PromptRequest)spawn 后台任务后立即返回,不阻塞后续请求
取舍:链式分发在方法数量较少时清晰高效,但方法超过一定数量(当前 30+)后线性扫描可能成为瓶颈。未来可考虑引入哈希表路由。
参考来源
crates/goose-acp-macros/src/lib.rs—#[custom_methods]过程宏实现crates/goose/src/acp/server/dispatch.rs— MatchDispatchFrom 请求分发链crates/goose/src/acp/server/custom_dispatch.rs— custom_methods 使用示例,30+ 自定义方法路由crates/goose/src/acp/transport/mod.rs— HTTP/WebSocket 传输层路由/common/api-gateway/04-acp-jsonrpc-routing— ACP 路由专题文档/common/mcp/01-overview— MCP 协议与生态集成