feat(acp): Honor MCP servers from clients like Zed (stdio + http) (#6230)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2025-12-24 05:49:10 +08:00
committed by GitHub
parent 4e33e5a934
commit 23f33e3fb2
15 changed files with 1191 additions and 678 deletions
Generated
+148 -179
View File
@@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.0"
@@ -28,35 +19,17 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "agent-client-protocol"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
"async-broadcast",
"async-trait",
"derive_more",
"futures",
"log",
"serde",
"serde_json",
]
[[package]]
name = "agent-client-protocol-schema"
version = "0.10.5"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4"
checksum = "16d08d095e8069115774caa50392e9c818e3fb1c482ef4f3153d26b4595482f2"
dependencies = [
"anyhow",
"derive_more",
"schemars",
"serde",
"serde_json",
"strum",
]
[[package]]
@@ -252,18 +225,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.20"
@@ -914,21 +875,6 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "backtrace"
version = "0.3.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
]
[[package]]
name = "base64"
version = "0.13.1"
@@ -1274,6 +1220,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32"
[[package]]
name = "boxfnonce"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5988cb1d626264ac94100be357308f29ff7cbdd3b36bda27f450a4ee3f713426"
[[package]]
name = "brotli"
version = "7.0.0"
@@ -2573,16 +2525,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.73.0"
@@ -2954,6 +2896,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generator"
version = "0.8.7"
@@ -2965,7 +2916,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows 0.59.0",
"windows 0.61.3",
]
[[package]]
@@ -3013,12 +2964,6 @@ dependencies = [
"weezl",
]
[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "git-version"
version = "0.3.9"
@@ -3075,7 +3020,6 @@ dependencies = [
name = "goose"
version = "1.17.0"
dependencies = [
"agent-client-protocol",
"ahash",
"anyhow",
"async-stream",
@@ -3124,7 +3068,8 @@ dependencies = [
"rand 0.8.5",
"regex",
"reqwest 0.12.12",
"rmcp 0.9.1",
"rmcp",
"sacp",
"schemars",
"serde",
"serde_json",
@@ -3175,7 +3120,7 @@ dependencies = [
"once_cell",
"paste",
"regex",
"rmcp 0.9.1",
"rmcp",
"serde",
"serde_json",
"tokio",
@@ -3188,7 +3133,6 @@ dependencies = [
name = "goose-cli"
version = "1.17.0"
dependencies = [
"agent-client-protocol",
"anstream",
"anyhow",
"async-trait",
@@ -3214,8 +3158,9 @@ dependencies = [
"open",
"rand 0.8.5",
"regex",
"rmcp 0.9.1",
"rmcp",
"rustyline",
"sacp",
"serde",
"serde_json",
"serde_yaml",
@@ -3269,7 +3214,7 @@ dependencies = [
"rayon",
"regex",
"reqwest 0.11.27",
"rmcp 0.8.5",
"rmcp",
"schemars",
"serde",
"serde_json",
@@ -3324,7 +3269,7 @@ dependencies = [
"http 1.2.0",
"rand 0.9.2",
"reqwest 0.12.12",
"rmcp 0.9.1",
"rmcp",
"rustls 0.23.31",
"schemars",
"serde",
@@ -4362,6 +4307,16 @@ dependencies = [
"serde",
]
[[package]]
name = "jsonrpcmsg"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.30.0"
@@ -5119,15 +5074,6 @@ dependencies = [
"objc2",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -5774,16 +5720,16 @@ dependencies = [
[[package]]
name = "process-wrap"
version = "8.2.0"
version = "8.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35f4dc9988d1326b065b4def5e950c3ed727aa03e3151b86cc9e2aec6b03f54"
checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1"
dependencies = [
"futures",
"indexmap 2.12.0",
"nix 0.29.0",
"nix 0.30.1",
"tokio",
"tracing",
"windows 0.59.0",
"windows 0.61.3",
]
[[package]]
@@ -6321,28 +6267,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5947688160b56fb6c827e3c20a72c90392a1d7e9dec74749197aa1780ac42ca"
dependencies = [
"base64 0.22.1",
"chrono",
"futures",
"paste",
"pin-project-lite",
"rmcp-macros 0.8.5",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp"
version = "0.9.1"
@@ -6351,15 +6275,19 @@ checksum = "eaa07b85b779d1e1df52dd79f6c6bffbe005b191f07290136cc42a142da3409a"
dependencies = [
"async-trait",
"base64 0.22.1",
"bytes",
"chrono",
"futures",
"http 1.2.0",
"http-body 1.0.1",
"http-body-util",
"oauth2",
"paste",
"pin-project-lite",
"process-wrap",
"rand 0.9.2",
"reqwest 0.12.12",
"rmcp-macros 0.9.1",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
@@ -6368,21 +6296,10 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"url",
]
[[package]]
name = "rmcp-macros"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01263441d3f8635c628e33856c468b96ebbce1af2d3699ea712ca71432d4ee7a"
dependencies = [
"darling 0.21.0",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.111",
"uuid",
]
[[package]]
@@ -6442,12 +6359,6 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6646,6 +6557,42 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15"
[[package]]
name = "sacp"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7679525f5c2f4e1cb4b07e3684c4b050b34bafa0f908852879b7411522ff6626"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
"boxfnonce",
"futures",
"futures-concurrency",
"fxhash",
"jsonrpcmsg",
"rmcp",
"sacp-derive",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "sacp-derive"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a36c27381224c2fc970935c887b26f48ff78105003d9abfa04c2e808569d3b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -7425,27 +7372,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -7921,20 +7847,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.43.1"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.8",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7954,9 +7879,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -8662,12 +8587,14 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.15.1"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587"
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.1",
"serde",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
@@ -9009,12 +8936,24 @@ dependencies = [
[[package]]
name = "windows"
version = "0.59.0"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-core 0.59.0",
"windows-targets 0.53.3",
"windows-collections",
"windows-core 0.61.2",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
]
[[package]]
@@ -9065,15 +9004,26 @@ dependencies = [
[[package]]
name = "windows-core"
version = "0.59.0"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement 0.59.0",
"windows-implement 0.60.2",
"windows-interface 0.59.1",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.3.1",
"windows-targets 0.53.3",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
]
[[package]]
@@ -9111,9 +9061,9 @@ dependencies = [
[[package]]
name = "windows-implement"
version = "0.59.0"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
@@ -9176,6 +9126,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.2.0"
@@ -9226,9 +9186,9 @@ dependencies = [
[[package]]
name = "windows-strings"
version = "0.3.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
@@ -9350,6 +9310,15 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
+1 -1
View File
@@ -19,7 +19,7 @@ goose = { path = "../goose" }
goose-bench = { path = "../goose-bench" }
goose-mcp = { path = "../goose-mcp" }
rmcp = { workspace = true }
agent-client-protocol = "0.9.0"
sacp = "9.0.0"
clap = { version = "4.4", features = ["derive"] }
cliclack = "0.3.5"
console = "0.16.1"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@ description.workspace = true
workspace = true
[dependencies]
rmcp = { version = "0.8.1", features = ["server", "client", "transport-io", "macros"] }
rmcp = { workspace = true, features = ["server", "client", "transport-io", "macros"] }
anyhow = "1.0.94"
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["io-util"] }
+2 -1
View File
@@ -121,7 +121,7 @@ unbinder = "0.1.7"
winapi = { version = "0.3", features = ["wincred"] }
[dev-dependencies]
agent-client-protocol = "0.9.2"
sacp = "9.0.0"
criterion = "0.5"
serial_test = "3.2.0"
mockall = "0.13.1"
@@ -133,6 +133,7 @@ dotenvy = "0.15.7"
ctor = "0.2.9"
test-case = "3.3"
env-lock = "1.0.1"
rmcp = { workspace = true, features = ["transport-streamable-http-server"] }
[[example]]
name = "agent"
+2 -2
View File
@@ -140,7 +140,7 @@ pub enum ExtensionError {
pub type ExtensionResult<T> = Result<T, ExtensionError>;
#[derive(Debug, Clone, Deserialize, Serialize, Default, ToSchema)]
#[derive(Debug, Clone, Deserialize, Serialize, Default, ToSchema, PartialEq)]
pub struct Envs {
/// A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host
#[serde(default)]
@@ -230,7 +230,7 @@ impl Envs {
}
/// Represents the different types of MCP extensions that can be added to the manager
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)]
#[serde(tag = "type")]
pub enum ExtensionConfig {
/// Server-sent events client with a URI endpoint
@@ -15,7 +15,7 @@ pub enum PrincipalType {
Tool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct PermissionConfirmation {
pub principal_type: PrincipalType,
pub permission: Permission,
+243 -83
View File
@@ -1,44 +1,102 @@
use agent_client_protocol::{
self as acp, Agent, Client, ClientSideConnection, ContentBlock, InitializeRequest,
NewSessionRequest, PromptRequest, ProtocolVersion, SessionNotification, SessionUpdate,
TextContent,
mod common;
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};
use std::path::Path;
use rmcp::{
handler::server::router::tool::ToolRouter, model::*, tool, tool_handler, tool_router,
ErrorData as McpError, ServerHandler,
};
use sacp::schema::{
ContentBlock, ContentChunk, InitializeRequest, McpServer, NewSessionRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionNotification, SessionUpdate, StopReason, TextContent, VERSION as PROTOCOL_VERSION,
};
use sacp::{ClientToAgent, JrConnectionCx};
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::task::JoinHandle;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const BASIC_RESPONSE: &str = include_str!("./test_data/openai_chat_completion_streaming.txt");
const BASIC_TEXT: &str = "Hello! How can I assist you today? 🌍";
/// Fake code returned by the MCP server - an LLM couldn't know this from memory
const FAKE_CODE: &str = "test-uuid-12345-67890";
#[tokio::test]
async fn test_acp_basic_completion() {
let mock_server = setup_mock_openai(BASIC_RESPONSE).await;
let work_dir = tempfile::tempdir().unwrap();
let prompt = "what is 1+1";
let mock_server = setup_mock_openai(vec![(
format!(r#"</info-msg>\n{prompt}","role":"user""#),
include_str!("./test_data/openai_basic_response.txt"),
)])
.await;
let (client, updates) = TestClient::new();
let child = spawn_goose_acp(&mock_server).await;
run_acp_session(&mock_server, vec![], |cx, session_id, updates| async move {
let response = cx
.send_request(PromptRequest {
session_id,
prompt: vec![ContentBlock::Text(TextContent {
text: prompt.to_string(),
annotations: None,
meta: None,
})],
meta: None,
})
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
wait_for_text(&updates, "2", Duration::from_secs(5)).await;
})
.await;
}
#[tokio::test]
async fn test_acp_with_mcp_http_server() {
let prompt = "Use the get_code tool and output only its result.";
let (mcp_url, _handle) = spawn_mcp_http_server().await;
let mock_server = setup_mock_openai(vec![
(
format!(r#"</info-msg>\n{prompt}","role":"user""#),
include_str!("./test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}","role":"tool""#),
include_str!("./test_data/openai_tool_result_response.txt"),
),
])
.await;
run_acp_session(
client,
child,
work_dir.path(),
|conn, session_id| async move {
let response = conn
.prompt(PromptRequest::new(
&mock_server,
vec![McpServer::Http {
name: "lookup".into(),
url: mcp_url,
headers: vec![],
}],
|cx, session_id, updates| async move {
let response = cx
.send_request(PromptRequest {
session_id,
vec![ContentBlock::Text(TextContent::new("test message"))],
))
prompt: vec![ContentBlock::Text(TextContent {
text: prompt.to_string(),
annotations: None,
meta: None,
})],
meta: None,
})
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, acp::StopReason::EndTurn);
wait_for_text(&updates, BASIC_TEXT, Duration::from_secs(5)).await;
assert_eq!(response.stop_reason, StopReason::EndTurn);
wait_for_text(&updates, FAKE_CODE, Duration::from_secs(5)).await;
},
)
.await;
@@ -63,16 +121,50 @@ async fn wait_for_text(
}
}
async fn setup_mock_openai(streaming_response: &str) -> MockServer {
/// Each entry is (expected_body_substring, response_body).
/// Session description requests are handled automatically.
async fn setup_mock_openai(exchanges: Vec<(String, &'static str)>) -> MockServer {
let mock_server = MockServer::start().await;
let queue: VecDeque<(String, &'static str)> = exchanges.into_iter().collect();
let queue = Arc::new(Mutex::new(queue));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(streaming_response),
)
.respond_with({
let queue = queue.clone();
move |req: &wiremock::Request| {
let body = String::from_utf8_lossy(&req.body);
if body.contains("Reply with only a description in four words or less") {
return ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_string(include_str!(
"./test_data/openai_session_description.json"
));
}
let (expected, response) = {
let mut q = queue.lock().unwrap();
match q.pop_front() {
Some(item) => item,
None => {
return ResponseTemplate::new(500)
.set_body_string(format!("unexpected request: {body}"));
}
}
};
if !body.contains(&expected) {
return ResponseTemplate::new(500).set_body_string(format!(
"expected body to contain: {expected}\nactual: {body}"
));
}
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(response)
}
})
.mount(&mock_server)
.await;
@@ -83,88 +175,156 @@ fn extract_text(updates: &[SessionNotification]) -> String {
updates
.iter()
.filter_map(|n| match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
},
SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(t),
..
}) => Some(t.text.clone()),
_ => None,
})
.collect()
}
struct TestClient {
updates: Arc<Mutex<Vec<SessionNotification>>>,
}
impl TestClient {
fn new() -> (Self, Arc<Mutex<Vec<SessionNotification>>>) {
let updates = Arc::new(Mutex::new(Vec::new()));
(
Self {
updates: updates.clone(),
},
updates,
)
}
}
#[async_trait::async_trait(?Send)]
impl Client for TestClient {
async fn request_permission(
&self,
_args: acp::RequestPermissionRequest,
) -> acp::Result<acp::RequestPermissionResponse> {
Err(acp::Error::method_not_found())
}
async fn session_notification(&self, args: SessionNotification) -> acp::Result<()> {
self.updates.lock().unwrap().push(args);
Ok(())
}
}
async fn spawn_goose_acp(mock_server: &MockServer) -> Child {
Command::new("cargo")
.args(["run", "-p", "goose-cli", "--", "acp"])
Command::new(&*common::GOOSE_BINARY)
.args(["acp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("GOOSE_PROVIDER", "openai")
.env("GOOSE_MODEL", "gpt-5-nano")
.env("GOOSE_MODE", "approve")
.env("OPENAI_HOST", mock_server.uri())
.env("OPENAI_API_KEY", "test-key")
.env(
"RUST_LOG",
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
)
.kill_on_drop(true)
.spawn()
.unwrap()
}
async fn run_acp_session<F, Fut>(client: TestClient, mut child: Child, work_dir: &Path, test_fn: F)
async fn run_acp_session<F, Fut>(mock_server: &MockServer, mcp_servers: Vec<McpServer>, test_fn: F)
where
F: FnOnce(ClientSideConnection, acp::SessionId) -> Fut,
F: FnOnce(
JrConnectionCx<ClientToAgent>,
sacp::schema::SessionId,
Arc<Mutex<Vec<SessionNotification>>>,
) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let mut child = spawn_goose_acp(mock_server).await;
let work_dir = tempfile::tempdir().unwrap();
let updates = Arc::new(Mutex::new(Vec::new()));
let outgoing = child.stdin.take().unwrap().compat_write();
let incoming = child.stdout.take().unwrap().compat();
let work_dir = work_dir.to_path_buf();
let local_set = tokio::task::LocalSet::new();
local_set
.run_until(async move {
let (conn, handle_io) = ClientSideConnection::new(client, outgoing, incoming, |fut| {
tokio::task::spawn_local(fut);
});
tokio::task::spawn_local(handle_io);
let transport = sacp::ByteStreams::new(outgoing, incoming);
conn.initialize(InitializeRequest::new(ProtocolVersion::V1))
ClientToAgent::builder()
.on_receive_notification(
{
let updates = updates.clone();
async move |notification: SessionNotification, _cx| {
updates.lock().unwrap().push(notification);
Ok(())
}
},
sacp::on_receive_notification!(),
)
.on_receive_request(
async move |request: RequestPermissionRequest, request_cx, _connection_cx| {
let option_id = request.options.first().map(|opt| opt.id.clone());
match option_id {
Some(id) => request_cx.respond(RequestPermissionResponse {
outcome: RequestPermissionOutcome::Selected { option_id: id },
meta: None,
}),
None => request_cx.respond(RequestPermissionResponse {
outcome: RequestPermissionOutcome::Cancelled,
meta: None,
}),
}
},
sacp::on_receive_request!(),
)
.with_client(
transport,
move |cx: JrConnectionCx<ClientToAgent>| async move {
cx.send_request(InitializeRequest {
protocol_version: PROTOCOL_VERSION,
client_capabilities: Default::default(),
client_info: Default::default(),
meta: None,
})
.block_task()
.await
.unwrap();
let session = conn
.new_session(NewSessionRequest::new(&work_dir))
.await
.unwrap();
let session = cx
.send_request(NewSessionRequest {
mcp_servers,
cwd: work_dir.path().to_path_buf(),
meta: None,
})
.block_task()
.await
.unwrap();
test_fn(conn, session.session_id).await;
})
.await;
test_fn(cx.clone(), session.session_id, updates).await;
Ok(())
},
)
.await
.unwrap();
}
#[derive(Clone)]
struct Lookup {
tool_router: ToolRouter<Lookup>,
}
#[tool_router]
impl Lookup {
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
/// Returns a fake code that an LLM couldn't know from memory
#[tool(description = "Get the code")]
fn get_code(&self) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(FAKE_CODE)]))
}
}
#[tool_handler]
impl ServerHandler for Lookup {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation::from_build_env(),
instructions: Some("Lookup server with get_code tool.".into()),
}
}
}
async fn spawn_mcp_http_server() -> (String, JoinHandle<()>) {
let service = StreamableHttpService::new(
|| Ok(Lookup::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{addr}/mcp");
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
(url, handle)
}
+43
View File
@@ -0,0 +1,43 @@
use std::path::PathBuf;
use std::process::Command;
use std::sync::LazyLock;
/// Build a binary from a package and return its path.
pub fn build_binary(package: &str, bin_name: &str) -> PathBuf {
let output = Command::new("cargo")
.args([
"build",
"-p",
package,
"--bin",
bin_name,
"--message-format=json",
])
.output()
.expect("failed to build binary");
if !output.status.success() {
panic!("build failed: {}", String::from_utf8_lossy(&output.stderr));
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|msg| msg["reason"] == "compiler-artifact")
.filter(|msg| msg["target"]["name"] == bin_name)
.filter(|msg| {
msg["target"]["kind"]
.as_array()
.map(|k| k.iter().any(|v| v == "bin"))
.unwrap_or(false)
})
.filter_map(|msg| msg["executable"].as_str().map(PathBuf::from))
.next()
.expect("failed to find binary path in cargo output")
}
#[allow(dead_code)]
pub static GOOSE_BINARY: LazyLock<PathBuf> = LazyLock::new(|| build_binary("goose-cli", "goose"));
#[allow(dead_code)]
pub static CAPTURE_BINARY: LazyLock<PathBuf> =
LazyLock::new(|| build_binary("goose-test", "capture"));
+2 -55
View File
@@ -1,4 +1,4 @@
use serde::Deserialize;
mod common;
use std::collections::HashMap;
use std::fs::File;
@@ -20,21 +20,6 @@ use async_trait::async_trait;
use goose::conversation::message::Message;
use goose::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use goose::providers::errors::ProviderError;
use once_cell::sync::Lazy;
use std::process::Command;
#[derive(Deserialize)]
struct CargoBuildMessage {
reason: String,
target: Target,
executable: String,
}
#[derive(Deserialize)]
struct Target {
name: String,
kind: Vec<String>,
}
#[derive(Clone)]
pub struct MockProvider {
@@ -75,44 +60,6 @@ impl Provider for MockProvider {
}
}
fn build_and_get_binary_path() -> PathBuf {
let output = Command::new("cargo")
.args([
"build",
"--frozen",
"-p",
"goose-test",
"--bin",
"capture",
"--message-format=json",
])
.output()
.expect("failed to build binary");
if !output.status.success() {
panic!("build failed: {}", String::from_utf8_lossy(&output.stderr));
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(serde_json::from_str::<CargoBuildMessage>)
.filter_map(Result::ok)
.filter(|message| message.reason == "compiler-artifact")
.filter_map(|message| {
if message.target.name == "capture"
&& message.target.kind.contains(&String::from("bin"))
{
Some(PathBuf::from(message.executable))
} else {
None
}
})
.next()
.expect("failed to parse binary path")
}
static REPLAY_BINARY_PATH: Lazy<PathBuf> = Lazy::new(build_and_get_binary_path);
enum TestMode {
Record,
Playback,
@@ -214,7 +161,7 @@ async fn test_replayed_session(
TestMode::Record => "record",
TestMode::Playback => "playback",
};
let cmd = REPLAY_BINARY_PATH.to_string_lossy().to_string();
let cmd = common::CAPTURE_BINARY.to_string_lossy().to_string();
let mut args = vec!["stdio", mode_arg]
.into_iter()
.map(str::to_string)
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"2"},"finish_reason":null}]}
data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}}
data: [DONE]
@@ -1,27 +0,0 @@
data: {"choices":[],"created":0,"id":"","prompt_filter_results":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"prompt_index":0}]}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"","role":"assistant"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Hello"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"!"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" How"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" can"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" I"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" assist"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" you"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" today"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"?"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" 🌍"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: {"choices":[{"finish_reason":"stop","index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":null}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","usage":{"completion_tokens":13,"completion_tokens_details":{"accepted_prediction_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":1675,"prompt_tokens_details":{"cached_tokens":1536},"total_tokens":1688},"model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"}
data: [DONE]
@@ -0,0 +1 @@
{"id":"chatcmpl-test","object":"chat.completion","created":1766229622,"model":"gpt-5-nano","choices":[{"index":0,"message":{"role":"assistant","content":"Test session"},"finish_reason":"stop"}],"usage":{"prompt_tokens":79,"completion_tokens":10,"total_tokens":89}}
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-test1","object":"chat.completion.chunk","created":1766309803,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"lookup__get_code","arguments":""}}],"refusal":null},"finish_reason":null}]}
data: {"id":"chatcmpl-test1","object":"chat.completion.chunk","created":1766309803,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-test1","object":"chat.completion.chunk","created":1766309803,"model":"gpt-5-nano","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
data: {"id":"chatcmpl-test1","object":"chat.completion.chunk","created":1766309803,"model":"gpt-5-nano","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}
data: [DONE]
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-test2","object":"chat.completion.chunk","created":1766309809,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-test2","object":"chat.completion.chunk","created":1766309809,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"test-uuid-12345-67890"},"finish_reason":null}]}
data: {"id":"chatcmpl-test2","object":"chat.completion.chunk","created":1766309809,"model":"gpt-5-nano","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-test2","object":"chat.completion.chunk","created":1766309809,"model":"gpt-5-nano","choices":[],"usage":{"prompt_tokens":200,"completion_tokens":10,"total_tokens":210}}
data: [DONE]