diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index decbcbb1f..87152d947 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -10,6 +10,7 @@ on: description: 'Whether to perform signing and notarization' required: false default: false + type: boolean secrets: CERTIFICATE_OSX_APPLICATION: description: 'Certificate for macOS application signing' @@ -116,7 +117,25 @@ jobs: run: npm ci working-directory: ui/desktop - - name: Make default Goose App + - name: Make Unsigned App + if: ${{ !inputs.signing }} + run: | + attempt=0 + max_attempts=2 + until [ $attempt -ge $max_attempts ]; do + npm run bundle:default && break + attempt=$((attempt + 1)) + echo "Attempt $attempt failed. Retrying..." + sleep 5 + done + if [ $attempt -ge $max_attempts ]; then + echo "Action failed after $max_attempts attempts." + exit 1 + fi + working-directory: ui/desktop + + - name: Make Signed App + if: ${{ inputs.signing }} run: | attempt=0 max_attempts=2 @@ -132,9 +151,9 @@ jobs: fi working-directory: ui/desktop env: - APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }} - APPLE_ID_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }} - APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Upload Desktop artifact uses: actions/upload-artifact@v4 @@ -176,4 +195,4 @@ jobs: exit 1 fi # Kill the app to clean up - pkill -f "Goose.app/Contents/MacOS/Goose" \ No newline at end of file + pkill -f "Goose.app/Contents/MacOS/Goose" diff --git a/crates/goose-mcp/src/developer2/mod.rs b/crates/goose-mcp/src/developer2/mod.rs index 3a2995521..6f46aa8f7 100644 --- a/crates/goose-mcp/src/developer2/mod.rs +++ b/crates/goose-mcp/src/developer2/mod.rs @@ -620,10 +620,7 @@ impl Router for Developer2Router { } fn capabilities(&self) -> ServerCapabilities { - CapabilitiesBuilder::new() - .with_tools(false) - .with_resources(false, false) - .build() + CapabilitiesBuilder::new().with_tools(false).build() } fn list_tools(&self) -> Vec { diff --git a/crates/goose/src/agents/capabilities.rs b/crates/goose/src/agents/capabilities.rs index fba442e78..2b803944e 100644 --- a/crates/goose/src/agents/capabilities.rs +++ b/crates/goose/src/agents/capabilities.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, TimeZone, Utc}; +use futures::stream::{FuturesUnordered, StreamExt}; use mcp_client::McpService; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::LazyLock; use std::time::Duration; @@ -13,6 +14,7 @@ use crate::providers::base::{Provider, ProviderUsage}; use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; use mcp_client::transport::{SseTransport, StdioTransport, Transport}; use mcp_core::{Content, Tool, ToolCall, ToolError, ToolResult}; +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 @@ -23,6 +25,7 @@ static DEFAULT_TIMESTAMP: LazyLock> = pub struct Capabilities { clients: HashMap>>>, instructions: HashMap, + resource_capable_systems: HashSet, provider: Box, provider_usage: Mutex>, } @@ -80,11 +83,16 @@ impl Capabilities { Self { clients: HashMap::new(), instructions: HashMap::new(), + resource_capable_systems: HashSet::new(), provider, provider_usage: Mutex::new(Vec::new()), } } + pub fn supports_resources(&self) -> bool { + !self.resource_capable_systems.is_empty() + } + /// Add a new MCP system based on the provided client type // TODO IMPORTANT need to ensure this times out if the system command is broken! pub async fn add_system(&mut self, config: SystemConfig) -> SystemResult<()> { @@ -141,6 +149,12 @@ impl Capabilities { .insert(init_result.server_info.name.clone(), instructions); } + // if the server is capable if resources we track it + if init_result.capabilities.resources.is_some() { + self.resource_capable_systems + .insert(sanitize(init_result.server_info.name.clone())); + } + // Store the client self.clients.insert( sanitize(init_result.server_info.name.clone()), @@ -278,7 +292,8 @@ impl Capabilities { .keys() .map(|name| { let instructions = self.instructions.get(name).cloned().unwrap_or_default(); - SystemInfo::new(name, "", &instructions) + let has_resources = self.resource_capable_systems.contains(name); + SystemInfo::new(name, &instructions, has_resources) }) .collect(); @@ -297,25 +312,195 @@ impl Capabilities { .map(Arc::clone) } - /// Dispatch a single tool call to the appropriate client - #[instrument(skip(self, tool_call), fields(input, output))] - pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> ToolResult> { + // Function that gets executed for read_resource tool + 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 system_name = params.get("system_name").and_then(|v| v.as_str()); + + // If system name is provided, we can just look it up + if system_name.is_some() { + let result = self + .read_resource_from_system(uri, system_name.unwrap()) + .await?; + return Ok(result); + } + + // If system name is not provided, we need to search for the resource across all systems + // Loop through each system 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 systems? + // currently it will reutrn the first match and skip any systems + for system_name in self.resource_capable_systems.iter() { + let result = self.read_resource_from_system(uri, system_name).await; + match result { + Ok(result) => return Ok(result), + Err(_) => continue, + } + } + + // None of the systems had the resource so we raise an error + let available_systems = self + .clients + .keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + let error_msg = format!( + "Resource with uri '{}' not found. Here are the available systems: {}", + uri, available_systems + ); + + Err(ToolError::InvalidParameters(error_msg)) + } + + async fn read_resource_from_system( + &self, + uri: &str, + system_name: &str, + ) -> Result, ToolError> { + let available_systems = self + .clients + .keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + let error_msg = format!( + "System '{}' not found. Here are the available systems: {}", + system_name, available_systems + ); + let client = self - .get_client_for_tool(&tool_call.name) - .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + .clients + .get(system_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 mcp_core::resource::ResourceContents::TextResourceContents { text, .. } = content + { + let content_str = format!("{}\n\n{}", uri, text); + result.push(Content::text(content_str)); + } + } + + Ok(result) + } - let tool_name = tool_call - .name - .split("__") - .nth(1) - .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + async fn list_resources_from_system( + &self, + system_name: &str, + ) -> Result, ToolError> { + let client = self.clients.get(system_name).ok_or_else(|| { + ToolError::InvalidParameters(format!("System {} is not valid", system_name)) + })?; let client_guard = client.lock().await; - let result = client_guard - .call_tool(tool_name, tool_call.clone().arguments) + client_guard + .list_resources(None) .await - .map(|result| result.content) - .map_err(|e| ToolError::ExecutionError(e.to_string())); + .map_err(|e| { + ToolError::ExecutionError(format!( + "Unable to list resources for {}, {:?}", + system_name, e + )) + }) + .map(|lr| { + let resource_list = lr + .resources + .into_iter() + .map(|r| format!("{} - {}, uri: ({})", system_name, r.name, r.uri)) + .collect::>() + .join("\n"); + + vec![Content::text(resource_list)] + }) + } + + async fn list_resources(&self, params: Value) -> Result, ToolError> { + let system = params.get("system").and_then(|v| v.as_str()); + + match system { + Some(system_name) => { + // Handle single system case + self.list_resources_from_system(system_name).await + } + None => { + // Handle all systems case using FuturesUnordered + let mut futures = FuturesUnordered::new(); + + // Create futures for each resource_capable_system + for system_name in &self.resource_capable_systems { + futures.push(async move { self.list_resources_from_system(system_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) + } + } + } + + /// Dispatch a single tool call to the appropriate client + #[instrument(skip(self, tool_call), fields(input, output))] + pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> ToolResult> { + let result = if tool_call.name == "platform__read_resource" { + // Check if the tool is read_resource and handle it separately + self.read_resource(tool_call.arguments.clone()).await + } else if tool_call.name == "platform__list_resources" { + self.list_resources(tool_call.arguments.clone()).await + } else { + // Else, dispatch tool call based on the prefix naming convention + let client = self + .get_client_for_tool(&tool_call.name) + .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + + let tool_name = tool_call + .name + .split("__") + .nth(1) + .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + + let client_guard = client.lock().await; + + client_guard + .call_tool(tool_name, tool_call.clone().arguments) + .await + .map(|result| result.content) + .map_err(|e| ToolError::ExecutionError(e.to_string())) + }; debug!( "input" = serde_json::to_string(&tool_call).unwrap(), diff --git a/crates/goose/src/agents/reference.rs b/crates/goose/src/agents/reference.rs index d11b399c4..61f78fbb7 100644 --- a/crates/goose/src/agents/reference.rs +++ b/crates/goose/src/agents/reference.rs @@ -13,7 +13,10 @@ use crate::providers::base::Provider; use crate::providers::base::ProviderUsage; use crate::register_agent; use crate::token_counter::TokenCounter; -use serde_json::Value; +use indoc::indoc; +use mcp_core::tool::Tool; +use serde_json::{json, Value}; + /// Reference implementation of an Agent pub struct ReferenceAgent { capabilities: Mutex, @@ -66,7 +69,52 @@ impl Agent for ReferenceAgent { let mut messages = messages.to_vec(); let reply_span = tracing::Span::current(); let mut capabilities = self.capabilities.lock().await; - let tools = capabilities.get_prefixed_tools().await?; + let mut tools = capabilities.get_prefixed_tools().await?; + // we add in the read_resource tool by default + // TODO: make sure there is no collision with another system's tool name + let read_resource_tool = Tool::new( + "platform__read_resource".to_string(), + indoc! {r#" + Read a resource from a system. + + Resources allow systems to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool searches for the + resource URI in the provided system, and reads in the resource content. If no system + is provided, the tool will search all systems for the resource. + "#}.to_string(), + json!({ + "type": "object", + "required": ["uri"], + "properties": { + "uri": {"type": "string", "description": "Resource URI"}, + "system_name": {"type": "string", "description": "Optional system name"} + } + }), + ); + + let list_resources_tool = Tool::new( + "platform__list_resources".to_string(), + indoc! {r#" + List resources from a system(s). + + Resources allow systems to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool lists resources + in the provided system, and returns a list for the user to browse. If no system + is provided, the tool will search all systems for the resource. + "#}.to_string(), + json!({ + "type": "object", + "properties": { + "system_name": {"type": "string", "description": "Optional system name"} + } + }), + ); + + if capabilities.supports_resources() { + tools.push(read_resource_tool); + tools.push(list_resources_tool); + } + let system_prompt = capabilities.get_system_prompt().await; let _estimated_limit = capabilities .provider() diff --git a/crates/goose/src/agents/system.rs b/crates/goose/src/agents/system.rs index 24b5abb72..41722f535 100644 --- a/crates/goose/src/agents/system.rs +++ b/crates/goose/src/agents/system.rs @@ -114,16 +114,16 @@ impl std::fmt::Display for SystemConfig { #[derive(Clone, Debug, Serialize)] pub struct SystemInfo { name: String, - description: String, instructions: String, + has_resources: bool, } impl SystemInfo { - pub fn new(name: &str, description: &str, instructions: &str) -> Self { + pub fn new(name: &str, instructions: &str, has_resources: bool) -> Self { Self { name: name.to_string(), - description: description.to_string(), instructions: instructions.to_string(), + has_resources, } } } diff --git a/crates/goose/src/prompts/system.md b/crates/goose/src/prompts/system.md index c902b96e0..1ddec1a46 100644 --- a/crates/goose/src/prompts/system.md +++ b/crates/goose/src/prompts/system.md @@ -14,8 +14,10 @@ in your tool specification. {% for system in systems %} ## {{system.name}} -{{system.description}} - +{% if system.has_resources %} +{{system.name}} supports resources, you can use platform__read_resource, +and platform__list_resources on this system. +{% endif %} {% if system.instructions %}### Instructions {{system.instructions}}{% endif %} {% endfor %} diff --git a/ui/desktop/src/bin/npx b/ui/desktop/src/bin/npx new file mode 100755 index 000000000..169e3fb39 --- /dev/null +++ b/ui/desktop/src/bin/npx @@ -0,0 +1,98 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting npx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Install Node.js using hermit +log "Installing Node.js with hermit." +hermit install node >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "node: $(which node)" +log "npx: $(which npx)" + + +log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..." +# Check if GOOSE_NPM_REGISTRY is set and accessible +if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_NPM_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_NPM_REGISTRY" + log "$GOOSE_NPM_REGISTRY is accessible. Using it for npm registry." + export NPM_CONFIG_REGISTRY="$GOOSE_NPM_REGISTRY" + + # Check if GOOSE_NPM_CERT is set and accessible + if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "$GOOSE_NPM_CERT" > /dev/null; then + log "Downloading certificate from: $GOOSE_NPM_CERT" + curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_NPM_CERT" + if [ $? -eq 0 ]; then + log "Certificate downloaded successfully." + export NODE_EXTRA_CA_CERTS=~/.config/goose/mcp-hermit/cert.pem + else + log "Unable to download the certificate. Skipping certificate setup." + fi + else + log "GOOSE_NPM_CERT is either not set or not accessible. Skipping certificate setup." + fi + +else + log "GOOSE_NPM_REGISTRY is either not set or not accessible. Falling back to default npm registry." + export NPM_CONFIG_REGISTRY="https://registry.npmjs.org/" +fi + + + + +# Final step: Execute npx with passed arguments +log "Executing 'npx' command with arguments: $*" +npx "$@" || log "Failed to execute 'npx' with arguments: $*" + +log "npx setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx new file mode 100755 index 000000000..88b9fcec9 --- /dev/null +++ b/ui/desktop/src/bin/uvx @@ -0,0 +1,99 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting uvx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Install UV for python using hermit +log "Installing UV with hermit." +hermit install uv >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "uv: $(which uv)" +log "uvx: $(which uvx)" + + +log "Checking for GOOSE_UV_REGISTRY and GOOSE_UV_CERT environment variables for custom python/pip/UV registry setup..." +# Check if GOOSE_UV_REGISTRY is set and accessible +if [ -n "${GOOSE_UV_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_UV_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_UV_REGISTRY" + log "$GOOSE_UV_REGISTRY is accessible. Using it for UV registry." + export UV_INDEX_URL="$GOOSE_UV_REGISTRY" + + if [ -n "${GOOSE_UV_CERT:-}" ] && curl -s --head --fail "$GOOSE_UV_CERT" > /dev/null; then + log "Downloading certificate from: $GOOSE_UV_CERT" + curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_UV_CERT" + if [ $? -eq 0 ]; then + log "Certificate downloaded successfully." + export SSL_CLIENT_CERT=~/.config/goose/mcp-hermit/cert.pem + else + log "Unable to download the certificate. Skipping certificate setup." + fi + else + log "GOOSE_UV_CERT is either not set or not accessible. Skipping certificate setup." + fi + +else + log "GOOSE_UV_REGISTRY is either not set or not accessible. Falling back to default pip registry." + export UV_INDEX_URL="https://pypi.org/simple" +fi + + + + + + +# Final step: Execute uvx with passed arguments +log "Executing 'uvx' command with arguments: $*" +uvx "$@" || log "Failed to execute 'uvx' with arguments: $*" + +log "uvx setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/config.ts b/ui/desktop/src/config.ts index bc657f357..672448393 100644 --- a/ui/desktop/src/config.ts +++ b/ui/desktop/src/config.ts @@ -39,7 +39,7 @@ export const extendGoosed = async (config: SystemConfig) => { console.error(`System ${config.cmd} is not supported right now`); return; } - + // if its goosed - we will update the path to the binary if (config.cmd === 'goosed') { config = { @@ -47,6 +47,25 @@ export const extendGoosed = async (config: SystemConfig) => { cmd: await window.electron.getBinaryPath('goosed') }; } + + if (config.cmd === 'npx') { + // use our special npx shim which uses hermit. + config = { + ...config, + cmd: await window.electron.getBinaryPath('npx') + }; + } + + if (config.cmd === 'uvx') { + // use our special uvx shim which uses hermit. + config = { + ...config, + cmd: await window.electron.getBinaryPath('uvx') + }; + } + + + } try {