From 2084c434762d8ec38f4e8360fbb5b7295c9ec9cf Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 30 Jun 2026 15:43:08 +1000 Subject: [PATCH] feat (acp): exposed available tools in acp schema (#10097) --- crates/goose-sdk-types/src/custom_requests.rs | 10 ++ .../src/custom_requests/recipe.rs | 13 +++ crates/goose/acp-schema.json | 70 ++++++++++++++ crates/goose/src/acp/server/extensions.rs | 55 ++++++++--- .../src/acp/server/recipe/conversions.rs | 60 ++++++++++-- crates/goose/src/agents/extension.rs | 39 ++++++++ ui/desktop/src/acp/extensions.ts | 11 +++ .../recipes/CreateEditRecipeModal.tsx | 11 +-- .../shared/RecipeExtensionSelector.tsx | 78 +++++++++++++-- .../RecipeExtensionSelector.test.tsx | 54 +++++++++++ .../settings/extensions/utils.test.ts | 95 +++++++++++++++++++ .../components/settings/extensions/utils.ts | 29 +++++- ui/desktop/src/recipe/validation.test.ts | 2 +- ui/sdk/src/generated/types.gen.ts | 28 ++++++ ui/sdk/src/generated/zod.gen.ts | 28 ++++++ 15 files changed, 543 insertions(+), 40 deletions(-) create mode 100644 ui/desktop/src/components/recipes/shared/__tests__/RecipeExtensionSelector.test.tsx diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index c9aeb8d18..8bd0ad468 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -331,6 +331,9 @@ pub enum GooseExtension { timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, Platform { name: String, @@ -340,6 +343,9 @@ pub enum GooseExtension { display_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, Mcp { server: McpServer, @@ -353,6 +359,9 @@ pub enum GooseExtension { socket: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, } @@ -364,6 +373,7 @@ impl Default for GooseExtension { display_name: None, timeout: None, bundled: None, + available_tools: None, } } } diff --git a/crates/goose-sdk-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs index 1dda40290..101fa230d 100644 --- a/crates/goose-sdk-types/src/custom_requests/recipe.rs +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -171,6 +171,9 @@ pub enum RecipeExtensionDto { timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, Platform { name: String, @@ -180,6 +183,9 @@ pub enum RecipeExtensionDto { display_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, Stdio { name: String, @@ -198,6 +204,9 @@ pub enum RecipeExtensionDto { cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, StreamableHttp { name: String, @@ -216,6 +225,9 @@ pub enum RecipeExtensionDto { socket: Option, #[serde(default, skip_serializing_if = "Option::is_none")] bundled: Option, + /// Tool allowlist for this extension. Omit this field to allow all tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, }, } @@ -227,6 +239,7 @@ impl Default for RecipeExtensionDto { display_name: None, timeout: None, bundled: None, + available_tools: None, } } } diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index f2e7a1177..90ce11dfe 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -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" diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index 4de4c4abf..6a204bdbf 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -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 { } } +fn available_tools_to_wire(available_tools: &[String]) -> Option> { + 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()); diff --git a/crates/goose/src/acp/server/recipe/conversions.rs b/crates/goose/src/acp/server/recipe/conversions.rs index 9ec6e1a7b..0a41a0594 100644 --- a/crates/goose/src/acp/server/recipe/conversions.rs +++ b/crates/goose/src/acp/server/recipe/conversions.rs @@ -290,25 +290,27 @@ impl TryFrom 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 for ExtensionConfig { timeout, cwd, bundled, + available_tools, } => Self::Stdio { name, description: description.unwrap_or_default(), @@ -330,7 +333,7 @@ impl TryFrom 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 for ExtensionConfig { timeout, socket, bundled, + available_tools, } => Self::StreamableHttp { name, description: description.unwrap_or_default(), @@ -352,7 +356,7 @@ impl TryFrom for ExtensionConfig { timeout, socket, bundled, - available_tools: Vec::new(), + available_tools: available_tools.unwrap_or_default(), }, }) } @@ -369,25 +373,27 @@ impl TryFrom 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 for RecipeExtensionDto { timeout, cwd, bundled, - .. + available_tools, } => Self::Stdio { name, description: Some(description), @@ -410,6 +416,7 @@ impl TryFrom for RecipeExtensionDto { timeout, cwd, bundled, + available_tools: available_tools_to_wire(available_tools), }, ExtensionConfig::StreamableHttp { name, @@ -421,7 +428,7 @@ impl TryFrom for RecipeExtensionDto { timeout, socket, bundled, - .. + available_tools, } => Self::StreamableHttp { name, description: Some(description), @@ -432,6 +439,7 @@ impl TryFrom 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) -> Option> { + 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] diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 2d4a5377c..ec1b40579 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -195,6 +195,7 @@ pub enum ExtensionConfig { #[serde(default)] bundled: Option, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, /// Built-in extension that is part of the bundled goose MCP server @@ -211,6 +212,7 @@ pub enum ExtensionConfig { #[serde(default)] bundled: Option, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, /// 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, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, /// Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification @@ -256,6 +259,7 @@ pub enum ExtensionConfig { #[serde(default)] bundled: Option, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, /// Frontend-provided tools that will be called through the frontend @@ -274,6 +278,7 @@ pub enum ExtensionConfig { #[serde(default)] bundled: Option, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, /// Inline Python code that will be executed using uvx @@ -293,6 +298,7 @@ pub enum ExtensionConfig { #[serde(default)] dependencies: Option>, #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] available_tools: Vec, }, } @@ -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(), diff --git a/ui/desktop/src/acp/extensions.ts b/ui/desktop/src/acp/extensions.ts index 1c69cfe82..fccc35a75 100644 --- a/ui/desktop/src/acp/extensions.ts +++ b/ui/desktop/src/acp/extensions.ts @@ -17,6 +17,10 @@ function headersToRecord(headers: { name: string; value: string }[] = []) { return Object.fromEntries(headers.map(({ name, value }) => [name, value])); } +function availableToolsOrUndefined(availableTools?: string[] | null): string[] | undefined { + return availableTools?.length ? availableTools : undefined; +} + export function gooseExtensionToExtensionConfig(extension: GooseExtension): ExtensionConfig | null { switch (extension.type) { case 'builtin': @@ -24,6 +28,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte return { ...extension, description: extension.description ?? '', + available_tools: availableToolsOrUndefined(extension.available_tools), }; case 'mcp': { const server = extension.server; @@ -37,6 +42,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte env_keys: extension.envKeys ?? [], timeout: extension.timeout, bundled: extension.bundled, + available_tools: availableToolsOrUndefined(extension.available_tools), }; } if ('url' in server) { @@ -50,6 +56,7 @@ export function gooseExtensionToExtensionConfig(extension: GooseExtension): Exte timeout: extension.timeout, socket: extension.socket, bundled: extension.bundled, + available_tools: availableToolsOrUndefined(extension.available_tools), }; } return null; @@ -94,6 +101,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE display_name: config.display_name, timeout: config.timeout, bundled: config.bundled, + available_tools: availableToolsOrUndefined(config.available_tools), }; case 'platform': return { @@ -102,6 +110,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE description: config.description, display_name: config.display_name, bundled: config.bundled, + available_tools: availableToolsOrUndefined(config.available_tools), }; case 'stdio': return { @@ -111,6 +120,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE description: config.description, timeout: config.timeout, bundled: config.bundled, + available_tools: availableToolsOrUndefined(config.available_tools), }; case 'streamable_http': return { @@ -126,6 +136,7 @@ export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseE timeout: config.timeout, socket: config.socket, bundled: config.bundled, + available_tools: availableToolsOrUndefined(config.available_tools), }; case 'sse': case 'frontend': diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index d5d90e1f5..aef53942f 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -259,13 +259,12 @@ export default function CreateEditRecipeModal({ : undefined; const cleanedExtensions = extensions?.map( - ( - extension: RecipeExtension & { - enabled?: boolean; - available_tools?: unknown; + (extension: RecipeExtension & { enabled?: boolean }) => { + const { enabled: _enabled, ...rest } = extension; + if (rest.available_tools == null || rest.available_tools.length === 0) { + const { available_tools: _availableTools, ...withoutAvailableTools } = rest; + return withoutAvailableTools; } - ) => { - const { enabled: _enabled, available_tools: _availableTools, ...rest } = extension; return rest; } ) as RecipeExtension[] | undefined; diff --git a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx index 5f8ee6cd8..9f0af6eec 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx @@ -38,6 +38,12 @@ type DisplayRecipeExtension = RecipeExtension & { enabled?: boolean; }; +function availableToolsProps(availableTools?: string[] | null) { + return availableTools && availableTools.length > 0 + ? { available_tools: availableTools } + : undefined; +} + function toRecipeExtension( extension: FixedExtensionEntry | DisplayRecipeExtension ): DisplayRecipeExtension | null { @@ -45,21 +51,74 @@ function toRecipeExtension( switch (extension.type) { case 'builtin': { - const { name, description, display_name, timeout, bundled, type } = extension; - return { name, description, display_name, timeout, bundled, type, enabled }; + const { name, description, display_name, timeout, bundled, available_tools, type } = + extension; + return { + name, + description, + display_name, + timeout, + bundled, + ...availableToolsProps(available_tools), + type, + enabled, + }; } case 'platform': { - const { name, description, display_name, bundled, type } = extension; - return { name, description, display_name, bundled, type, enabled }; + const { name, description, display_name, bundled, available_tools, type } = extension; + return { + name, + description, + display_name, + bundled, + ...availableToolsProps(available_tools), + type, + enabled, + }; } case 'stdio': { - const { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type } = - extension; - return { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type, enabled }; + const { + name, + description, + cmd, + args, + envs, + env_keys, + timeout, + cwd, + bundled, + available_tools, + type, + } = extension; + return { + name, + description, + cmd, + args, + envs, + env_keys, + timeout, + cwd, + bundled, + ...availableToolsProps(available_tools), + type, + enabled, + }; } case 'streamable_http': { - const { name, description, uri, envs, env_keys, headers, timeout, socket, bundled, type } = - extension; + const { + name, + description, + uri, + envs, + env_keys, + headers, + timeout, + socket, + bundled, + available_tools, + type, + } = extension; return { name, description, @@ -70,6 +129,7 @@ function toRecipeExtension( timeout, socket, bundled, + ...availableToolsProps(available_tools), type, enabled, }; diff --git a/ui/desktop/src/components/recipes/shared/__tests__/RecipeExtensionSelector.test.tsx b/ui/desktop/src/components/recipes/shared/__tests__/RecipeExtensionSelector.test.tsx new file mode 100644 index 000000000..353e6a8fe --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/__tests__/RecipeExtensionSelector.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, type RenderOptions, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { RecipeExtensionSelector } from '../RecipeExtensionSelector'; +import { IntlTestWrapper } from '../../../../i18n/test-utils'; +import type { FixedExtensionEntry } from '../../../ConfigContext'; + +const configContextMock = vi.hoisted(() => ({ + extensionsList: [] as FixedExtensionEntry[], +})); + +vi.mock('../../../ConfigContext', () => ({ + useConfig: () => ({ + extensionsList: configContextMock.extensionsList, + }), +})); + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); + +describe('RecipeExtensionSelector', () => { + beforeEach(() => { + configContextMock.extensionsList = []; + }); + + it('preserves non-empty available tools when selecting a configured extension', async () => { + const user = userEvent.setup(); + const onExtensionsChange = vi.fn(); + configContextMock.extensionsList = [ + { + type: 'builtin', + name: 'developer', + description: 'Developer tools', + enabled: true, + available_tools: ['shell', 'read_file'], + }, + ]; + + renderWithIntl( + + ); + + await user.click(screen.getByText('Developer')); + + expect(onExtensionsChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'builtin', + name: 'developer', + available_tools: ['shell', 'read_file'], + }), + ]); + }); +}); diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index 5fd4cd089..8150fa4f3 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -157,6 +157,34 @@ describe('Extension Utils', () => { }); }); + it('should preserve available tools metadata', () => { + const extension: FixedExtensionEntry = { + type: 'builtin', + name: 'developer', + description: 'developer', + enabled: true, + available_tools: ['shell', 'read_file'], + }; + + const formData = extensionToFormData(extension); + + expect(formData.available_tools).toEqual(['shell', 'read_file']); + }); + + it('should omit empty available tools metadata', () => { + const extension: FixedExtensionEntry = { + type: 'builtin', + name: 'developer', + description: 'developer', + enabled: true, + available_tools: [], + }; + + const formData = extensionToFormData(extension); + + expect(formData).not.toHaveProperty('available_tools'); + }); + it('should not escape @ in command args', () => { const extension: FixedExtensionEntry = { type: 'stdio', @@ -320,6 +348,73 @@ describe('Extension Utils', () => { timeout: 300, }); }); + + it('should create sse extension config', () => { + const formData = { + name: 'test-sse', + description: 'Test SSE extension', + type: 'sse' as const, + cmd: '', + endpoint: 'http://api.example.com/sse', + enabled: true, + timeout: 300, + envVars: [], + headers: [], + }; + + const config = createExtensionConfig(formData); + + expect(config).toEqual({ + type: 'sse', + name: 'test-sse', + description: 'Test SSE extension', + uri: 'http://api.example.com/sse', + }); + }); + + it('should preserve available tools metadata', () => { + const formData = { + name: 'developer', + description: 'developer', + type: 'builtin' as const, + cmd: '', + endpoint: '', + enabled: true, + timeout: 300, + envVars: [], + headers: [], + available_tools: ['shell', 'read_file'], + }; + + const config = createExtensionConfig(formData); + + expect(config).toEqual({ + type: 'builtin', + name: 'developer', + description: 'developer', + timeout: 300, + available_tools: ['shell', 'read_file'], + }); + }); + + it('should omit empty available tools metadata', () => { + const formData = { + name: 'developer', + description: 'developer', + type: 'builtin' as const, + cmd: '', + endpoint: '', + enabled: true, + timeout: 300, + envVars: [], + headers: [], + available_tools: [], + }; + + const config = createExtensionConfig(formData); + + expect(config).not.toHaveProperty('available_tools'); + }); }); describe('splitCmdAndArgs', () => { diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 121726102..05f0dc3e7 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -38,6 +38,7 @@ export interface ExtensionFormData { isEdited?: boolean; }[]; installation_notes?: string; + available_tools?: string[]; } export function getDefaultFormData(): ExtensionFormData { @@ -95,6 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo ); } + const availableTools = + 'available_tools' in extension + ? availableToolsOrUndefined(extension.available_tools) + : undefined; + return { name: extension.name || '', description: extension.description || '', @@ -116,9 +122,19 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo installation_notes: (extension as Record)['installation_notes'] as | string | undefined, + ...(availableTools ? { available_tools: availableTools } : {}), }; } +function availableToolsOrUndefined(availableTools?: string[] | null): string[] | undefined { + return availableTools && availableTools.length > 0 ? availableTools : undefined; +} + +function availableToolsConfig(availableTools?: string[] | null) { + const normalized = availableToolsOrUndefined(availableTools); + return normalized ? { available_tools: normalized } : undefined; +} + export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig { // Extract just the keys from env vars const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0); @@ -135,6 +151,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon args: args, timeout: formData.timeout, ...(env_keys.length > 0 ? { env_keys } : {}), + ...availableToolsConfig(formData.available_tools), }; } else if (formData.type === 'streamable_http') { // Extract headers @@ -156,14 +173,22 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon uri: formData.endpoint || '', ...(env_keys.length > 0 ? { env_keys } : {}), headers, + ...availableToolsConfig(formData.available_tools), }; - } else { - // For other types + } else if (formData.type === 'builtin') { return { type: formData.type, name: formData.name, description: formData.description, timeout: formData.timeout, + ...availableToolsConfig(formData.available_tools), + }; + } else { + return { + type: formData.type, + name: formData.name, + description: formData.description, + uri: formData.endpoint || '', }; } } diff --git a/ui/desktop/src/recipe/validation.test.ts b/ui/desktop/src/recipe/validation.test.ts index 35f51fd73..99aa1268e 100644 --- a/ui/desktop/src/recipe/validation.test.ts +++ b/ui/desktop/src/recipe/validation.test.ts @@ -39,7 +39,7 @@ describe('Recipe Validation', () => { expect(schemaJson).not.toContain('sse'); expect(schemaJson).not.toContain('frontend'); expect(schemaJson).not.toContain('inline_python'); - expect(schemaJson).not.toContain('available_tools'); + expect(schemaJson).toContain('available_tools'); }); }); }); diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 6360032df..3143e02aa 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -15,12 +15,20 @@ export type GooseExtension = { display_name?: string | null; timeout?: number | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'builtin'; } | { name: string; description?: string | null; display_name?: string | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'platform'; } | { server: McpServer; @@ -29,6 +37,10 @@ export type GooseExtension = { timeout?: number | null; socket?: string | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'mcp'; }; @@ -1527,12 +1539,20 @@ export type RecipeExtensionDto = { display_name?: string | null; timeout?: number | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'builtin'; } | { name: string; description?: string | null; display_name?: string | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'platform'; } | { name: string; @@ -1546,6 +1566,10 @@ export type RecipeExtensionDto = { timeout?: number | null; cwd?: string | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'stdio'; } | { name: string; @@ -1561,6 +1585,10 @@ export type RecipeExtensionDto = { timeout?: number | null; socket?: string | null; bundled?: boolean | null; + /** + * Tool allowlist for this extension. Omit this field to allow all tools. + */ + available_tools?: Array | null; type: 'streamable_http'; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index bbe1b7f7f..cabac4a80 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -134,6 +134,10 @@ export const zGooseExtension = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('builtin') }), z.object({ @@ -150,6 +154,10 @@ export const zGooseExtension = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('platform') }), z.object({ @@ -171,6 +179,10 @@ export const zGooseExtension = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('mcp') }) ]); @@ -1464,6 +1476,10 @@ export const zRecipeExtensionDto = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('builtin') }), z.object({ @@ -1480,6 +1496,10 @@ export const zRecipeExtensionDto = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('platform') }), z.object({ @@ -1504,6 +1524,10 @@ export const zRecipeExtensionDto = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('stdio') }), z.object({ @@ -1528,6 +1552,10 @@ export const zRecipeExtensionDto = z.union([ z.boolean(), z.null() ]).optional(), + available_tools: z.union([ + z.array(z.string()), + z.null() + ]).optional(), type: z.literal('streamable_http') }) ]);