feat (acp): exposed available tools in acp schema (#10097)

This commit is contained in:
Lifei Zhou
2026-06-30 15:43:08 +10:00
committed by GitHub
parent d43c692127
commit 2084c43476
15 changed files with 543 additions and 40 deletions
@@ -331,6 +331,9 @@ pub enum GooseExtension {
timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
Platform {
name: String,
@@ -340,6 +343,9 @@ pub enum GooseExtension {
display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
Mcp {
server: McpServer,
@@ -353,6 +359,9 @@ pub enum GooseExtension {
socket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
}
@@ -364,6 +373,7 @@ impl Default for GooseExtension {
display_name: None,
timeout: None,
bundled: None,
available_tools: None,
}
}
}
@@ -171,6 +171,9 @@ pub enum RecipeExtensionDto {
timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
Platform {
name: String,
@@ -180,6 +183,9 @@ pub enum RecipeExtensionDto {
display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
Stdio {
name: String,
@@ -198,6 +204,9 @@ pub enum RecipeExtensionDto {
cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
StreamableHttp {
name: String,
@@ -216,6 +225,9 @@ pub enum RecipeExtensionDto {
socket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundled: Option<bool>,
/// Tool allowlist for this extension. Omit this field to allow all tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
available_tools: Option<Vec<String>>,
},
}
@@ -227,6 +239,7 @@ impl Default for RecipeExtensionDto {
display_name: None,
timeout: None,
bundled: None,
available_tools: None,
}
}
}
+70
View File
@@ -53,6 +53,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "builtin"
@@ -87,6 +97,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "platform"
@@ -134,6 +154,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "mcp"
@@ -3630,6 +3660,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "builtin"
@@ -3664,6 +3704,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "platform"
@@ -3726,6 +3776,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "stdio"
@@ -3789,6 +3849,16 @@
"null"
]
},
"available_tools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Tool allowlist for this extension. Omit this field to allow all tools."
},
"type": {
"type": "string",
"const": "streamable_http"
+43 -12
View File
@@ -145,25 +145,27 @@ fn config_to_goose_extension(
display_name,
timeout,
bundled,
..
available_tools,
} => GooseExtension::Builtin {
name: name.clone(),
description: empty_string_to_none(description),
display_name: display_name.clone(),
timeout: *timeout,
bundled: *bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::Platform {
name,
description,
display_name,
bundled,
..
available_tools,
} => GooseExtension::Platform {
name: name.clone(),
description: empty_string_to_none(description),
display_name: display_name.clone(),
bundled: *bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::Stdio {
name,
@@ -173,6 +175,7 @@ fn config_to_goose_extension(
env_keys,
timeout,
bundled,
available_tools,
..
} => GooseExtension::Mcp {
server: McpServer::Stdio(McpServerStdio::new(name, cmd).args(args.clone())),
@@ -181,6 +184,7 @@ fn config_to_goose_extension(
timeout: *timeout,
socket: None,
bundled: *bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::StreamableHttp {
name,
@@ -191,6 +195,7 @@ fn config_to_goose_extension(
timeout,
socket,
bundled,
available_tools,
..
} => {
let headers = headers
@@ -204,6 +209,7 @@ fn config_to_goose_extension(
timeout: *timeout,
socket: socket.clone(),
bundled: *bundled,
available_tools: available_tools_to_wire(available_tools),
}
}
ExtensionConfig::Frontend { .. }
@@ -229,25 +235,27 @@ fn goose_extension_to_config(
display_name,
timeout,
bundled,
available_tools,
} => ExtensionConfig::Builtin {
name,
description: description.unwrap_or_default(),
display_name,
timeout,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
GooseExtension::Platform {
name,
description,
display_name,
bundled,
available_tools,
} => ExtensionConfig::Platform {
name,
description: description.unwrap_or_default(),
display_name,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
GooseExtension::Mcp {
server,
@@ -256,6 +264,7 @@ fn goose_extension_to_config(
timeout,
socket,
bundled,
available_tools,
} => match server {
McpServer::Stdio(stdio) => {
if socket.is_some() {
@@ -279,7 +288,7 @@ fn goose_extension_to_config(
timeout,
cwd: None,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
}
}
McpServer::Http(http) => ExtensionConfig::StreamableHttp {
@@ -296,7 +305,7 @@ fn goose_extension_to_config(
timeout,
socket,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
McpServer::Sse(_) => {
return Err(agent_client_protocol::Error::invalid_params()
@@ -358,6 +367,14 @@ fn empty_string_to_none(value: &str) -> Option<String> {
}
}
fn available_tools_to_wire(available_tools: &[String]) -> Option<Vec<String>> {
if available_tools.is_empty() {
None
} else {
Some(available_tools.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -386,6 +403,7 @@ mod tests {
display_name,
timeout,
bundled,
available_tools,
} = extension
else {
panic!("expected builtin extension");
@@ -396,6 +414,7 @@ mod tests {
assert_eq!(display_name.as_deref(), Some("Developer"));
assert_eq!(timeout, Some(30));
assert_eq!(bundled, Some(true));
assert_eq!(available_tools, Some(vec!["shell".to_string()]));
}
#[test]
@@ -417,6 +436,7 @@ mod tests {
description,
display_name,
bundled,
available_tools,
} = extension
else {
panic!("expected platform extension");
@@ -426,6 +446,7 @@ mod tests {
assert_eq!(description.as_deref(), Some("Todo tools"));
assert_eq!(display_name.as_deref(), Some("Todo"));
assert_eq!(bundled, Some(true));
assert_eq!(available_tools, Some(vec!["write_todos".to_string()]));
}
#[test]
@@ -443,7 +464,7 @@ mod tests {
timeout: Some(42),
cwd: None,
bundled: None,
available_tools: vec![],
available_tools: vec!["run".to_string()],
};
let extension = config_to_goose_extension(&config)
@@ -457,6 +478,7 @@ mod tests {
timeout,
socket,
bundled,
available_tools,
} = extension
else {
panic!("expected mcp extension");
@@ -467,6 +489,7 @@ mod tests {
assert_eq!(timeout, Some(42));
assert_eq!(socket, None);
assert_eq!(bundled, None);
assert_eq!(available_tools, Some(vec!["run".to_string()]));
let McpServer::Stdio(stdio) = server else {
panic!("expected stdio server");
@@ -496,7 +519,7 @@ mod tests {
timeout: Some(99),
socket: Some("@egress.sock".to_string()),
bundled: None,
available_tools: vec![],
available_tools: vec!["fetch".to_string()],
};
let extension = config_to_goose_extension(&config)
@@ -510,6 +533,7 @@ mod tests {
timeout,
socket,
bundled,
available_tools,
} = extension
else {
panic!("expected mcp extension");
@@ -520,6 +544,7 @@ mod tests {
assert_eq!(timeout, Some(99));
assert_eq!(socket.as_deref(), Some("@egress.sock"));
assert_eq!(bundled, None);
assert_eq!(available_tools, Some(vec!["fetch".to_string()]));
let McpServer::Http(http) = server else {
panic!("expected http server");
@@ -602,6 +627,7 @@ mod tests {
timeout: Some(42),
socket: None,
bundled: Some(true),
available_tools: Some(vec!["run".to_string()]),
};
let conversion = goose_extension_to_config(extension).expect("conversion should succeed");
@@ -634,7 +660,7 @@ mod tests {
assert_eq!(env_keys, vec!["SECRET_TOKEN"]);
assert_eq!(timeout, Some(42));
assert_eq!(bundled, Some(true));
assert!(available_tools.is_empty());
assert_eq!(available_tools, vec!["run"]);
}
#[test]
@@ -652,6 +678,7 @@ mod tests {
timeout: Some(42),
socket: None,
bundled: Some(true),
available_tools: None,
};
let conversion = goose_extension_to_config(extension).expect("conversion should succeed");
@@ -694,6 +721,7 @@ mod tests {
timeout: Some(99),
socket: Some("@egress.sock".to_string()),
bundled: Some(true),
available_tools: Some(vec!["fetch".to_string()]),
};
let conversion = goose_extension_to_config(extension).expect("conversion should succeed");
@@ -733,7 +761,7 @@ mod tests {
assert_eq!(timeout, Some(99));
assert_eq!(socket.as_deref(), Some("@egress.sock"));
assert_eq!(bundled, Some(true));
assert!(available_tools.is_empty());
assert_eq!(available_tools, vec!["fetch"]);
}
#[test]
@@ -744,6 +772,7 @@ mod tests {
display_name: Some("Developer".to_string()),
timeout: Some(30),
bundled: Some(true),
available_tools: Some(vec!["shell".to_string()]),
};
let conversion = goose_extension_to_config(builtin).expect("conversion should succeed");
@@ -766,7 +795,7 @@ mod tests {
assert_eq!(display_name.as_deref(), Some("Developer"));
assert_eq!(timeout, Some(30));
assert_eq!(bundled, Some(true));
assert!(available_tools.is_empty());
assert_eq!(available_tools, vec!["shell"]);
}
#[test]
@@ -776,6 +805,7 @@ mod tests {
description: Some("Todo tools".to_string()),
display_name: Some("Todo".to_string()),
bundled: Some(true),
available_tools: Some(vec!["write_todos".to_string()]),
};
let conversion = goose_extension_to_config(platform).expect("conversion should succeed");
@@ -796,7 +826,7 @@ mod tests {
assert_eq!(description, "Todo tools");
assert_eq!(display_name.as_deref(), Some("Todo"));
assert_eq!(bundled, Some(true));
assert!(available_tools.is_empty());
assert_eq!(available_tools, vec!["write_todos"]);
}
#[test]
@@ -808,6 +838,7 @@ mod tests {
timeout: None,
socket: None,
bundled: None,
available_tools: None,
};
assert!(goose_extension_to_config(extension).is_err());
@@ -290,25 +290,27 @@ impl TryFrom<RecipeExtensionDto> for ExtensionConfig {
display_name,
timeout,
bundled,
available_tools,
} => Self::Builtin {
name,
description: description.unwrap_or_default(),
display_name,
timeout,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
RecipeExtensionDto::Platform {
name,
description,
display_name,
bundled,
available_tools,
} => Self::Platform {
name,
description: description.unwrap_or_default(),
display_name,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
RecipeExtensionDto::Stdio {
name,
@@ -320,6 +322,7 @@ impl TryFrom<RecipeExtensionDto> for ExtensionConfig {
timeout,
cwd,
bundled,
available_tools,
} => Self::Stdio {
name,
description: description.unwrap_or_default(),
@@ -330,7 +333,7 @@ impl TryFrom<RecipeExtensionDto> for ExtensionConfig {
timeout,
cwd,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
RecipeExtensionDto::StreamableHttp {
name,
@@ -342,6 +345,7 @@ impl TryFrom<RecipeExtensionDto> for ExtensionConfig {
timeout,
socket,
bundled,
available_tools,
} => Self::StreamableHttp {
name,
description: description.unwrap_or_default(),
@@ -352,7 +356,7 @@ impl TryFrom<RecipeExtensionDto> for ExtensionConfig {
timeout,
socket,
bundled,
available_tools: Vec::new(),
available_tools: available_tools.unwrap_or_default(),
},
})
}
@@ -369,25 +373,27 @@ impl TryFrom<ExtensionConfig> for RecipeExtensionDto {
display_name,
timeout,
bundled,
..
available_tools,
} => Self::Builtin {
name,
description: Some(description),
display_name,
timeout,
bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::Platform {
name,
description,
display_name,
bundled,
..
available_tools,
} => Self::Platform {
name,
description: Some(description),
display_name,
bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::Stdio {
name,
@@ -399,7 +405,7 @@ impl TryFrom<ExtensionConfig> for RecipeExtensionDto {
timeout,
cwd,
bundled,
..
available_tools,
} => Self::Stdio {
name,
description: Some(description),
@@ -410,6 +416,7 @@ impl TryFrom<ExtensionConfig> for RecipeExtensionDto {
timeout,
cwd,
bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::StreamableHttp {
name,
@@ -421,7 +428,7 @@ impl TryFrom<ExtensionConfig> for RecipeExtensionDto {
timeout,
socket,
bundled,
..
available_tools,
} => Self::StreamableHttp {
name,
description: Some(description),
@@ -432,6 +439,7 @@ impl TryFrom<ExtensionConfig> for RecipeExtensionDto {
timeout,
socket,
bundled,
available_tools: available_tools_to_wire(available_tools),
},
ExtensionConfig::Sse { .. } => bail_unsupported_extension("sse")?,
ExtensionConfig::Frontend { .. } => bail_unsupported_extension("frontend")?,
@@ -444,6 +452,14 @@ fn bail_unsupported_extension(extension_type: &str) -> Result<RecipeExtensionDto
bail!("recipe extension type `{extension_type}` is not supported by RecipeDto")
}
fn available_tools_to_wire(available_tools: Vec<String>) -> Option<Vec<String>> {
if available_tools.is_empty() {
None
} else {
Some(available_tools)
}
}
pub fn recipe_manifest_to_list_entry_dto(
id: String,
recipe: Recipe,
@@ -484,6 +500,7 @@ mod tests {
display_name: Some("Developer".to_string()),
timeout: Some(300),
bundled: Some(true),
available_tools: Some(vec!["shell".to_string()]),
},
RecipeExtensionDto::Stdio {
name: "local".to_string(),
@@ -495,6 +512,7 @@ mod tests {
timeout: Some(60),
cwd: Some("/tmp".to_string()),
bundled: None,
available_tools: Some(vec!["run".to_string()]),
},
RecipeExtensionDto::StreamableHttp {
name: "remote".to_string(),
@@ -506,6 +524,7 @@ mod tests {
timeout: Some(30),
socket: None,
bundled: Some(false),
available_tools: Some(vec!["fetch".to_string()]),
},
]),
settings: Some(RecipeSettingsDto {
@@ -558,15 +577,33 @@ mod tests {
assert_eq!(recipe.version, "1.0.0");
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.extensions.as_ref().unwrap().len(), 3);
match &recipe.extensions.as_ref().unwrap()[0] {
ExtensionConfig::Builtin {
available_tools, ..
} => {
assert_eq!(available_tools, &vec!["shell".to_string()]);
}
extension => panic!("expected builtin extension, got {extension:?}"),
}
match &recipe.extensions.as_ref().unwrap()[1] {
ExtensionConfig::Stdio { envs, .. } => {
ExtensionConfig::Stdio {
envs,
available_tools,
..
} => {
assert_eq!(envs.get_env()["LOCAL_MODE"], "true");
assert_eq!(available_tools, &vec!["run".to_string()]);
}
extension => panic!("expected stdio extension, got {extension:?}"),
}
match &recipe.extensions.as_ref().unwrap()[2] {
ExtensionConfig::StreamableHttp { envs, .. } => {
ExtensionConfig::StreamableHttp {
envs,
available_tools,
..
} => {
assert_eq!(envs.get_env()["REMOTE_MODE"], "true");
assert_eq!(available_tools, &vec!["fetch".to_string()]);
}
extension => panic!("expected streamable_http extension, got {extension:?}"),
}
@@ -582,8 +619,11 @@ mod tests {
assert!(serialized.get("subRecipes").is_none());
assert_eq!(serialized["parameters"][0]["input_type"], json!("select"));
assert_eq!(serialized["retry"]["checks"][0]["type"], json!("shell"));
assert_eq!(serialized["extensions"][0]["available_tools"][0], "shell");
assert_eq!(serialized["extensions"][1]["envs"]["LOCAL_MODE"], "true");
assert_eq!(serialized["extensions"][1]["available_tools"][0], "run");
assert_eq!(serialized["extensions"][2]["envs"]["REMOTE_MODE"], "true");
assert_eq!(serialized["extensions"][2]["available_tools"][0], "fetch");
}
#[test]
+39
View File
@@ -195,6 +195,7 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
/// Built-in extension that is part of the bundled goose MCP server
@@ -211,6 +212,7 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
/// Platform extensions that have direct access to the agent etc and run in the agent process
@@ -226,6 +228,7 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
/// Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification
@@ -256,6 +259,7 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
/// Frontend-provided tools that will be called through the frontend
@@ -274,6 +278,7 @@ pub enum ExtensionConfig {
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
/// Inline Python code that will be executed using uvx
@@ -293,6 +298,7 @@ pub enum ExtensionConfig {
#[serde(default)]
dependencies: Option<Vec<String>>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
available_tools: Vec<String>,
},
}
@@ -685,6 +691,39 @@ available_tools: []
assert_eq!(map.get("SAFE_VAR"), Some(&"ok".to_string()));
}
#[test]
fn serialization_omits_empty_available_tools() {
let config = ExtensionConfig::Builtin {
name: "developer".into(),
description: "dev".into(),
display_name: Some("Developer".into()),
timeout: Some(300),
bundled: Some(true),
available_tools: vec![],
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(!yaml.contains("available_tools"));
}
#[test]
fn serialization_preserves_available_tools() {
let config = ExtensionConfig::Builtin {
name: "developer".into(),
description: "dev".into(),
display_name: Some("Developer".into()),
timeout: Some(300),
bundled: Some(true),
available_tools: vec!["shell".to_string()],
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("available_tools"));
assert!(yaml.contains("- shell"));
}
#[test_case(
ExtensionConfig::Builtin {
name: "developer".into(),