From 15bf378f2818b3d799d93f70babfd0a51a3862b9 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 14:46:51 +1000 Subject: [PATCH 1/9] Add support for inline Python extensions in recipes - Add InlinePython variant to ExtensionConfig - Add support for Python dependencies in uvx execution - Add example recipe and test for word counting - Update documentation with inline Python examples --- crates/goose/Cargo.toml | 2 +- crates/goose/src/agents/extension.rs | 36 +++++++++ crates/goose/src/agents/extension_manager.rs | 48 ++++++++++- crates/goose/src/recipe/mod.rs | 33 ++++++++ .../src/recipe/test_recipes/word_counter.yaml | 81 +++++++++++++++++++ 5 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 crates/goose/src/recipe/test_recipes/word_counter.yaml diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index c435449a69a4..384dd5166524 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -79,6 +79,7 @@ blake3 = "1.5" fs2 = "0.4.3" futures-util = "0.3.31" tokio-stream = "0.1.17" +tempfile = "3.15.0" # Vector database for tool selection lancedb = "0.13" @@ -89,7 +90,6 @@ winapi = { version = "0.3", features = ["wincred"] } [dev-dependencies] criterion = "0.5" -tempfile = "3.15.0" serial_test = "3.2.0" mockall = "0.13.1" wiremock = "0.6.0" diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 30ac368828fc..1021147a653e 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -28,6 +28,8 @@ pub enum ExtensionError { SetupError(String), #[error("Join error occurred during task execution: {0}")] TaskJoinError(#[from] tokio::task::JoinError), + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), } pub type ExtensionResult = Result; @@ -181,6 +183,21 @@ pub enum ExtensionConfig { #[serde(default)] bundled: Option, }, + /// Inline Python code that will be executed using uvx + #[serde(rename = "inline_python")] + InlinePython { + /// The name used to identify this extension + name: String, + /// The Python code to execute + code: String, + /// Description of what the extension does + description: Option, + /// Timeout in seconds + timeout: Option, + /// Python package dependencies required by this extension + #[serde(default)] + dependencies: Option>, + }, } impl Default for ExtensionConfig { @@ -225,6 +242,21 @@ impl ExtensionConfig { } } + pub fn inline_python, T: Into>( + name: S, + code: S, + description: S, + timeout: T, + ) -> Self { + Self::InlinePython { + name: name.into(), + code: code.into(), + description: Some(description.into()), + timeout: Some(timeout.into()), + dependencies: None, + } + } + pub fn with_args(self, args: I) -> Self where I: IntoIterator, @@ -266,6 +298,7 @@ impl ExtensionConfig { Self::Stdio { name, .. } => name, Self::Builtin { name, .. } => name, Self::Frontend { name, .. } => name, + Self::InlinePython { name, .. } => name, } .to_string() } @@ -284,6 +317,9 @@ impl std::fmt::Display for ExtensionConfig { ExtensionConfig::Frontend { name, tools, .. } => { write!(f, "Frontend({}: {} tools)", name, tools.len()) } + ExtensionConfig::InlinePython { name, code, .. } => { + write!(f, "InlinePython({}: {} chars)", name, code.len()) + } } } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index aa8d117297e0..01ee13f0fb72 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::LazyLock; use std::time::Duration; +use tempfile::tempdir; use tokio::sync::Mutex; use tokio::task; use tokio_stream::wrappers::ReceiverStream; @@ -243,6 +244,48 @@ impl ExtensionManager { .await?, ) } + ExtensionConfig::InlinePython { + name, + code, + timeout, + dependencies, + .. + } => { + let temp_dir = tempdir()?; + let file_path = temp_dir.path().join(format!("{}.py", name)); + std::fs::write(&file_path, code)?; + + let mut args = vec![]; + + let standard_deps = vec!["mcp".to_string()]; + + let all_deps: Vec = standard_deps + .into_iter() + .chain(dependencies.unwrap_or_default()) + .collect(); + + // Add each dependency with -r flag + for dep in all_deps { + args.push("-r".to_string()); + args.push(dep); + } + + // Add the script path as the final argument + args.push(file_path.to_str().unwrap().to_string()); + + // Execute using uvx + let transport = StdioTransport::new("uvx", args, HashMap::new()); + let handle = transport.start().await?; + Box::new( + McpClient::connect( + handle, + Duration::from_secs( + timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), + ), + ) + .await?, + ) + } _ => unreachable!(), }; @@ -754,8 +797,11 @@ impl ExtensionManager { } | ExtensionConfig::Stdio { description, name, .. + } + | ExtensionConfig::InlinePython { + description, name, .. } => { - // For SSE/Stdio, use description if available + // For SSE/Stdio/InlinePython, use description if available description .as_ref() .map(|s| s.to_string()) diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index a8604ff7491b..4794bd5e6290 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -363,6 +363,7 @@ impl RecipeBuilder { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn test_from_content_with_json() { @@ -540,6 +541,38 @@ sub_recipes: assert_eq!(author.contact, Some("test@example.com".to_string())); } + #[test] + fn test_inline_python_extension() { + // Test parsing a recipe with an inline Python extension + let recipe_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/recipe/test_recipes/word_counter.yaml"); + let recipe_content = fs::read_to_string(recipe_path).expect("Failed to read recipe file"); + + let recipe = Recipe::from_content(&recipe_content).expect("Failed to parse recipe"); + + // Verify we have one extension + assert!(recipe.extensions.is_some()); + let extensions = recipe.extensions.unwrap(); + assert_eq!(extensions.len(), 1); + + // Verify it's an inline Python extension + match &extensions[0] { + ExtensionConfig::InlinePython { name, code, description, timeout, dependencies } => { + assert_eq!(name, "word_counter"); + assert!(code.contains("from mcp import *")); + assert!(code.contains("@tool(\"count_words\")")); + assert_eq!(description.as_deref(), Some("Count words and provide text statistics with visualization")); + assert_eq!(timeout, &Some(300)); + + // Verify dependencies + let deps = dependencies.as_ref().expect("Should have dependencies"); + assert!(deps.contains(&"numpy".to_string())); + assert!(deps.contains(&"matplotlib".to_string())); + assert!(deps.contains(&"wordcloud".to_string())); + } + _ => panic!("Expected InlinePython extension"), + } + } + #[test] fn test_from_content_with_activities() { let content = r#"{ diff --git a/crates/goose/src/recipe/test_recipes/word_counter.yaml b/crates/goose/src/recipe/test_recipes/word_counter.yaml new file mode 100644 index 000000000000..641024564de2 --- /dev/null +++ b/crates/goose/src/recipe/test_recipes/word_counter.yaml @@ -0,0 +1,81 @@ +version: "1.0.0" +title: "Word Counter" +description: "A recipe that counts words in text using an inline Python extension" + +instructions: | + This recipe provides a simple word counting tool. You can: + 1. Count words in a text string + 2. Get statistics about the text (unique words, average word length) + 3. Generate word clouds from the text + +parameters: + - key: text + input_type: string + requirement: required + description: "The text to analyze" + +extensions: + - type: inline_python + name: word_counter + code: | + from mcp import * + import json + from collections import Counter + import numpy as np + from wordcloud import WordCloud + import matplotlib.pyplot as plt + import base64 + from io import BytesIO + + @tool("count_words") + def count_words(text: str) -> str: + """Count the number of words in a text string and generate statistics""" + words = text.split() + word_count = len(words) + unique_words = len(set(words)) + avg_length = sum(len(word) for word in words) / word_count if word_count > 0 else 0 + + # Generate word cloud + wordcloud = WordCloud(width=800, height=400, background_color='white').generate(text) + + # Save word cloud to base64 + plt.figure(figsize=(10, 5)) + plt.imshow(wordcloud, interpolation='bilinear') + plt.axis('off') + + # Save to bytes + buf = BytesIO() + plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0) + buf.seek(0) + img_base64 = base64.b64encode(buf.getvalue()).decode() + plt.close() + + result = { + "total_words": word_count, + "unique_words": unique_words, + "average_word_length": round(avg_length, 2), + "most_common": dict(Counter(words).most_common(5)), + "wordcloud_base64": img_base64 + } + + return json.dumps(result, indent=2) + + if __name__ == "__main__": + run_server() + timeout: 300 + description: "Count words and provide text statistics with visualization" + dependencies: + - "numpy" + - "matplotlib" + - "wordcloud" + +prompt: | + You are a helpful assistant that can analyze text using word counting tools. + The user has provided the following text to analyze: {{ text }} + + Use the word_counter__count_words tool to analyze this text and provide insights about: + - Total word count + - Number of unique words + - Average word length + - Most commonly used words + - A word cloud visualization of the text \ No newline at end of file From 49882a5e5669468eddfa570a23e5c10116239738 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 14:54:54 +1000 Subject: [PATCH 2/9] Fix uvx dependency flag: use --with instead of -r --- crates/goose/src/agents/extension_manager.rs | 35 ++++++++++++-------- crates/goose/src/recipe/mod.rs | 29 ++++++++++------ 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 01ee13f0fb72..653e3d41cdb6 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -251,30 +251,37 @@ impl ExtensionManager { dependencies, .. } => { + // Create a temporary file for the Python code let temp_dir = tempdir()?; let file_path = temp_dir.path().join(format!("{}.py", name)); std::fs::write(&file_path, code)?; - + + // Build uvx command with dependencies let mut args = vec![]; - - let standard_deps = vec!["mcp".to_string()]; - - let all_deps: Vec = standard_deps - .into_iter() - .chain(dependencies.unwrap_or_default()) - .collect(); - - // Add each dependency with -r flag + + // Add standard dependencies that should always be available + let mut all_deps = vec!["mcp".to_string()]; + + // Add custom dependencies if any + if let Some(deps) = dependencies.as_ref() { + all_deps.extend(deps.iter().cloned()); + } + + // Add each dependency with --with flag for dep in all_deps { - args.push("-r".to_string()); + args.push("--with".to_string()); args.push(dep); } - + // Add the script path as the final argument args.push(file_path.to_str().unwrap().to_string()); - + // Execute using uvx - let transport = StdioTransport::new("uvx", args, HashMap::new()); + let transport = StdioTransport::new( + "uvx", + args, + HashMap::new(), + ); let handle = transport.start().await?; Box::new( McpClient::connect( diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 4794bd5e6290..e830573cefa4 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -543,26 +543,35 @@ sub_recipes: #[test] fn test_inline_python_extension() { - // Test parsing a recipe with an inline Python extension - let recipe_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/recipe/test_recipes/word_counter.yaml"); + let recipe_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/recipe/test_recipes/word_counter.yaml" + ); let recipe_content = fs::read_to_string(recipe_path).expect("Failed to read recipe file"); - + let recipe = Recipe::from_content(&recipe_content).expect("Failed to parse recipe"); - - // Verify we have one extension + assert!(recipe.extensions.is_some()); let extensions = recipe.extensions.unwrap(); assert_eq!(extensions.len(), 1); - - // Verify it's an inline Python extension + match &extensions[0] { - ExtensionConfig::InlinePython { name, code, description, timeout, dependencies } => { + ExtensionConfig::InlinePython { + name, + code, + description, + timeout, + dependencies, + } => { assert_eq!(name, "word_counter"); assert!(code.contains("from mcp import *")); assert!(code.contains("@tool(\"count_words\")")); - assert_eq!(description.as_deref(), Some("Count words and provide text statistics with visualization")); + assert_eq!( + description.as_deref(), + Some("Count words and provide text statistics with visualization") + ); assert_eq!(timeout, &Some(300)); - + // Verify dependencies let deps = dependencies.as_ref().expect("Should have dependencies"); assert!(deps.contains(&"numpy".to_string())); From e6ec48d4658e328126b0529dbbc363aa4842f61d Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 14:56:30 +1000 Subject: [PATCH 3/9] Fix uvx command format: use python command to run script --- crates/goose/src/agents/extension_manager.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 653e3d41cdb6..6ed8771975a8 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -267,13 +267,14 @@ impl ExtensionManager { all_deps.extend(deps.iter().cloned()); } - // Add each dependency with --with flag + // Add dependencies with --with flag for dep in all_deps { args.push("--with".to_string()); args.push(dep); } - // Add the script path as the final argument + // Add python file as command to run + args.push("python".to_string()); args.push(file_path.to_str().unwrap().to_string()); // Execute using uvx From 0a352807e81f5ddde43ca4ce064ca89b66e1fc5b Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 15:00:16 +1000 Subject: [PATCH 4/9] Keep temporary Python files alive for the duration of the extension - Add temp_dirs field to ExtensionManager to store TempDir instances - Store temp dir when creating inline Python extension - Clean up temp dir when removing extension --- crates/goose/src/agents/extension_manager.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 6ed8771975a8..c67f5991b474 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -35,6 +35,7 @@ pub struct ExtensionManager { clients: HashMap, instructions: HashMap, resource_capable_extensions: HashSet, + temp_dirs: HashMap, // Keep temp dirs alive for the lifetime of the extension } /// A flattened representation of a resource used by the agent to prepare inference @@ -105,6 +106,7 @@ impl ExtensionManager { clients: HashMap::new(), instructions: HashMap::new(), resource_capable_extensions: HashSet::new(), + temp_dirs: HashMap::new(), } } @@ -284,7 +286,7 @@ impl ExtensionManager { HashMap::new(), ); let handle = transport.start().await?; - Box::new( + let client = Box::new( McpClient::connect( handle, Duration::from_secs( @@ -292,7 +294,12 @@ impl ExtensionManager { ), ) .await?, - ) + ); + + // Store the temp_dir to keep it alive + self.temp_dirs.insert(sanitized_name.clone(), temp_dir); + + client } _ => unreachable!(), }; @@ -344,6 +351,7 @@ impl ExtensionManager { self.clients.remove(&sanitized_name); self.instructions.remove(&sanitized_name); self.resource_capable_extensions.remove(&sanitized_name); + self.temp_dirs.remove(&sanitized_name); Ok(()) } From 47dfb96f56de679bcf2e50d5ef9695222eb1c5c3 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 15:02:19 +1000 Subject: [PATCH 5/9] Fix tool decorator import in word counter recipe --- crates/goose/src/recipe/test_recipes/word_counter.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/goose/src/recipe/test_recipes/word_counter.yaml b/crates/goose/src/recipe/test_recipes/word_counter.yaml index 641024564de2..7d994a86f9dc 100644 --- a/crates/goose/src/recipe/test_recipes/word_counter.yaml +++ b/crates/goose/src/recipe/test_recipes/word_counter.yaml @@ -19,6 +19,7 @@ extensions: name: word_counter code: | from mcp import * + from mcp.server import tool import json from collections import Counter import numpy as np From 2afea4c9473dbc4c5577b50f09b5e0898dad9798 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 15:16:02 +1000 Subject: [PATCH 6/9] Add required dependencies for inline Python extensions --- .../src/recipe/test_recipes/word_counter.yaml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/goose/src/recipe/test_recipes/word_counter.yaml b/crates/goose/src/recipe/test_recipes/word_counter.yaml index 7d994a86f9dc..221efb1b24f8 100644 --- a/crates/goose/src/recipe/test_recipes/word_counter.yaml +++ b/crates/goose/src/recipe/test_recipes/word_counter.yaml @@ -18,8 +18,7 @@ extensions: - type: inline_python name: word_counter code: | - from mcp import * - from mcp.server import tool + from mcp.server.fastmcp import FastMCP import json from collections import Counter import numpy as np @@ -28,7 +27,9 @@ extensions: import base64 from io import BytesIO - @tool("count_words") + mcp = FastMCP("word_counter") + + @mcp.tool() def count_words(text: str) -> str: """Count the number of words in a text string and generate statistics""" words = text.split() @@ -60,15 +61,19 @@ extensions: } return json.dumps(result, indent=2) - + if __name__ == "__main__": - run_server() + mcp.run() timeout: 300 description: "Count words and provide text statistics with visualization" dependencies: + - "mcp" - "numpy" - "matplotlib" - "wordcloud" + - "beautifulsoup4" + - "html2text" + - "requests" prompt: | You are a helpful assistant that can analyze text using word counting tools. From ed824f7e6e68197ce9e4295e144eb3829168c30e Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 15:49:39 +1000 Subject: [PATCH 7/9] Simplify --- crates/goose/src/recipe/mod.rs | 39 +++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index e830573cefa4..0666003cf4b2 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -543,13 +543,24 @@ sub_recipes: #[test] fn test_inline_python_extension() { - let recipe_path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/recipe/test_recipes/word_counter.yaml" - ); - let recipe_content = fs::read_to_string(recipe_path).expect("Failed to read recipe file"); + let content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "instructions": "Test instructions", + "extensions": [ + { + "type": "inline_python", + "name": "test_python", + "code": "print('hello world')", + "timeout": 300, + "description": "Test python extension", + "dependencies": ["numpy", "matplotlib"] + } + ] + }"#; - let recipe = Recipe::from_content(&recipe_content).expect("Failed to parse recipe"); + let recipe = Recipe::from_content(content).unwrap(); assert!(recipe.extensions.is_some()); let extensions = recipe.extensions.unwrap(); @@ -563,20 +574,14 @@ sub_recipes: timeout, dependencies, } => { - assert_eq!(name, "word_counter"); - assert!(code.contains("from mcp import *")); - assert!(code.contains("@tool(\"count_words\")")); - assert_eq!( - description.as_deref(), - Some("Count words and provide text statistics with visualization") - ); + assert_eq!(name, "test_python"); + assert_eq!(code, "print('hello world')"); + assert_eq!(description.as_deref(), Some("Test python extension")); assert_eq!(timeout, &Some(300)); - - // Verify dependencies - let deps = dependencies.as_ref().expect("Should have dependencies"); + assert!(dependencies.is_some()); + let deps = dependencies.as_ref().unwrap(); assert!(deps.contains(&"numpy".to_string())); assert!(deps.contains(&"matplotlib".to_string())); - assert!(deps.contains(&"wordcloud".to_string())); } _ => panic!("Expected InlinePython extension"), } From 52892bc34793d1b9155ac626f7d756bd02c2f0cd Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 27 Jun 2025 15:54:07 +1000 Subject: [PATCH 8/9] update --- crates/goose/src/agents/extension_manager.rs | 28 ++---- .../src/recipe/test_recipes/word_counter.yaml | 87 ------------------- 2 files changed, 8 insertions(+), 107 deletions(-) delete mode 100644 crates/goose/src/recipe/test_recipes/word_counter.yaml diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index c67f5991b474..14133a1b27e8 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -35,7 +35,7 @@ pub struct ExtensionManager { clients: HashMap, instructions: HashMap, resource_capable_extensions: HashSet, - temp_dirs: HashMap, // Keep temp dirs alive for the lifetime of the extension + temp_dirs: HashMap, } /// A flattened representation of a resource used by the agent to prepare inference @@ -253,38 +253,27 @@ impl ExtensionManager { dependencies, .. } => { - // Create a temporary file for the Python code let temp_dir = tempdir()?; let file_path = temp_dir.path().join(format!("{}.py", name)); std::fs::write(&file_path, code)?; - - // Build uvx command with dependencies + let mut args = vec![]; - - // Add standard dependencies that should always be available + let mut all_deps = vec!["mcp".to_string()]; - - // Add custom dependencies if any + if let Some(deps) = dependencies.as_ref() { all_deps.extend(deps.iter().cloned()); } - - // Add dependencies with --with flag + for dep in all_deps { args.push("--with".to_string()); args.push(dep); } - - // Add python file as command to run + args.push("python".to_string()); args.push(file_path.to_str().unwrap().to_string()); - - // Execute using uvx - let transport = StdioTransport::new( - "uvx", - args, - HashMap::new(), - ); + + let transport = StdioTransport::new("uvx", args, HashMap::new()); let handle = transport.start().await?; let client = Box::new( McpClient::connect( @@ -296,7 +285,6 @@ impl ExtensionManager { .await?, ); - // Store the temp_dir to keep it alive self.temp_dirs.insert(sanitized_name.clone(), temp_dir); client diff --git a/crates/goose/src/recipe/test_recipes/word_counter.yaml b/crates/goose/src/recipe/test_recipes/word_counter.yaml deleted file mode 100644 index 221efb1b24f8..000000000000 --- a/crates/goose/src/recipe/test_recipes/word_counter.yaml +++ /dev/null @@ -1,87 +0,0 @@ -version: "1.0.0" -title: "Word Counter" -description: "A recipe that counts words in text using an inline Python extension" - -instructions: | - This recipe provides a simple word counting tool. You can: - 1. Count words in a text string - 2. Get statistics about the text (unique words, average word length) - 3. Generate word clouds from the text - -parameters: - - key: text - input_type: string - requirement: required - description: "The text to analyze" - -extensions: - - type: inline_python - name: word_counter - code: | - from mcp.server.fastmcp import FastMCP - import json - from collections import Counter - import numpy as np - from wordcloud import WordCloud - import matplotlib.pyplot as plt - import base64 - from io import BytesIO - - mcp = FastMCP("word_counter") - - @mcp.tool() - def count_words(text: str) -> str: - """Count the number of words in a text string and generate statistics""" - words = text.split() - word_count = len(words) - unique_words = len(set(words)) - avg_length = sum(len(word) for word in words) / word_count if word_count > 0 else 0 - - # Generate word cloud - wordcloud = WordCloud(width=800, height=400, background_color='white').generate(text) - - # Save word cloud to base64 - plt.figure(figsize=(10, 5)) - plt.imshow(wordcloud, interpolation='bilinear') - plt.axis('off') - - # Save to bytes - buf = BytesIO() - plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0) - buf.seek(0) - img_base64 = base64.b64encode(buf.getvalue()).decode() - plt.close() - - result = { - "total_words": word_count, - "unique_words": unique_words, - "average_word_length": round(avg_length, 2), - "most_common": dict(Counter(words).most_common(5)), - "wordcloud_base64": img_base64 - } - - return json.dumps(result, indent=2) - - if __name__ == "__main__": - mcp.run() - timeout: 300 - description: "Count words and provide text statistics with visualization" - dependencies: - - "mcp" - - "numpy" - - "matplotlib" - - "wordcloud" - - "beautifulsoup4" - - "html2text" - - "requests" - -prompt: | - You are a helpful assistant that can analyze text using word counting tools. - The user has provided the following text to analyze: {{ text }} - - Use the word_counter__count_words tool to analyze this text and provide insights about: - - Total word count - - Number of unique words - - Average word length - - Most commonly used words - - A word cloud visualization of the text \ No newline at end of file From 56213a2b5ca44ad5f534337a3b636584c0d8eace Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 24 Jul 2025 14:48:14 +1000 Subject: [PATCH 9/9] fixes needed for non bundled --- recipe-inline-python.yaml | 87 +++++++++++++++++++ ui/desktop/openapi.json | 45 ++++++++++ ui/desktop/src/api/types.gen.ts | 22 +++++ .../settings/extensions/ExtensionsSection.tsx | 4 +- .../settings/extensions/bundled-extensions.ts | 2 +- .../subcomponents/ExtensionItem.tsx | 3 +- .../subcomponents/ExtensionList.tsx | 7 +- .../components/settings/extensions/utils.ts | 5 +- .../permission/PermissionRulesModal.tsx | 4 +- .../settings/permission/PermissionSetting.tsx | 4 +- 10 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 recipe-inline-python.yaml diff --git a/recipe-inline-python.yaml b/recipe-inline-python.yaml new file mode 100644 index 000000000000..221efb1b24f8 --- /dev/null +++ b/recipe-inline-python.yaml @@ -0,0 +1,87 @@ +version: "1.0.0" +title: "Word Counter" +description: "A recipe that counts words in text using an inline Python extension" + +instructions: | + This recipe provides a simple word counting tool. You can: + 1. Count words in a text string + 2. Get statistics about the text (unique words, average word length) + 3. Generate word clouds from the text + +parameters: + - key: text + input_type: string + requirement: required + description: "The text to analyze" + +extensions: + - type: inline_python + name: word_counter + code: | + from mcp.server.fastmcp import FastMCP + import json + from collections import Counter + import numpy as np + from wordcloud import WordCloud + import matplotlib.pyplot as plt + import base64 + from io import BytesIO + + mcp = FastMCP("word_counter") + + @mcp.tool() + def count_words(text: str) -> str: + """Count the number of words in a text string and generate statistics""" + words = text.split() + word_count = len(words) + unique_words = len(set(words)) + avg_length = sum(len(word) for word in words) / word_count if word_count > 0 else 0 + + # Generate word cloud + wordcloud = WordCloud(width=800, height=400, background_color='white').generate(text) + + # Save word cloud to base64 + plt.figure(figsize=(10, 5)) + plt.imshow(wordcloud, interpolation='bilinear') + plt.axis('off') + + # Save to bytes + buf = BytesIO() + plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0) + buf.seek(0) + img_base64 = base64.b64encode(buf.getvalue()).decode() + plt.close() + + result = { + "total_words": word_count, + "unique_words": unique_words, + "average_word_length": round(avg_length, 2), + "most_common": dict(Counter(words).most_common(5)), + "wordcloud_base64": img_base64 + } + + return json.dumps(result, indent=2) + + if __name__ == "__main__": + mcp.run() + timeout: 300 + description: "Count words and provide text statistics with visualization" + dependencies: + - "mcp" + - "numpy" + - "matplotlib" + - "wordcloud" + - "beautifulsoup4" + - "html2text" + - "requests" + +prompt: | + You are a helpful assistant that can analyze text using word counting tools. + The user has provided the following text to analyze: {{ text }} + + Use the word_counter__count_words tool to analyze this text and provide insights about: + - Total word count + - Number of unique words + - Average word length + - Most commonly used words + - A word cloud visualization of the text \ No newline at end of file diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 3fa2f9e0b322..442aab0001b5 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1644,6 +1644,51 @@ ] } } + }, + { + "type": "object", + "description": "Inline Python code that will be executed using uvx", + "required": [ + "name", + "code", + "type" + ], + "properties": { + "code": { + "type": "string", + "description": "The Python code to execute" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Python package dependencies required by this extension", + "nullable": true + }, + "description": { + "type": "string", + "description": "Description of what the extension does", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "description": "Timeout in seconds", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "inline_python" + ] + } + } } ], "description": "Represents the different types of MCP extensions that can be added to the manager", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 655688b7ef71..871d6c6b2b52 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -209,6 +209,28 @@ export type ExtensionConfig = { */ tools: Array; type: 'frontend'; +} | { + /** + * The Python code to execute + */ + code: string; + /** + * Python package dependencies required by this extension + */ + dependencies?: Array | null; + /** + * Description of what the extension does + */ + description?: string | null; + /** + * The name used to identify this extension + */ + name: string; + /** + * Timeout in seconds + */ + timeout?: number | null; + type: 'inline_python'; }; export type ExtensionEntry = ExtensionConfig & { diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index e8197bb8d86e..de49b0f76a53 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -55,8 +55,8 @@ export default function ExtensionsSection({ if (a.type !== 'builtin' && b.type === 'builtin') return 1; // Then sort by bundled (handle null/undefined cases) - const aBundled = a.bundled === true; - const bBundled = b.bundled === true; + const aBundled = 'bundled' in a && a.bundled === true; + const bBundled = 'bundled' in b && b.bundled === true; if (aBundled && !bBundled) return -1; if (!aBundled && bBundled) return 1; diff --git a/ui/desktop/src/components/settings/extensions/bundled-extensions.ts b/ui/desktop/src/components/settings/extensions/bundled-extensions.ts index b84bd778dcb8..0a84dee7e478 100644 --- a/ui/desktop/src/components/settings/extensions/bundled-extensions.ts +++ b/ui/desktop/src/components/settings/extensions/bundled-extensions.ts @@ -43,7 +43,7 @@ export async function syncBundledExtensions( const existingExt = existingExtensions.find((ext) => nameToKey(ext.name) === bundledExt.id); // Skip if extension exists and is already marked as bundled - if (existingExt?.bundled) continue; + if (existingExt && 'bundled' in existingExt && existingExt.bundled) continue; // Create the config for this extension let extConfig: ExtensionConfig; diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index 84425926374c..046240b8a98a 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -68,7 +68,8 @@ export default function ExtensionItem({ // Over time we can take the first part of the conditional away as people have bundled: true in their config.yaml entries // allow configuration editing if extension is not a builtin/bundled extension AND isStatic = false - const editable = !(extension.type === 'builtin' || extension.bundled) && !isStatic; + const editable = + !(extension.type === 'builtin' || ('bundled' in extension && extension.bundled)) && !isStatic; return ( voi if (a.type !== 'builtin' && b.type === 'builtin') return 1; // Then sort by bundled (handle null/undefined cases) - const aBundled = a.bundled === true; - const bBundled = b.bundled === true; + const aBundled = 'bundled' in a && a.bundled === true; + const bBundled = 'bundled' in b && b.bundled === true; if (aBundled && !bBundled) return -1; if (!aBundled && bBundled) return 1;