use anyhow::Result; use chrono::{DateTime, TimeZone, Utc}; use futures::stream::{FuturesUnordered, StreamExt}; use futures::{future, FutureExt}; use rmcp::model::GetPromptResult; 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; use tracing::{error, warn}; use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo}; use super::tool_execution::ToolCallResult; use crate::agents::extension::Envs; use crate::config::{Config, ExtensionConfigManager}; use crate::prompt_template; use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; use mcp_client::transport::{SseTransport, StdioTransport, StreamableHttpTransport, Transport}; use mcp_core::{ToolCall, ToolError}; use rmcp::model::{Content, Prompt, Resource, ResourceContents, Tool}; use serde_json::Value; // By default, we set it to Jan 1, 2020 if the resource does not have a timestamp // This is to ensure that the resource is considered less important than resources with a more recent timestamp static DEFAULT_TIMESTAMP: LazyLock> = LazyLock::new(|| Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()); type McpClientBox = Arc>>; /// Manages Goose extensions / MCP clients and their interactions pub struct ExtensionManager { clients: HashMap, instructions: HashMap, resource_capable_extensions: HashSet, temp_dirs: HashMap, } /// A flattened representation of a resource used by the agent to prepare inference #[derive(Debug, Clone)] pub struct ResourceItem { pub client_name: String, // The name of the client that owns the resource pub uri: String, // The URI of the resource pub name: String, // The name of the resource pub content: String, // The content of the resource pub timestamp: DateTime, // The timestamp of the resource pub priority: f32, // The priority of the resource pub token_count: Option, // The token count of the resource (filled in by the agent) } impl ResourceItem { pub fn new( client_name: String, uri: String, name: String, content: String, timestamp: DateTime, priority: f32, ) -> Self { Self { client_name, uri, name, content, timestamp, priority, token_count: None, } } } /// Sanitizes a string by replacing invalid characters with underscores. /// Valid characters match [a-zA-Z0-9_-] fn normalize(input: String) -> String { let mut result = String::with_capacity(input.len()); for c in input.chars() { result.push(match c { c if c.is_ascii_alphanumeric() || c == '_' || c == '-' => c, c if c.is_whitespace() => continue, // effectively "strip" whitespace _ => '_', // Replace any other non-ASCII character with '_' }); } result.to_lowercase() } pub fn get_parameter_names(tool: &Tool) -> Vec { tool.input_schema .get("properties") .and_then(|props| props.as_object()) .map(|props| props.keys().cloned().collect()) .unwrap_or_default() } impl Default for ExtensionManager { fn default() -> Self { Self::new() } } impl ExtensionManager { /// Create a new ExtensionManager instance pub fn new() -> Self { Self { clients: HashMap::new(), instructions: HashMap::new(), resource_capable_extensions: HashSet::new(), temp_dirs: HashMap::new(), } } pub fn supports_resources(&self) -> bool { !self.resource_capable_extensions.is_empty() } /// Add a new MCP extension based on the provided client type // TODO IMPORTANT need to ensure this times out if the extension command is broken! pub async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(config_name.clone()); /// Helper function to merge environment variables from direct envs and keychain-stored env_keys async fn merge_environments( envs: &Envs, env_keys: &[String], ext_name: &str, ) -> Result, ExtensionError> { let mut all_envs = envs.get_env(); let config_instance = Config::global(); for key in env_keys { // If the Envs payload already contains the key, prefer that value // over looking into the keychain/secret store if all_envs.contains_key(key) { continue; } match config_instance.get(key, true) { Ok(value) => { if value.is_null() { warn!( key = %key, ext_name = %ext_name, "Secret key not found in config (returned null)." ); continue; } // Try to get string value if let Some(str_val) = value.as_str() { all_envs.insert(key.clone(), str_val.to_string()); } else { warn!( key = %key, ext_name = %ext_name, value_type = %value.get("type").and_then(|t| t.as_str()).unwrap_or("unknown"), "Secret value is not a string; skipping." ); } } Err(e) => { error!( key = %key, ext_name = %ext_name, error = %e, "Failed to fetch secret from config." ); return Err(ExtensionError::SetupError(format!( "Failed to fetch secret '{}' from config: {}", key, e ))); } } } Ok(all_envs) } let mut client: Box = match &config { ExtensionConfig::Sse { uri, envs, env_keys, timeout, .. } => { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; let transport = SseTransport::new(uri, all_envs); let handle = transport.start().await?; Box::new( McpClient::connect( handle, Duration::from_secs( timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), ), ) .await?, ) } ExtensionConfig::StreamableHttp { uri, envs, env_keys, headers, timeout, .. } => { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; let transport = StreamableHttpTransport::with_headers(uri, all_envs, headers.clone()); let handle = transport.start().await?; Box::new( McpClient::connect( handle, Duration::from_secs( timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), ), ) .await?, ) } ExtensionConfig::Stdio { cmd, args, envs, env_keys, timeout, .. } => { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; let transport = StdioTransport::new(cmd, args.to_vec(), all_envs); let handle = transport.start().await?; Box::new( McpClient::connect( handle, Duration::from_secs( timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), ), ) .await?, ) } ExtensionConfig::Builtin { name, display_name: _, description: _, timeout, bundled: _, } => { let cmd = std::env::current_exe() .expect("should find the current executable") .to_str() .expect("should resolve executable to string path") .to_string(); let transport = StdioTransport::new( &cmd, vec!["mcp".to_string(), name.clone()], HashMap::new(), ); let handle = transport.start().await?; Box::new( McpClient::connect( handle, Duration::from_secs( timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), ), ) .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 mut all_deps = vec!["mcp".to_string()]; if let Some(deps) = dependencies.as_ref() { all_deps.extend(deps.iter().cloned()); } for dep in all_deps { args.push("--with".to_string()); args.push(dep); } args.push("python".to_string()); args.push(file_path.to_str().unwrap().to_string()); let transport = StdioTransport::new("uvx", args, HashMap::new()); let handle = transport.start().await?; let client = Box::new( McpClient::connect( handle, Duration::from_secs( timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), ), ) .await?, ); self.temp_dirs.insert(sanitized_name.clone(), temp_dir); client } _ => unreachable!(), }; // Initialize the client with default capabilities let info = ClientInfo { name: "goose".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }; let capabilities = ClientCapabilities::default(); let init_result = client .initialize(info, capabilities) .await .map_err(|e| ExtensionError::Initialization(Box::new(config.clone()), e))?; if let Some(instructions) = init_result.instructions { self.instructions .insert(sanitized_name.clone(), instructions); } if init_result.capabilities.resources.is_some() { self.resource_capable_extensions .insert(sanitized_name.clone()); } self.add_client(sanitized_name, client); Ok(()) } pub fn add_client(&mut self, client_name: String, client: Box) { let sanitized_name = normalize(client_name); self.clients .insert(sanitized_name, Arc::new(Mutex::new(client))); } /// Get extensions info pub async fn get_extensions_info(&self) -> Vec { self.clients .keys() .map(|name| { let instructions = self.instructions.get(name).cloned().unwrap_or_default(); let has_resources = self.resource_capable_extensions.contains(name); ExtensionInfo::new(name, &instructions, has_resources) }) .collect() } /// Get aggregated usage statistics pub async fn remove_extension(&mut self, name: &str) -> ExtensionResult<()> { let sanitized_name = normalize(name.to_string()); self.clients.remove(&sanitized_name); self.instructions.remove(&sanitized_name); self.resource_capable_extensions.remove(&sanitized_name); self.temp_dirs.remove(&sanitized_name); Ok(()) } pub async fn suggest_disable_extensions_prompt(&self) -> Value { let enabled_extensions_count = self.clients.len(); let total_tools = self .get_prefixed_tools(None) .await .map(|tools| tools.len()) .unwrap_or(0); // Check if either condition is met const MIN_EXTENSIONS: usize = 5; const MIN_TOOLS: usize = 50; if enabled_extensions_count > MIN_EXTENSIONS || total_tools > MIN_TOOLS { Value::String(format!( "The user currently has enabled {} extensions with a total of {} tools. \ Since this exceeds the recommended limits ({} extensions or {} tools), \ you should ask the user if they would like to disable some extensions for this session.\n\n\ Use the search_available_extensions tool to find extensions available to disable. \ You should only disable extensions found from the search_available_extensions tool. \ List all the extensions available to disable in the response. \ Explain that minimizing extensions helps with the recall of the correct tools to use.", enabled_extensions_count, total_tools, MIN_EXTENSIONS, MIN_TOOLS, )) } else { Value::String(String::new()) // Empty string if under limits } } pub async fn list_extensions(&self) -> ExtensionResult> { Ok(self.clients.keys().cloned().collect()) } /// Get all tools from all clients with proper prefixing pub async fn get_prefixed_tools( &self, extension_name: Option, ) -> ExtensionResult> { // Filter clients based on the provided extension_name or include all if None let filtered_clients = self.clients.iter().filter(|(name, _)| { if let Some(ref name_filter) = extension_name { *name == name_filter } else { true } }); let client_futures = filtered_clients.map(|(name, client)| { let name = name.clone(); let client = client.clone(); task::spawn(async move { let mut tools = Vec::new(); let client_guard = client.lock().await; let mut client_tools = client_guard.list_tools(None).await?; loop { for client_tool in client_tools.tools { let mut tool = Tool::new( format!("{}__{}", name, client_tool.name), client_tool.description.unwrap_or_default(), client_tool.input_schema, ); if tool.annotations.is_some() { tool = tool.annotate(client_tool.annotations.unwrap()) } tools.push(tool); } // Exit loop when there are no more pages if client_tools.next_cursor.is_none() { break; } client_tools = client_guard.list_tools(client_tools.next_cursor).await?; } Ok::, ExtensionError>(tools) }) }); // Collect all results concurrently let results = future::join_all(client_futures).await; // Aggregate tools and handle errors let mut tools = Vec::new(); for result in results { match result { Ok(Ok(client_tools)) => tools.extend(client_tools), Ok(Err(err)) => return Err(err), Err(join_err) => return Err(ExtensionError::from(join_err)), } } Ok(tools) } /// Get client resources and their contents pub async fn get_resources(&self) -> ExtensionResult> { let mut result: Vec = Vec::new(); for (name, client) in &self.clients { let client_guard = client.lock().await; let resources = client_guard.list_resources(None).await?; for resource in resources.resources { // Skip reading the resource if it's not marked active // This avoids blowing up the context with inactive resources if !resource_is_active(&resource) { continue; } if let Ok(contents) = client_guard.read_resource(&resource.uri).await { for content in contents.contents { let (uri, content_str) = match content { ResourceContents::TextResourceContents { uri, text, .. } => (uri, text), ResourceContents::BlobResourceContents { uri, blob, .. } => (uri, blob), }; result.push(ResourceItem::new( name.clone(), uri, resource.name.clone(), content_str, resource.timestamp().unwrap_or(*DEFAULT_TIMESTAMP), resource.priority().unwrap_or(0.0), )); } } } } Ok(result) } /// Get the extension prompt including client instructions pub async fn get_planning_prompt(&self, tools_info: Vec) -> String { let mut context: HashMap<&str, Value> = HashMap::new(); context.insert("tools", serde_json::to_value(tools_info).unwrap()); prompt_template::render_global_file("plan.md", &context).expect("Prompt should render") } /// Find and return a reference to the appropriate client for a tool call fn get_client_for_tool(&self, prefixed_name: &str) -> Option<(&str, McpClientBox)> { self.clients .iter() .find(|(key, _)| prefixed_name.starts_with(*key)) .map(|(name, client)| (name.as_str(), Arc::clone(client))) } // Function that gets executed for read_resource tool pub async fn read_resource(&self, params: Value) -> Result, ToolError> { let uri = params .get("uri") .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("Missing 'uri' parameter".to_string()))?; let extension_name = params.get("extension_name").and_then(|v| v.as_str()); // If extension name is provided, we can just look it up if extension_name.is_some() { let result = self .read_resource_from_extension(uri, extension_name.unwrap()) .await?; return Ok(result); } // If extension name is not provided, we need to search for the resource across all extensions // Loop through each extension and try to read the resource, don't raise an error if the resource is not found // TODO: do we want to find if a provided uri is in multiple extensions? // currently it will return the first match and skip any others for extension_name in self.resource_capable_extensions.iter() { let result = self.read_resource_from_extension(uri, extension_name).await; match result { Ok(result) => return Ok(result), Err(_) => continue, } } // None of the extensions had the resource so we raise an error let available_extensions = self .clients .keys() .map(|s| s.as_str()) .collect::>() .join(", "); let error_msg = format!( "Resource with uri '{}' not found. Here are the available extensions: {}", uri, available_extensions ); Err(ToolError::InvalidParameters(error_msg)) } async fn read_resource_from_extension( &self, uri: &str, extension_name: &str, ) -> Result, ToolError> { let available_extensions = self .clients .keys() .map(|s| s.as_str()) .collect::>() .join(", "); let error_msg = format!( "Extension '{}' not found. Here are the available extensions: {}", extension_name, available_extensions ); let client = self .clients .get(extension_name) .ok_or(ToolError::InvalidParameters(error_msg))?; let client_guard = client.lock().await; let read_result = client_guard.read_resource(uri).await.map_err(|_| { ToolError::ExecutionError(format!("Could not read resource with uri: {}", uri)) })?; let mut result = Vec::new(); for content in read_result.contents { // Only reading the text resource content; skipping the blob content cause it's too long if let ResourceContents::TextResourceContents { text, .. } = content { let content_str = format!("{}\n\n{}", uri, text); result.push(Content::text(content_str)); } } Ok(result) } async fn list_resources_from_extension( &self, extension_name: &str, ) -> Result, ToolError> { let client = self.clients.get(extension_name).ok_or_else(|| { ToolError::InvalidParameters(format!("Extension {} is not valid", extension_name)) })?; let client_guard = client.lock().await; client_guard .list_resources(None) .await .map_err(|e| { ToolError::ExecutionError(format!( "Unable to list resources for {}, {:?}", extension_name, e )) }) .map(|lr| { let resource_list = lr .resources .into_iter() .map(|r| format!("{} - {}, uri: ({})", extension_name, r.name, r.uri)) .collect::>() .join("\n"); vec![Content::text(resource_list)] }) } pub async fn list_resources(&self, params: Value) -> Result, ToolError> { let extension = params.get("extension").and_then(|v| v.as_str()); match extension { Some(extension_name) => { // Handle single extension case self.list_resources_from_extension(extension_name).await } None => { // Handle all extensions case using FuturesUnordered let mut futures = FuturesUnordered::new(); // Create futures for each resource_capable_extension for extension_name in &self.resource_capable_extensions { futures.push(async move { self.list_resources_from_extension(extension_name).await }); } let mut all_resources = Vec::new(); let mut errors = Vec::new(); // Process results as they complete while let Some(result) = futures.next().await { match result { Ok(content) => { all_resources.extend(content); } Err(tool_error) => { errors.push(tool_error); } } } // Log any errors that occurred if !errors.is_empty() { tracing::error!( errors = ?errors .into_iter() .map(|e| format!("{:?}", e)) .collect::>(), "errors from listing resources" ); } Ok(all_resources) } } } pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> Result { // Dispatch tool call based on the prefix naming convention let (client_name, client) = self .get_client_for_tool(&tool_call.name) .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; // rsplit returns the iterator in reverse, tool_name is then at 0 let tool_name = tool_call .name .strip_prefix(client_name) .and_then(|s| s.strip_prefix("__")) .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))? .to_string(); let arguments = tool_call.arguments.clone(); let client = client.clone(); let notifications_receiver = client.lock().await.subscribe().await; let fut = async move { let client_guard = client.lock().await; client_guard .call_tool(&tool_name, arguments) .await .map(|call| call.content) .map_err(|e| ToolError::ExecutionError(e.to_string())) }; Ok(ToolCallResult { result: Box::new(fut.boxed()), notification_stream: Some(Box::new(ReceiverStream::new(notifications_receiver))), }) } pub async fn list_prompts_from_extension( &self, extension_name: &str, ) -> Result, ToolError> { let client = self.clients.get(extension_name).ok_or_else(|| { ToolError::InvalidParameters(format!("Extension {} is not valid", extension_name)) })?; let client_guard = client.lock().await; client_guard .list_prompts(None) .await .map_err(|e| { ToolError::ExecutionError(format!( "Unable to list prompts for {}, {:?}", extension_name, e )) }) .map(|lp| lp.prompts) } pub async fn list_prompts(&self) -> Result>, ToolError> { let mut futures = FuturesUnordered::new(); for extension_name in self.clients.keys() { futures.push(async move { ( extension_name, self.list_prompts_from_extension(extension_name).await, ) }); } let mut all_prompts = HashMap::new(); let mut errors = Vec::new(); // Process results as they complete while let Some(result) = futures.next().await { let (name, prompts) = result; match prompts { Ok(content) => { all_prompts.insert(name.to_string(), content); } Err(tool_error) => { errors.push(tool_error); } } } // Log any errors that occurred if !errors.is_empty() { tracing::debug!( errors = ?errors .into_iter() .map(|e| format!("{:?}", e)) .collect::>(), "errors from listing prompts" ); } Ok(all_prompts) } pub async fn get_prompt( &self, extension_name: &str, name: &str, arguments: Value, ) -> Result { let client = self .clients .get(extension_name) .ok_or_else(|| anyhow::anyhow!("Extension {} not found", extension_name))?; let client_guard = client.lock().await; client_guard .get_prompt(name, arguments) .await .map_err(|e| anyhow::anyhow!("Failed to get prompt: {}", e)) } pub async fn search_available_extensions(&self) -> Result, ToolError> { let mut output_parts = vec![]; // First get disabled extensions from current config let mut disabled_extensions: Vec = vec![]; for extension in ExtensionConfigManager::get_all().expect("should load extensions") { if !extension.enabled { let config = extension.config.clone(); let description = match &config { ExtensionConfig::Builtin { name, display_name, .. } => { // For builtin extensions, use display name if available display_name .as_ref() .map(|s| s.to_string()) .unwrap_or_else(|| name.clone()) } ExtensionConfig::Sse { description, name, .. } | ExtensionConfig::StreamableHttp { description, name, .. } | ExtensionConfig::Stdio { description, name, .. } | ExtensionConfig::InlinePython { description, name, .. } => { // For SSE/StreamableHttp/Stdio/InlinePython, use description if available description .as_ref() .map(|s| s.to_string()) .unwrap_or_else(|| format!("Extension '{}'", name)) } ExtensionConfig::Frontend { name, .. } => { format!("Frontend extension '{}'", name) } }; disabled_extensions.push(format!("- {} - {}", config.name(), description)); } } // Get currently enabled extensions that can be disabled let enabled_extensions: Vec = self.clients.keys().cloned().collect(); // Build output string if !disabled_extensions.is_empty() { output_parts.push(format!( "Extensions available to enable:\n{}\n", disabled_extensions.join("\n") )); } else { output_parts.push("No extensions available to enable.\n".to_string()); } if !enabled_extensions.is_empty() { output_parts.push(format!( "\n\nExtensions available to disable:\n{}\n", enabled_extensions .iter() .map(|name| format!("- {}", name)) .collect::>() .join("\n") )); } else { output_parts.push("No extensions that can be disabled.\n".to_string()); } Ok(vec![Content::text(output_parts.join("\n"))]) } } fn resource_is_active(resource: &Resource) -> bool { resource.priority().is_some_and(|p| (p - 1.0).abs() < 1e-6) } #[cfg(test)] mod tests { use super::*; use mcp_client::client::Error; use mcp_client::client::McpClientTrait; use mcp_core::protocol::{ CallToolResult, InitializeResult, ListPromptsResult, ListResourcesResult, ListToolsResult, ReadResourceResult, }; use rmcp::model::{GetPromptResult, ServerNotification}; use serde_json::json; use tokio::sync::mpsc; struct MockClient {} #[async_trait::async_trait] impl McpClientTrait for MockClient { async fn initialize( &mut self, _info: ClientInfo, _capabilities: ClientCapabilities, ) -> Result { Err(Error::NotInitialized) } async fn list_resources( &self, _next_cursor: Option, ) -> Result { Err(Error::NotInitialized) } async fn read_resource(&self, _uri: &str) -> Result { Err(Error::NotInitialized) } async fn list_tools(&self, _next_cursor: Option) -> Result { Err(Error::NotInitialized) } async fn call_tool(&self, name: &str, _arguments: Value) -> Result { match name { "tool" | "test__tool" => Ok(CallToolResult { content: vec![], is_error: None, }), _ => Err(Error::NotInitialized), } } async fn list_prompts( &self, _next_cursor: Option, ) -> Result { Err(Error::NotInitialized) } async fn get_prompt( &self, _name: &str, _arguments: Value, ) -> Result { Err(Error::NotInitialized) } async fn subscribe(&self) -> mpsc::Receiver { mpsc::channel(1).1 } } #[test] fn test_get_client_for_tool() { let mut extension_manager = ExtensionManager::new(); // Add some mock clients extension_manager.clients.insert( normalize("test_client".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); extension_manager.clients.insert( normalize("__client".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); extension_manager.clients.insert( normalize("__cli__ent__".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); extension_manager.clients.insert( normalize("client 🚀".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); // Test basic case assert!(extension_manager .get_client_for_tool("test_client__tool") .is_some()); // Test leading underscores assert!(extension_manager .get_client_for_tool("__client__tool") .is_some()); // Test multiple underscores in client name, and ending with __ assert!(extension_manager .get_client_for_tool("__cli__ent____tool") .is_some()); // Test unicode in tool name, "client 🚀" should become "client_" assert!(extension_manager .get_client_for_tool("client___tool") .is_some()); } #[tokio::test] async fn test_dispatch_tool_call() { // test that dispatch_tool_call parses out the sanitized name correctly, and extracts // tool_names let mut extension_manager = ExtensionManager::new(); // Add some mock clients extension_manager.clients.insert( normalize("test_client".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); extension_manager.clients.insert( normalize("__cli__ent__".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); extension_manager.clients.insert( normalize("client 🚀".to_string()), Arc::new(Mutex::new(Box::new(MockClient {}))), ); // verify a normal tool call let tool_call = ToolCall { name: "test_client__tool".to_string(), arguments: json!({}), }; let result = extension_manager.dispatch_tool_call(tool_call).await; assert!(result.is_ok()); let tool_call = ToolCall { name: "test_client__test__tool".to_string(), arguments: json!({}), }; let result = extension_manager.dispatch_tool_call(tool_call).await; assert!(result.is_ok()); // verify a multiple underscores dispatch let tool_call = ToolCall { name: "__cli__ent____tool".to_string(), arguments: json!({}), }; let result = extension_manager.dispatch_tool_call(tool_call).await; assert!(result.is_ok()); // Test unicode in tool name, "client 🚀" should become "client_" let tool_call = ToolCall { name: "client___tool".to_string(), arguments: json!({}), }; let result = extension_manager.dispatch_tool_call(tool_call).await; assert!(result.is_ok()); let tool_call = ToolCall { name: "client___test__tool".to_string(), arguments: json!({}), }; let result = extension_manager.dispatch_tool_call(tool_call).await; assert!(result.is_ok()); // this should error out, specifically for an ToolError::ExecutionError let invalid_tool_call = ToolCall { name: "client___tools".to_string(), arguments: json!({}), }; let result = extension_manager .dispatch_tool_call(invalid_tool_call) .await .unwrap() .result .await; assert!(matches!( result.err().unwrap(), ToolError::ExecutionError(_) )); // this should error out, specifically with an ToolError::NotFound // this client doesn't exist let invalid_tool_call = ToolCall { name: "_client__tools".to_string(), arguments: json!({}), }; let result = extension_manager .dispatch_tool_call(invalid_tool_call) .await; if let Err(err) = result { let tool_err = err.downcast_ref::().expect("Expected ToolError"); assert!(matches!(tool_err, ToolError::NotFound(_))); } else { panic!("Expected ToolError::NotFound"); } } }