diff --git a/Cargo.lock b/Cargo.lock index 6f7976f868d4..db81d97590ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2616,7 +2616,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.14.0" +version = "1.15.0" dependencies = [ "ahash", "anyhow", @@ -2699,7 +2699,7 @@ dependencies = [ [[package]] name = "goose-bench" -version = "1.14.0" +version = "1.15.0" dependencies = [ "anyhow", "async-trait", @@ -2722,7 +2722,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.14.0" +version = "1.15.0" dependencies = [ "agent-client-protocol", "anstream", @@ -2775,7 +2775,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.14.0" +version = "1.15.0" dependencies = [ "anyhow", "async-trait", @@ -2787,7 +2787,6 @@ dependencies = [ "docx-rs", "etcetera", "glob", - "goose", "http-body-util", "hyper 1.6.0", "ignore", @@ -2841,7 +2840,7 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.14.0" +version = "1.15.0" dependencies = [ "anyhow", "async-trait", @@ -2879,7 +2878,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.14.0" +version = "1.15.0" dependencies = [ "clap", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4b6b8886460b..a256dc7fc5e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.14.0" +version = "1.15.0" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 328caa6761b8..e0083d774200 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -2,6 +2,10 @@ use anyhow::Result; use clap::{Args, Parser, Subcommand}; use goose::config::{Config, ExtensionConfig}; +use goose_mcp::mcp_server_runner::{serve, McpCommand}; +use goose_mcp::{ + AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, +}; use crate::commands::acp::run_acp_agent; use crate::commands::bench::agent_generator; @@ -420,7 +424,10 @@ enum Command { /// Manage system prompts and behaviors #[command(about = "Run one of the mcp servers bundled with goose")] - Mcp { name: String }, + Mcp { + #[arg(value_parser = clap::value_parser!(McpCommand))] + server: McpCommand, + }, /// Run goose as an ACP (Agent Client Protocol) agent #[command(about = "Run goose as an ACP agent server on stdio")] @@ -877,15 +884,18 @@ pub async fn cli() -> anyhow::Result<()> { ); match cli.command { - Some(Command::Configure {}) => { - handle_configure().await?; - } - Some(Command::Info { verbose }) => { - handle_info(verbose)?; - } - Some(Command::Mcp { name }) => { + Some(Command::Configure {}) => handle_configure().await?, + Some(Command::Info { verbose }) => handle_info(verbose)?, + Some(Command::Mcp { server }) => { + let name = server.name(); crate::logging::setup_logging(Some(&format!("mcp-{name}")), None)?; - goose_mcp::mcp_server_runner::run_mcp_server(&name).await?; + match server { + McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, + McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, + McpCommand::Memory => serve(MemoryServer::new()).await?, + McpCommand::Tutorial => serve(TutorialServer::new()).await?, + McpCommand::Developer => serve(DeveloperServer::new()).await?, + } } Some(Command::Acp {}) => { run_acp_agent().await?; diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 15ce91bc61af..6066502b76b8 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -11,7 +11,6 @@ description.workspace = true workspace = true [dependencies] -goose = { path = "../goose" } rmcp = { version = "0.8.1", features = ["server", "client", "transport-io", "macros"] } anyhow = "1.0.94" tokio = { version = "1", features = ["full"] } @@ -77,13 +76,13 @@ libc = "0.2" # ~1000 downloads). Pinned to exact version to prevent supply chain attacks. mpatch = "=0.2.0" tokio-util = "0.7.16" +clap = { version = "4", features = ["derive"] } [dev-dependencies] serial_test = "3.0.0" sysinfo = "0.32.1" temp-env = "0.3.6" -clap = { version = "4", features = ["derive"] } colored = "2" [features] diff --git a/crates/goose-mcp/src/developer/analyze/formatter.rs b/crates/goose-mcp/src/developer/analyze/formatter.rs index 49bb76cc4509..90529a06c702 100644 --- a/crates/goose-mcp/src/developer/analyze/formatter.rs +++ b/crates/goose-mcp/src/developer/analyze/formatter.rs @@ -2,11 +2,19 @@ use crate::developer::analyze::types::{ AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData, }; use crate::developer::lang; -use goose::utils::safe_truncate; use rmcp::model::{Content, Role}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +fn safe_truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{}...", truncated) + } +} + pub struct Formatter; impl Formatter { diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 609f88bcc507..282e638c554d 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -1,6 +1,7 @@ pub mod analyze; mod editor_models; mod lang; +pub mod paths; mod shell; mod text_editor; diff --git a/crates/goose-mcp/src/developer/paths.rs b/crates/goose-mcp/src/developer/paths.rs new file mode 100644 index 000000000000..29d3f2a0f3bf --- /dev/null +++ b/crates/goose-mcp/src/developer/paths.rs @@ -0,0 +1,112 @@ +use anyhow::Result; +use std::env; +use std::path::PathBuf; +use tokio::process::Command; +use tokio::sync::OnceCell; + +static SHELL_PATH_DIRS: OnceCell, anyhow::Error>> = OnceCell::const_new(); + +pub async fn get_shell_path_dirs() -> Result<&'static Vec> { + let result = SHELL_PATH_DIRS + .get_or_init(|| async { + get_shell_path_async() + .await + .map(|path| env::split_paths(&path).collect()) + }) + .await; + + match result { + Ok(dirs) => Ok(dirs), + Err(e) => Err(anyhow::anyhow!( + "Failed to get shell PATH directories: {}", + e + )), + } +} + +async fn get_shell_path_async() -> Result { + let shell = env::var("SHELL").unwrap_or_else(|_| { + if cfg!(windows) { + "cmd".to_string() + } else { + "/bin/bash".to_string() + } + }); + + if cfg!(windows) { + get_windows_path_async(&shell).await + } else { + get_unix_path_async(&shell).await + } + .or_else(|e| { + tracing::warn!( + "Failed to get PATH from shell ({}), falling back to current PATH", + e + ); + env::var("PATH").map_err(|_| anyhow::anyhow!("No PATH variable available")) + }) +} + +async fn get_unix_path_async(shell: &str) -> Result { + let output = Command::new(shell) + .args(["-l", "-i", "-c", "echo $PATH"]) + .output() + .await + .map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); + } + + let path = String::from_utf8(output.stdout) + .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? + .trim() + .to_string(); + + if path.is_empty() { + return Err(anyhow::anyhow!("Shell returned empty PATH")); + } + + Ok(path) +} + +async fn get_windows_path_async(shell: &str) -> Result { + let shell_name = std::path::Path::new(shell) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("cmd"); + + let output = match shell_name { + "pwsh" | "powershell" => { + Command::new(shell) + .args(["-NoLogo", "-Command", "$env:PATH"]) + .output() + .await + } + _ => { + Command::new(shell) + .args(["/c", "echo %PATH%"]) + .output() + .await + } + }; + + let output = output.map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); + } + + let path = String::from_utf8(output.stdout) + .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? + .trim() + .to_string(); + + if path.is_empty() { + return Err(anyhow::anyhow!("Shell returned empty PATH")); + } + + Ok(path) +} diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index e04132ab94d2..9217ad50f94f 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use base64::Engine; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use include_dir::{include_dir, Dir}; @@ -17,6 +18,8 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, + env::join_paths, + ffi::OsString, future::Future, io::Cursor, path::{Path, PathBuf}, @@ -31,11 +34,11 @@ use tokio::{ use tokio_stream::{wrappers::SplitStream, StreamExt as _}; use tokio_util::sync::CancellationToken; +use crate::developer::{paths::get_shell_path_dirs, shell::ShellConfig}; + use super::analyze::{types::AnalyzeParams, CodeAnalyzer}; use super::editor_models::{create_editor_model, EditorModel}; -use super::shell::{ - configure_shell_command, expand_path, get_shell_config, is_absolute_path, kill_process_group, -}; +use super::shell::{configure_shell_command, expand_path, is_absolute_path, kill_process_group}; use super::text_editor::{ text_editor_insert, text_editor_replace, text_editor_undo, text_editor_view, text_editor_write, }; @@ -179,6 +182,8 @@ pub struct DeveloperServer { pub running_processes: Arc>>, #[cfg(not(test))] running_processes: Arc>>, + bash_env_file: Option, + extend_path_with_shell: bool, } #[tool_handler(router = self.tool_router)] @@ -549,9 +554,21 @@ impl DeveloperServer { prompts: load_prompt_files(), code_analyzer: CodeAnalyzer::new(), running_processes: Arc::new(RwLock::new(HashMap::new())), + extend_path_with_shell: false, + bash_env_file: None, } } + pub fn extend_path_with_shell(mut self, value: bool) -> Self { + self.extend_path_with_shell = value; + self + } + + pub fn bash_env_file(mut self, value: Option) -> Self { + self.bash_env_file = value; + self + } + /// List all available windows that can be used with screen_capture. /// Returns a list of window titles that can be used with the window_title parameter /// of the screen_capture tool. @@ -942,10 +959,34 @@ impl DeveloperServer { peer: &rmcp::service::Peer, cancellation_token: CancellationToken, ) -> Result { - // Get platform-specific shell configuration - let shell_config = get_shell_config(); + let mut shell_config = ShellConfig::default(); + let shell_name = std::path::Path::new(&shell_config.executable) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("bash"); + + if let Some(ref env_file) = self.bash_env_file { + if shell_name == "bash" { + shell_config.envs.push(( + OsString::from("BASH_ENV"), + env_file.clone().into_os_string(), + )) + } + } + + let mut command = configure_shell_command(&shell_config, command); + + if self.extend_path_with_shell { + if let Err(e) = get_shell_path_dirs() + .await + .and_then(|dirs| join_paths(dirs).map_err(|e| anyhow!(e))) + .map(|path| command.env("PATH", path)) + { + tracing::error!("Failed to extend PATH with shell directories: {}", e) + } + } - let mut child = configure_shell_command(&shell_config, command) + let mut child = command .spawn() .map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?; diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index 8f0912cf31fa..51f91dc4faa5 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -1,6 +1,5 @@ use std::{env, ffi::OsString, process::Stdio}; -use goose::config::paths::Paths; #[cfg(unix)] #[allow(unused_imports)] // False positive: trait is used for process_group method use std::os::unix::process::CommandExt; @@ -22,24 +21,10 @@ impl Default for ShellConfig { #[cfg(not(windows))] { let shell = env::var("SHELL").unwrap_or_else(|_| "bash".to_string()); - // Get just the shell name from the path (e.g., /bin/zsh -> zsh) - let shell_name = std::path::Path::new(&shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("bash"); - - // Configure environment based on shell type - let envs = if shell_name == "bash" { - let bash_env = Paths::config_dir().join(".bash_env").into_os_string(); - vec![(OsString::from("BASH_ENV"), bash_env)] - } else { - vec![] - }; - Self { executable: shell, args: vec!["-c".to_string()], // -c is standard across bash/zsh/fish - envs, + envs: vec![], } } } @@ -82,10 +67,6 @@ impl ShellConfig { } } -pub fn get_shell_config() -> ShellConfig { - ShellConfig::default() -} - pub fn expand_path(path_str: &str) -> String { if cfg!(windows) { // Expand Windows environment variables (%VAR%) diff --git a/crates/goose-mcp/src/mcp_server_runner.rs b/crates/goose-mcp/src/mcp_server_runner.rs index 2f15fe53af1f..222890cb93e9 100644 --- a/crates/goose-mcp/src/mcp_server_runner.rs +++ b/crates/goose-mcp/src/mcp_server_runner.rs @@ -1,37 +1,45 @@ -use crate::{ - AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, -}; -use anyhow::{anyhow, Result}; +use std::str::FromStr; + +use anyhow::Result; use rmcp::{transport::stdio, ServiceExt}; -/// Run an MCP server by name -/// -/// This function handles the common logic for starting MCP servers. -/// The caller is responsible for setting up logging before calling this function. -pub async fn run_mcp_server(name: &str) -> Result<()> { - if name == "googledrive" || name == "google_drive" { - return Err(anyhow!( - "the built-in Google Drive extension has been removed" - )); +#[derive(Clone, Debug)] +pub enum McpCommand { + AutoVisualiser, + ComputerController, + Developer, + Memory, + Tutorial, +} + +impl FromStr for McpCommand { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().replace(' ', "").as_str() { + "autovisualiser" => Ok(McpCommand::AutoVisualiser), + "computercontroller" => Ok(McpCommand::ComputerController), + "developer" => Ok(McpCommand::Developer), + "memory" => Ok(McpCommand::Memory), + "tutorial" => Ok(McpCommand::Tutorial), + _ => Err(format!("Invalid command: {}", s)), + } } +} - tracing::info!("Starting MCP server"); - - match name.to_lowercase().replace(' ', "").as_str() { - "autovisualiser" => serve_and_wait(AutoVisualiserRouter::new()).await, - "computercontroller" => serve_and_wait(ComputerControllerServer::new()).await, - "developer" => serve_and_wait(DeveloperServer::new()).await, - "memory" => serve_and_wait(MemoryServer::new()).await, - "tutorial" => serve_and_wait(TutorialServer::new()).await, - _ => { - tracing::warn!("Unknown MCP server name: {}", name); - Err(anyhow!("Unknown MCP server name: {}", name)) +impl McpCommand { + pub fn name(&self) -> &str { + match self { + McpCommand::AutoVisualiser => "autovisualiser", + McpCommand::ComputerController => "computercontroller", + McpCommand::Developer => "developer", + McpCommand::Memory => "memory", + McpCommand::Tutorial => "tutorial", } } } -/// Helper function to run any MCP server with common error handling -async fn serve_and_wait(server: S) -> Result<()> +pub async fn serve(server: S) -> Result<()> where S: rmcp::ServerHandler, { diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index d8e96fddb341..03e04fcde79f 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -7,6 +7,11 @@ mod routes; mod state; use clap::{Parser, Subcommand}; +use goose::config::paths::Paths; +use goose_mcp::{ + mcp_server_runner::{serve, McpCommand}, + AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, +}; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -22,8 +27,8 @@ enum Commands { Agent, /// Run the MCP server Mcp { - /// Name of the MCP server type - name: String, + #[arg(value_parser = clap::value_parser!(McpCommand))] + server: McpCommand, }, } @@ -31,13 +36,27 @@ enum Commands { async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - match &cli.command { + match cli.command { Commands::Agent => { commands::agent::run().await?; } - Commands::Mcp { name } => { - logging::setup_logging(Some(&format!("mcp-{name}")))?; - goose_mcp::mcp_server_runner::run_mcp_server(name).await?; + Commands::Mcp { server } => { + logging::setup_logging(Some(&format!("mcp-{}", server.name())))?; + match server { + McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, + McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, + McpCommand::Memory => serve(MemoryServer::new()).await?, + McpCommand::Tutorial => serve(TutorialServer::new()).await?, + McpCommand::Developer => { + let bash_env = Paths::config_dir().join(".bash_env"); + serve( + DeveloperServer::new() + .extend_path_with_shell(true) + .bash_env_file(Some(bash_env)), + ) + .await? + } + } } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 8b87f06badb4..8364df2dcef9 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.14.0" + "version": "1.15.0" }, "paths": { "/agent/add_extension": { diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 431e285a04c9..82e57e7cdd4b 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "goose-app", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goose-app", - "version": "1.14.0", + "version": "1.15.0", "license": "Apache-2.0", "dependencies": { "@ai-sdk/openai": "^2.0.52", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index b7a799228767..f6624b7cac20 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "Goose", - "version": "1.14.0", + "version": "1.15.0", "description": "Goose App", "engines": { "node": "^22.17.1"