feat: upgrade to rmcp 0.12.0 and sacp 10.0.0 by removing SSE transport (#6304)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-01-02 07:07:53 +08:00
committed by GitHub
parent 38fcf4f374
commit 5a5312a2e6
51 changed files with 508 additions and 931 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ goose = { path = "../goose" }
goose-bench = { path = "../goose-bench" }
goose-mcp = { path = "../goose-mcp" }
rmcp = { workspace = true }
sacp = "10.0.0-alpha.3"
sacp = { workspace = true }
agent-client-protocol-schema = "0.10.5"
clap = { version = "4.4", features = ["derive"] }
cliclack = "0.3.5"
-25
View File
@@ -527,16 +527,6 @@ enum Command {
)]
extensions: Vec<String>,
/// Add remote extensions with a URL
#[arg(
long = "with-remote-extension",
value_name = "URL",
help = "Add remote extensions (can be specified multiple times)",
long_help = "Add remote extensions from a URL. Can be specified multiple times. Format: 'url...'",
action = clap::ArgAction::Append
)]
remote_extensions: Vec<String>,
/// Add streamable HTTP extensions with a URL
#[arg(
long = "with-streamable-http-extension",
@@ -705,16 +695,6 @@ enum Command {
)]
extensions: Vec<String>,
/// Add remote extensions
#[arg(
long = "with-remote-extension",
value_name = "URL",
help = "Add remote extensions (can be specified multiple times)",
long_help = "Add remote extensions. Can be specified multiple times. Format: 'url...'",
action = clap::ArgAction::Append
)]
remote_extensions: Vec<String>,
/// Add streamable HTTP extensions
#[arg(
long = "with-streamable-http-extension",
@@ -1017,7 +997,6 @@ pub async fn cli() -> anyhow::Result<()> {
max_tool_repetitions,
max_turns,
extensions,
remote_extensions,
streamable_http_extensions,
builtins,
}) => {
@@ -1109,7 +1088,6 @@ pub async fn cli() -> anyhow::Result<()> {
resume,
no_session: false,
extensions,
remote_extensions,
streamable_http_extensions,
builtins,
extensions_override: None,
@@ -1198,7 +1176,6 @@ pub async fn cli() -> anyhow::Result<()> {
max_tool_repetitions,
max_turns,
extensions,
remote_extensions,
streamable_http_extensions,
builtins,
params,
@@ -1320,7 +1297,6 @@ pub async fn cli() -> anyhow::Result<()> {
resume,
no_session,
extensions,
remote_extensions,
streamable_http_extensions,
builtins,
extensions_override: input_config.extensions_override,
@@ -1535,7 +1511,6 @@ pub async fn cli() -> anyhow::Result<()> {
resume: false,
no_session: false,
extensions: Vec::new(),
remote_extensions: Vec::new(),
streamable_http_extensions: Vec::new(),
builtins: Vec::new(),
extensions_override: None,
+221 -331
View File
@@ -13,14 +13,14 @@ use goose::session::SessionManager;
use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role};
use sacp::schema::{
AgentCapabilities, AuthenticateRequest, AuthenticateResponse, BlobResourceContents,
CancelNotification, ContentBlock, ContentChunk, EmbeddedResource, EmbeddedResourceResource,
ImageContent, InitializeRequest, InitializeResponse, LoadSessionRequest, LoadSessionResponse,
McpCapabilities, McpServer, NewSessionRequest, NewSessionResponse, PermissionOption,
PermissionOptionId, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse,
RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionId,
SessionNotification, SessionUpdate, StopReason, TextContent, TextResourceContents, ToolCall,
ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
ToolCallUpdateFields, ToolKind,
CancelNotification, Content, ContentBlock, ContentChunk, EmbeddedResource,
EmbeddedResourceResource, ImageContent, InitializeRequest, InitializeResponse,
LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, NewSessionRequest,
NewSessionResponse, PermissionOption, PermissionOptionId, PermissionOptionKind,
PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome,
RequestPermissionRequest, ResourceLink, SessionId, SessionNotification, SessionUpdate,
StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId,
ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
};
use sacp::{AgentToClient, ByteStreams, Handled, JrConnectionCx, JrMessageHandler, MessageCx};
use std::collections::{HashMap, HashSet};
@@ -46,49 +46,43 @@ struct GooseAcpAgent {
fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConfig, String> {
match mcp_server {
McpServer::Stdio {
name,
command,
args,
env,
..
} => Ok(ExtensionConfig::Stdio {
name,
McpServer::Stdio(stdio) => Ok(ExtensionConfig::Stdio {
name: stdio.name,
description: String::new(),
cmd: command.to_string_lossy().to_string(),
args,
envs: Envs::new(env.into_iter().map(|e| (e.name, e.value)).collect()),
cmd: stdio.command.to_string_lossy().to_string(),
args: stdio.args,
envs: Envs::new(stdio.env.into_iter().map(|e| (e.name, e.value)).collect()),
env_keys: vec![],
timeout: None,
bundled: Some(false),
available_tools: vec![],
}),
McpServer::Http {
name, url, headers, ..
} => Ok(ExtensionConfig::StreamableHttp {
name,
McpServer::Http(http) => Ok(ExtensionConfig::StreamableHttp {
name: http.name,
description: String::new(),
uri: url,
uri: http.url,
envs: Envs::default(),
env_keys: vec![],
headers: headers.into_iter().map(|h| (h.name, h.value)).collect(),
headers: http
.headers
.into_iter()
.map(|h| (h.name, h.value))
.collect(),
timeout: None,
bundled: Some(false),
available_tools: vec![],
}),
McpServer::Sse { name, .. } => Err(format!(
"SSE transport is deprecated and not supported: {}",
name
)),
McpServer::Sse(_) => Err("SSE is unsupported, migrate to streamable_http".to_string()),
_ => Err("Unknown MCP server type".to_string()),
}
}
fn create_tool_location(path: &str, line: Option<u32>) -> ToolCallLocation {
ToolCallLocation {
path: path.into(),
line,
meta: None,
let mut loc = ToolCallLocation::new(path);
if let Some(l) = line {
loc = loc.line(l);
}
loc
}
fn extract_tool_locations(
@@ -393,6 +387,7 @@ impl GooseAcpAgent {
}
}
ContentBlock::Audio(..) => (),
_ => (), // Handle any future ContentBlock variants
}
}
@@ -409,18 +404,12 @@ impl GooseAcpAgent {
match content_item {
MessageContent::Text(text) => {
// Stream text to the client
cx.send_notification(SessionNotification {
session_id: session_id.clone(),
update: SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(TextContent {
text: text.text.clone(),
annotations: None,
meta: None,
}),
meta: None,
}),
meta: None,
})?;
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new(&text.text),
))),
))?;
}
MessageContent::ToolRequest(tool_request) => {
self.handle_tool_request(tool_request, session_id, session, cx)
@@ -432,18 +421,12 @@ impl GooseAcpAgent {
}
MessageContent::Thinking(thinking) => {
// Stream thinking/reasoning content as thought chunks
cx.send_notification(SessionNotification {
session_id: session_id.clone(),
update: SessionUpdate::AgentThoughtChunk(ContentChunk {
content: ContentBlock::Text(TextContent {
text: thinking.thinking.clone(),
annotations: None,
meta: None,
}),
meta: None,
}),
meta: None,
})?;
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentThoughtChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new(&thinking.thinking),
))),
))?;
}
MessageContent::ActionRequired(action_required) => {
if let ActionRequiredData::ToolConfirmation {
@@ -489,21 +472,16 @@ impl GooseAcpAgent {
};
// Send tool call notification using the provider's tool call ID directly
cx.send_notification(SessionNotification {
session_id: session_id.clone(),
update: SessionUpdate::ToolCall(ToolCall {
id: ToolCallId(tool_request.id.clone().into()),
title: format_tool_name(&tool_name),
kind: ToolKind::default(),
status: ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: None,
raw_output: None,
meta: None,
}),
meta: None,
})?;
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(
ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
format_tool_name(&tool_name),
)
.status(ToolCallStatus::Pending),
),
))?;
Ok(())
}
@@ -532,27 +510,17 @@ impl GooseAcpAgent {
};
// Send status update using provider's tool call ID directly
cx.send_notification(SessionNotification {
session_id: session_id.clone(),
update: SessionUpdate::ToolCallUpdate(ToolCallUpdate {
id: ToolCallId(tool_response.id.clone().into()),
fields: ToolCallUpdateFields {
status: Some(status),
content: Some(content),
locations: if locations.is_empty() {
None
} else {
Some(locations)
},
title: None,
kind: None,
raw_input: None,
raw_output: None,
},
meta: None,
}),
meta: None,
})?;
let mut fields = ToolCallUpdateFields::new().status(status).content(content);
if !locations.is_empty() {
fields = fields.locations(locations);
}
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(tool_response.id.clone()),
fields,
)),
))?;
Ok(())
}
@@ -573,27 +541,17 @@ impl GooseAcpAgent {
let formatted_name = format_tool_name(&tool_name);
// Use the request_id (provider's tool call ID) directly
let tool_call_update = ToolCallUpdate {
id: ToolCallId(request_id.clone().into()),
fields: ToolCallUpdateFields {
title: Some(formatted_name),
kind: Some(ToolKind::default()),
status: Some(ToolCallStatus::Pending),
content: prompt.map(|p| {
vec![ToolCallContent::Content {
content: ContentBlock::Text(TextContent {
text: p,
annotations: None,
meta: None,
}),
}]
}),
locations: None,
raw_input: Some(serde_json::Value::Object(arguments)),
raw_output: None,
},
meta: None,
};
let mut fields = ToolCallUpdateFields::new()
.title(formatted_name)
.kind(ToolKind::default())
.status(ToolCallStatus::Pending)
.raw_input(serde_json::Value::Object(arguments));
if let Some(p) = prompt {
fields = fields.content(vec![ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(p)),
))]);
}
let tool_call_update = ToolCallUpdate::new(ToolCallId::new(request_id.clone()), fields);
fn option(kind: PermissionOptionKind) -> PermissionOption {
let id = serde_json::to_value(kind)
@@ -601,12 +559,7 @@ impl GooseAcpAgent {
.as_str()
.unwrap()
.to_string();
PermissionOption {
id: PermissionOptionId::from(id.clone()),
name: id,
kind,
meta: None,
}
PermissionOption::new(PermissionOptionId::from(id.clone()), id, kind)
}
let options = vec![
option(PermissionOptionKind::AllowAlways),
@@ -614,12 +567,8 @@ impl GooseAcpAgent {
option(PermissionOptionKind::RejectOnce),
];
let permission_request = RequestPermissionRequest {
session_id,
tool_call: tool_call_update,
options,
meta: None,
};
let permission_request =
RequestPermissionRequest::new(session_id, tool_call_update, options);
cx.send_request(permission_request)
.on_receiving_result(move |result| async move {
@@ -656,18 +605,20 @@ impl GooseAcpAgent {
fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation {
let permission = match outcome {
RequestPermissionOutcome::Cancelled => Permission::Cancel,
RequestPermissionOutcome::Selected { option_id } => {
RequestPermissionOutcome::Selected(selected) => {
match serde_json::from_value::<PermissionOptionKind>(serde_json::Value::String(
option_id.0.to_string(),
selected.option_id.to_string(),
)) {
Ok(PermissionOptionKind::AllowAlways) => Permission::AlwaysAllow,
Ok(PermissionOptionKind::AllowOnce) => Permission::AllowOnce,
Ok(PermissionOptionKind::RejectOnce | PermissionOptionKind::RejectAlways) => {
Permission::DenyOnce
}
Ok(_) => Permission::Cancel, // Handle any future permission kinds
Err(_) => Permission::Cancel,
}
}
_ => Permission::Cancel, // Handle any future variants
};
PermissionConfirmation {
principal_type: PrincipalType::Tool,
@@ -681,56 +632,43 @@ fn build_tool_call_content(tool_result: &ToolResult<CallToolResult>) -> Vec<Tool
.content
.iter()
.filter_map(|content| match &content.raw {
RawContent::Text(val) => Some(ToolCallContent::Content {
content: ContentBlock::Text(TextContent {
text: val.text.clone(),
annotations: None,
meta: None,
}),
}),
RawContent::Image(val) => Some(ToolCallContent::Content {
content: ContentBlock::Image(ImageContent {
data: val.data.clone(),
mime_type: val.mime_type.clone(),
uri: None,
annotations: None,
meta: None,
}),
}),
RawContent::Resource(val) => Some(ToolCallContent::Content {
content: ContentBlock::Resource(EmbeddedResource {
resource: match &val.resource {
ResourceContents::TextResourceContents {
mime_type,
text,
uri,
..
} => EmbeddedResourceResource::TextResourceContents(
TextResourceContents {
text: text.clone(),
uri: uri.clone(),
mime_type: mime_type.clone(),
meta: None,
},
),
ResourceContents::BlobResourceContents {
mime_type,
blob,
uri,
..
} => EmbeddedResourceResource::BlobResourceContents(
BlobResourceContents {
blob: blob.clone(),
uri: uri.clone(),
mime_type: mime_type.clone(),
meta: None,
},
),
},
annotations: None,
meta: None,
}),
}),
RawContent::Text(val) => Some(ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(&val.text)),
))),
RawContent::Image(val) => Some(ToolCallContent::Content(Content::new(
ContentBlock::Image(ImageContent::new(&val.data, &val.mime_type)),
))),
RawContent::Resource(val) => {
let resource = match &val.resource {
ResourceContents::TextResourceContents {
mime_type,
text,
uri,
..
} => {
let mut r = TextResourceContents::new(text.clone(), uri.clone());
if let Some(mt) = mime_type {
r = r.mime_type(mt.clone());
}
EmbeddedResourceResource::TextResourceContents(r)
}
ResourceContents::BlobResourceContents {
mime_type,
blob,
uri,
..
} => {
let mut r = BlobResourceContents::new(blob.clone(), uri.clone());
if let Some(mt) = mime_type {
r = r.mime_type(mt.clone());
}
EmbeddedResourceResource::BlobResourceContents(r)
}
};
Some(ToolCallContent::Content(Content::new(
ContentBlock::Resource(EmbeddedResource::new(resource)),
)))
}
RawContent::Audio(_) => {
// Audio content is not supported in ACP ContentBlock, skip it
None
@@ -753,27 +691,16 @@ impl GooseAcpAgent {
debug!(?args, "initialize request");
// Advertise Goose's capabilities
Ok(InitializeResponse {
protocol_version: args.protocol_version,
agent_capabilities: AgentCapabilities {
load_session: true,
prompt_capabilities: PromptCapabilities {
image: true,
audio: false,
embedded_context: true,
meta: None,
},
mcp_capabilities: McpCapabilities {
http: true,
sse: false, // SSE is deprecated; rmcp drops support after 0.10.0
meta: None,
},
meta: None,
},
auth_methods: vec![],
agent_info: None,
meta: None,
})
let capabilities = AgentCapabilities::new()
.load_session(true)
.prompt_capabilities(
PromptCapabilities::new()
.image(true)
.audio(false)
.embedded_context(true),
)
.mcp_capabilities(McpCapabilities::new().http(true));
Ok(InitializeResponse::new(args.protocol_version).agent_capabilities(capabilities))
}
async fn on_new_session(
@@ -788,10 +715,11 @@ impl GooseAcpAgent {
SessionType::User,
)
.await
.map_err(|e| sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Failed to create session: {}", e),
data: None,
.map_err(|e| {
sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Failed to create session: {}", e),
)
})?;
let session = GooseAcpSession {
@@ -808,20 +736,15 @@ impl GooseAcpAgent {
let config = match mcp_server_to_extension_config(mcp_server) {
Ok(c) => c,
Err(msg) => {
return Err(sacp::Error {
code: sacp::ErrorCode::INVALID_PARAMS.code,
message: msg,
data: None,
});
return Err(sacp::Error::new(sacp::ErrorCode::InvalidParams.into(), msg));
}
};
let name = config.name().to_string();
if let Err(e) = self.agent.add_extension(config).await {
return Err(sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Failed to add MCP server '{}': {}", name, e),
data: None,
});
return Err(sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Failed to add MCP server '{}': {}", name, e),
));
}
}
@@ -831,11 +754,7 @@ impl GooseAcpAgent {
"Session started"
);
Ok(NewSessionResponse {
session_id: SessionId(goose_session.id.into()),
modes: None,
meta: None,
})
Ok(NewSessionResponse::new(SessionId::new(goose_session.id)))
}
async fn on_load_session(
@@ -849,26 +768,29 @@ impl GooseAcpAgent {
let goose_session = SessionManager::get_session(&session_id, true)
.await
.map_err(|e| sacp::Error {
code: sacp::ErrorCode::INVALID_PARAMS.code,
message: format!("Failed to load session {}: {}", session_id, e),
data: None,
.map_err(|e| {
sacp::Error::new(
sacp::ErrorCode::InvalidParams.into(),
format!("Failed to load session {}: {}", session_id, e),
)
})?;
let conversation = goose_session.conversation.ok_or_else(|| sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Session {} has no conversation data", session_id),
data: None,
let conversation = goose_session.conversation.ok_or_else(|| {
sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Session {} has no conversation data", session_id),
)
})?;
SessionManager::update_session(&session_id)
.working_dir(args.cwd.clone())
.apply()
.await
.map_err(|e| sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Failed to update session working directory: {}", e),
data: None,
.map_err(|e| {
sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Failed to update session working directory: {}", e),
)
})?;
let mut session = GooseAcpSession {
@@ -887,23 +809,16 @@ impl GooseAcpAgent {
for content_item in &message.content {
match content_item {
MessageContent::Text(text) => {
let chunk = ContentChunk {
content: ContentBlock::Text(TextContent {
annotations: None,
text: text.text.clone(),
meta: None,
}),
meta: None,
};
let chunk =
ContentChunk::new(ContentBlock::Text(TextContent::new(&text.text)));
let update = match message.role {
Role::User => SessionUpdate::UserMessageChunk(chunk),
Role::Assistant => SessionUpdate::AgentMessageChunk(chunk),
};
cx.send_notification(SessionNotification {
session_id: args.session_id.clone(),
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
update,
meta: None,
})?;
))?;
}
MessageContent::ToolRequest(tool_request) => {
self.handle_tool_request(tool_request, &args.session_id, &mut session, cx)
@@ -919,18 +834,12 @@ impl GooseAcpAgent {
.await?;
}
MessageContent::Thinking(thinking) => {
cx.send_notification(SessionNotification {
session_id: args.session_id.clone(),
update: SessionUpdate::AgentThoughtChunk(ContentChunk {
content: ContentBlock::Text(TextContent {
annotations: None,
text: thinking.thinking.clone(),
meta: None,
}),
meta: None,
}),
meta: None,
})?;
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
SessionUpdate::AgentThoughtChunk(ContentChunk::new(
ContentBlock::Text(TextContent::new(&thinking.thinking)),
)),
))?;
}
_ => {
// Ignore other content types
@@ -948,10 +857,7 @@ impl GooseAcpAgent {
"Session loaded"
);
Ok(LoadSessionResponse {
modes: None,
meta: None,
})
Ok(LoadSessionResponse::new())
}
async fn on_prompt(
@@ -964,10 +870,11 @@ impl GooseAcpAgent {
{
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(&session_id).ok_or_else(|| sacp::Error {
code: sacp::ErrorCode::INVALID_PARAMS.code,
message: format!("Session not found: {}", session_id),
data: None,
let session = sessions.get_mut(&session_id).ok_or_else(|| {
sacp::Error::new(
sacp::ErrorCode::InvalidParams.into(),
format!("Session not found: {}", session_id),
)
})?;
session.cancel_token = Some(cancel_token.clone());
}
@@ -985,10 +892,11 @@ impl GooseAcpAgent {
.agent
.reply(user_message, session_config, Some(cancel_token.clone()))
.await
.map_err(|e| sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Error getting agent reply: {}", e),
data: None,
.map_err(|e| {
sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Error getting agent reply: {}", e),
)
})?;
use futures::StreamExt;
@@ -1004,10 +912,11 @@ impl GooseAcpAgent {
match event {
Ok(goose::agents::AgentEvent::Message(message)) => {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(&session_id).ok_or_else(|| sacp::Error {
code: sacp::ErrorCode::INVALID_PARAMS.code,
message: format!("Session not found: {}", session_id),
data: None,
let session = sessions.get_mut(&session_id).ok_or_else(|| {
sacp::Error::new(
sacp::ErrorCode::InvalidParams.into(),
format!("Session not found: {}", session_id),
)
})?;
session.messages.push(message.clone());
@@ -1019,11 +928,10 @@ impl GooseAcpAgent {
}
Ok(_) => {}
Err(e) => {
return Err(sacp::Error {
code: sacp::ErrorCode::INTERNAL_ERROR.code,
message: format!("Error in agent response stream: {}", e),
data: None,
});
return Err(sacp::Error::new(
sacp::ErrorCode::InternalError.into(),
format!("Error in agent response stream: {}", e),
));
}
}
}
@@ -1033,14 +941,12 @@ impl GooseAcpAgent {
session.cancel_token = None;
}
Ok(PromptResponse {
stop_reason: if was_cancelled {
StopReason::Cancelled
} else {
StopReason::EndTurn
},
meta: None,
})
let stop_reason = if was_cancelled {
StopReason::Cancelled
} else {
StopReason::EndTurn
};
Ok(PromptResponse::new(stop_reason))
}
async fn on_cancel(&self, args: CancelNotification) -> Result<(), sacp::Error> {
@@ -1090,7 +996,7 @@ impl JrMessageHandler for GooseAcpHandler {
.await
.if_request(
|_req: AuthenticateRequest, req_cx: JrRequestCx<AuthenticateResponse>| async {
req_cx.respond(AuthenticateResponse { meta: None })
req_cx.respond(AuthenticateResponse::new())
},
)
.await
@@ -1156,9 +1062,11 @@ pub async fn run_acp_agent(builtins: Vec<String>) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use sacp::schema::{EnvVariable, HttpHeader, McpServer, ResourceLink};
use sacp::schema::{
EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio,
ResourceLink, SelectedPermissionOutcome,
};
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use test_case::test_case;
@@ -1168,16 +1076,14 @@ mod tests {
use goose::agents::ExtensionConfig;
#[test_case(
McpServer::Stdio {
name: "github".into(),
command: PathBuf::from("/path/to/github-mcp-server"),
args: vec!["stdio".into()],
env: vec![EnvVariable {
name: "GITHUB_PERSONAL_ACCESS_TOKEN".into(),
value: "ghp_xxxxxxxxxxxx".into(),
meta: None,
}],
},
McpServer::Stdio(
McpServerStdio::new("github", "/path/to/github-mcp-server")
.args(vec!["stdio".into()])
.env(vec![EnvVariable::new(
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghp_xxxxxxxxxxxx"
)])
),
Ok(ExtensionConfig::Stdio {
name: "github".into(),
description: String::new(),
@@ -1197,15 +1103,10 @@ mod tests {
})
)]
#[test_case(
McpServer::Http {
name: "github".into(),
url: "https://api.githubcopilot.com/mcp/".into(),
headers: vec![HttpHeader {
name: "Authorization".into(),
value: "Bearer ghp_xxxxxxxxxxxx".into(),
meta: None,
}],
},
McpServer::Http(
McpServerHttp::new("github", "https://api.githubcopilot.com/mcp/")
.headers(vec![HttpHeader::new("Authorization", "Bearer ghp_xxxxxxxxxxxx")])
),
Ok(ExtensionConfig::StreamableHttp {
name: "github".into(),
description: String::new(),
@@ -1222,12 +1123,8 @@ mod tests {
})
)]
#[test_case(
McpServer::Sse {
name: "test-sse".into(),
url: "https://example.com/sse".into(),
headers: vec![],
},
Err("SSE transport is deprecated and not supported: test-sse".to_string())
McpServer::Sse(McpServerSse::new("test-sse", "https://agent-fin.biodnd.com/sse")),
Err("SSE is unsupported, migrate to streamable_http".to_string())
)]
fn test_mcp_server_to_extension_config(
input: McpServer,
@@ -1240,21 +1137,14 @@ mod tests {
let mut file = NamedTempFile::new()?;
file.write_all(content.as_bytes())?;
let link = ResourceLink {
name: file
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
uri: format!("file://{}", file.path().to_str().unwrap()),
annotations: None,
description: None,
mime_type: None,
size: None,
title: None,
meta: None,
};
let name = file
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let uri = format!("file://{}", file.path().to_str().unwrap());
let link = ResourceLink::new(name, uri);
Ok((link, file))
}
@@ -1305,27 +1195,27 @@ print(\"hello, world\")
}
#[test_case(
RequestPermissionOutcome::Selected { option_id: PermissionOptionId::from("allow_once".to_string()) },
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("allow_once")),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::AllowOnce };
"allow_once_maps_to_allow_once"
)]
#[test_case(
RequestPermissionOutcome::Selected { option_id: PermissionOptionId::from("allow_always".to_string()) },
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("allow_always")),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::AlwaysAllow };
"allow_always_maps_to_always_allow"
)]
#[test_case(
RequestPermissionOutcome::Selected { option_id: PermissionOptionId::from("reject_once".to_string()) },
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("reject_once")),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::DenyOnce };
"reject_once_maps_to_deny_once"
)]
#[test_case(
RequestPermissionOutcome::Selected { option_id: PermissionOptionId::from("reject_always".to_string()) },
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("reject_always")),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::DenyOnce };
"reject_always_maps_to_deny_once"
)]
#[test_case(
RequestPermissionOutcome::Selected { option_id: PermissionOptionId::from("unknown".to_string()) },
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("unknown")),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::Cancel };
"unknown_option_maps_to_cancel"
)]
+1 -2
View File
@@ -38,8 +38,7 @@ pub async fn agent_generator(
resume: false,
no_session: false,
extensions: requirements.external,
remote_extensions: requirements.remote,
streamable_http_extensions: Vec::new(),
streamable_http_extensions: requirements.streamable_http,
builtins: requirements.builtin,
extensions_override: None,
additional_system_prompt: None,
+12 -104
View File
@@ -625,6 +625,10 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
/// Configure extensions that can be used with goose
/// Dialog for toggling which extensions are enabled/disabled
pub fn toggle_extensions_dialog() -> anyhow::Result<()> {
for warning in goose::config::get_warnings() {
eprintln!("{}", style(format!("Warning: {}", warning)).yellow());
}
let extensions = get_all_extensions();
if extensions.is_empty() {
@@ -692,15 +696,10 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
"Command-line Extension",
"Run a local command or script",
)
.item(
"sse",
"Remote Extension (SSE)",
"Connect to a remote extension via Server-Sent Events",
)
.item(
"streamable_http",
"Remote Extension (Streaming HTTP)",
"Connect to a remote extension via MCP Streaming HTTP",
"Remote Extension (Streamable HTTP)",
"Connect to a remote extension via MCP Streamable HTTP",
)
.interact()?;
@@ -870,101 +869,6 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
cliclack::outro(format!("Added {} extension", style(name).green()))?;
}
"sse" => {
let extensions = get_all_extension_names();
let name: String = cliclack::input("What would you like to call this extension?")
.placeholder("my-remote-extension")
.validate(move |input: &String| {
if input.is_empty() {
Err("Please enter a name")
} else if extensions.contains(input) {
Err("An extension with this name already exists")
} else {
Ok(())
}
})
.interact()?;
let uri: String = cliclack::input("What is the SSE endpoint URI?")
.placeholder("http://localhost:8000/events")
.validate(|input: &String| {
if input.is_empty() {
Err("Please enter a URI")
} else if !input.starts_with("http") {
Err("URI should start with http:// or https://")
} else {
Ok(())
}
})
.interact()?;
let timeout: u64 = cliclack::input("Please set the timeout for this tool (in secs):")
.placeholder(&goose::config::DEFAULT_EXTENSION_TIMEOUT.to_string())
.validate(|input: &String| match input.parse::<u64>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid timeout"),
})
.interact()?;
let description = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| match input.parse::<String>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid description"),
})
.interact()?;
let add_env =
cliclack::confirm("Would you like to add environment variables?").interact()?;
let mut envs = HashMap::new();
let mut env_keys = Vec::new();
let config = Config::global();
if add_env {
loop {
let key: String = cliclack::input("Environment variable name:")
.placeholder("API_KEY")
.interact()?;
let value: String = cliclack::password("Environment variable value:")
.mask('▪')
.interact()?;
// Try to store in keychain
let keychain_key = key.to_string();
match config.set_secret(&keychain_key, &value) {
Ok(_) => {
// Successfully stored in keychain, add to env_keys
env_keys.push(keychain_key);
}
Err(_) => {
// Failed to store in keychain, store directly in envs
envs.insert(key, value);
}
}
if !cliclack::confirm("Add another environment variable?").interact()? {
break;
}
}
}
set_extension(ExtensionEntry {
enabled: true,
config: ExtensionConfig::Sse {
name: name.clone(),
uri,
envs: Envs::new(envs),
env_keys,
description,
timeout: Some(timeout),
bundled: None,
available_tools: Vec::new(),
},
});
cliclack::outro(format!("Added {} extension", style(name).green()))?;
}
"streamable_http" => {
let extensions = get_all_extension_names();
let name: String = cliclack::input("What would you like to call this extension?")
@@ -980,7 +884,7 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
})
.interact()?;
let uri: String = cliclack::input("What is the Streaming HTTP endpoint URI?")
let uri: String = cliclack::input("What is the Streamable HTTP endpoint URI?")
.placeholder("http://localhost:8000/messages")
.validate(|input: &String| {
if input.is_empty() {
@@ -1034,7 +938,7 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
}
}
let add_env = false; // No env prompt for Streaming HTTP
let add_env = false; // No env prompt for Streamable HTTP
let mut envs = HashMap::new();
let mut env_keys = Vec::new();
@@ -1095,6 +999,10 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
}
pub fn remove_extension_dialog() -> anyhow::Result<()> {
for warning in goose::config::get_warnings() {
eprintln!("{}", style(format!("Warning: {}", warning)).yellow());
}
let extensions = get_all_extensions();
// Create a list of extension names and their enabled status
@@ -51,13 +51,17 @@ fn extract_secrets_from_extensions(
for ext in extensions {
let (extension_name, env_keys) = match ext {
ExtensionConfig::Sse { name, env_keys, .. } => (name, env_keys),
ExtensionConfig::Stdio { name, env_keys, .. } => (name, env_keys),
ExtensionConfig::StreamableHttp { name, env_keys, .. } => (name, env_keys),
ExtensionConfig::Builtin { name, .. } => (name, &Vec::new()),
ExtensionConfig::Platform { name, .. } => (name, &Vec::new()),
ExtensionConfig::Frontend { name, .. } => (name, &Vec::new()),
ExtensionConfig::InlinePython { name, .. } => (name, &Vec::new()),
// SSE is unsupported - skip
ExtensionConfig::Sse { name, .. } => {
tracing::warn!(name = %name, "SSE is unsupported, skipping");
continue;
}
};
for key in env_keys {
@@ -136,15 +140,16 @@ mod tests {
instructions: Some("Test instructions".to_string()),
prompt: None,
extensions: Some(vec![
ExtensionConfig::Sse {
ExtensionConfig::StreamableHttp {
name: "github-mcp".to_string(),
uri: "sse://example.com".to_string(),
uri: "http://localhost:8080/mcp".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["GITHUB_TOKEN".to_string(), "GITHUB_API_URL".to_string()],
description: "github-mcp".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
headers: HashMap::new(),
},
ExtensionConfig::Stdio {
name: "slack-mcp".to_string(),
@@ -231,15 +236,16 @@ mod tests {
instructions: Some("Test instructions".to_string()),
prompt: None,
extensions: Some(vec![
ExtensionConfig::Sse {
ExtensionConfig::StreamableHttp {
name: "service-a".to_string(),
uri: "sse://example.com".to_string(),
uri: "http://localhost:8080/mcp".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["API_KEY".to_string()],
description: "service-a".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
headers: HashMap::new(),
},
ExtensionConfig::Stdio {
name: "service-b".to_string(),
@@ -289,15 +295,16 @@ mod tests {
description: "A recipe with sub-recipes".to_string(),
instructions: Some("Test instructions".to_string()),
prompt: None,
extensions: Some(vec![ExtensionConfig::Sse {
extensions: Some(vec![ExtensionConfig::StreamableHttp {
name: "parent-ext".to_string(),
uri: "sse://parent.com".to_string(),
uri: "http://localhost:8080/mcp".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["PARENT_TOKEN".to_string()],
description: "parent-ext".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
headers: HashMap::new(),
}]),
sub_recipes: Some(vec![SubRecipe {
name: "child-recipe".to_string(),
@@ -50,6 +50,7 @@ impl McpClientTrait for MockClient {
Ok(ListResourcesResult {
resources: vec![],
next_cursor: None,
meta: None,
})
}
@@ -85,6 +86,7 @@ impl McpClientTrait for MockClient {
Ok(ListToolsResult {
tools: rmcp_tools,
next_cursor: None,
meta: None,
})
}
@@ -117,6 +119,7 @@ impl McpClientTrait for MockClient {
Ok(ListPromptsResult {
prompts: vec![],
next_cursor: None,
meta: None,
})
}
+6 -33
View File
@@ -34,8 +34,6 @@ pub struct SessionBuilderConfig {
pub no_session: bool,
/// List of stdio extension commands to add
pub extensions: Vec<String>,
/// List of remote extension commands to add
pub remote_extensions: Vec<String>,
/// List of streamable HTTP extension commands to add
pub streamable_http_extensions: Vec<String>,
/// List of builtin extension commands to add
@@ -81,7 +79,6 @@ impl Default for SessionBuilderConfig {
resume: false,
no_session: false,
extensions: Vec::new(),
remote_extensions: Vec::new(),
streamable_http_extensions: Vec::new(),
builtins: Vec::new(),
extensions_override: None,
@@ -422,6 +419,11 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
// Setup extensions for the agent
// Extensions need to be added after the session is created because we change directory when resuming a session
for warning in goose::config::get_warnings() {
eprintln!("{}", style(format!("Warning: {}", warning)).yellow());
}
// If we get extensions_override, only run those extensions and none other
let extensions_to_run: Vec<_> = if let Some(extensions) = session_config.extensions_override {
extensions.into_iter().collect()
@@ -548,32 +550,6 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
}
// Add remote extensions if provided
for extension_str in session_config.remote_extensions {
if let Err(e) = session.add_remote_extension(extension_str.clone()).await {
eprintln!(
"{}",
style(format!(
"Warning: Failed to start remote extension '{}' ({}), continuing without it",
extension_str, e
))
.yellow()
);
// Offer debugging help
if let Err(debug_err) = offer_extension_debugging_help(
&extension_str,
&e.to_string(),
Arc::clone(&provider_for_display),
session_config.interactive,
)
.await
{
eprintln!("Note: Could not start debugging session: {}", debug_err);
}
}
}
// Add streamable HTTP extensions if provided
for extension_str in session_config.streamable_http_extensions {
if let Err(e) = session
@@ -686,8 +662,7 @@ mod tests {
resume: false,
no_session: false,
extensions: vec!["echo test".to_string()],
remote_extensions: vec!["http://example.com".to_string()],
streamable_http_extensions: vec!["http://example.com/streamable".to_string()],
streamable_http_extensions: vec!["http://localhost:8080/mcp".to_string()],
builtins: vec!["developer".to_string()],
extensions_override: None,
additional_system_prompt: Some("Test prompt".to_string()),
@@ -707,7 +682,6 @@ mod tests {
};
assert_eq!(config.extensions.len(), 1);
assert_eq!(config.remote_extensions.len(), 1);
assert_eq!(config.streamable_http_extensions.len(), 1);
assert_eq!(config.builtins.len(), 1);
assert!(config.debug);
@@ -726,7 +700,6 @@ mod tests {
assert!(!config.resume);
assert!(!config.no_session);
assert!(config.extensions.is_empty());
assert!(config.remote_extensions.is_empty());
assert!(config.streamable_http_extensions.is_empty());
assert!(config.builtins.is_empty());
assert!(config.extensions_override.is_none());
-28
View File
@@ -254,34 +254,6 @@ impl CliSession {
Ok(())
}
/// Add a remote extension to the session
///
/// # Arguments
/// * `extension_url` - URL of the server
pub async fn add_remote_extension(&mut self, extension_url: String) -> Result<()> {
let config = ExtensionConfig::Sse {
name: String::new(),
uri: extension_url,
envs: Envs::new(HashMap::new()),
env_keys: Vec::new(),
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
// TODO: should set timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
available_tools: Vec::new(),
};
self.agent
.add_extension(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?;
// Invalidate the completion cache when a new extension is added
self.invalidate_completion_cache().await;
Ok(())
}
/// Add a streamable HTTP extension to the session
///
/// # Arguments