Skip to main content
Tools are the primary way extensions provide functionality to Goose. This guide covers best practices for designing and implementing effective tools.

What is a Tool?

A tool is a function that:
  • Has a well-defined interface (name, parameters, schema)
  • Performs a specific operation
  • Returns a result to the agent
  • Can be called by the LLM during agent execution
Tools are exposed via MCP and called by the agent when needed.

Tool Anatomy

Every tool consists of:
Tool {
    name: "tool_name".into(),
    description: Some("What this tool does".to_string()),
    input_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "param_name": {
                "type": "string",
                "description": "Parameter description"
            }
        },
        "required": ["param_name"]
    }),
}

Name

  • Use snake_case
  • Be descriptive but concise
  • Prefix with domain if needed (e.g., file_read, db_query)
Good:
  • read_file
  • search_code
  • execute_command
Bad:
  • rf (too short)
  • readTheContentsOfAFile (too verbose)
  • do_stuff (not descriptive)

Description

Provide a clear description that explains:
  • What the tool does
  • When to use it
  • Important limitations or requirements
description: Some(
    "Read the contents of a file from the filesystem. \
    Returns the file contents as text. \
    Fails if the file doesn't exist or is not readable by the current user."
    .to_string()
)

Input Schema

Define parameters using JSON Schema:
{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "Path to the file to read"
    },
    "encoding": {
      "type": "string",
      "description": "File encoding (default: utf-8)",
      "enum": ["utf-8", "ascii", "latin1"]
    }
  },
  "required": ["path"]
}

Implementing Tool Logic

Basic Implementation

async fn call_tool(
    &self,
    params: CallToolRequestParams,
) -> rmcp::Result<CallToolResult> {
    match params.name.as_str() {
        "read_file" => self.read_file_tool(params).await,
        "write_file" => self.write_file_tool(params).await,
        _ => Err(rmcp::Error::method_not_found(
            format!("Unknown tool: {}", params.name)
        )),
    }
}

async fn read_file_tool(
    &self,
    params: CallToolRequestParams,
) -> rmcp::Result<CallToolResult> {
    // 1. Extract and validate parameters
    let path = params.arguments
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| rmcp::Error::invalid_params(
            "Missing required parameter: path"
        ))?;

    // 2. Perform operation
    let contents = match std::fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(e) => {
            return Ok(CallToolResult {
                content: vec![ToolResponseContent::text(
                    format!("Failed to read file: {}", e)
                )],
                is_error: Some(true),
            });
        }
    };

    // 3. Return result
    Ok(CallToolResult {
        content: vec![ToolResponseContent::text(contents)],
        is_error: Some(false),
    })
}

Parameter Extraction Helpers

Create helper functions for common parameter types:
fn get_string_param(
    params: &serde_json::Map<String, serde_json::Value>,
    name: &str,
) -> rmcp::Result<String> {
    params
        .get(name)
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| rmcp::Error::invalid_params(
            format!("Missing or invalid parameter: {}", name)
        ))
}

fn get_optional_string_param(
    params: &serde_json::Map<String, serde_json::Value>,
    name: &str,
) -> Option<String> {
    params.get(name).and_then(|v| v.as_str()).map(|s| s.to_string())
}

fn get_bool_param(
    params: &serde_json::Map<String, serde_json::Value>,
    name: &str,
    default: bool,
) -> bool {
    params.get(name).and_then(|v| v.as_bool()).unwrap_or(default)
}
Usage:
let path = get_string_param(&params.arguments, "path")?;
let encoding = get_optional_string_param(&params.arguments, "encoding")
    .unwrap_or_else(|| "utf-8".to_string());
let recursive = get_bool_param(&params.arguments, "recursive", false);

Tool Design Patterns

1. Read-Only Tools

Tools that only read data, never modify:
Tool {
    name: "list_files".into(),
    description: Some("List files in a directory".to_string()),
    input_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "pattern": {
                "type": "string",
                "description": "Glob pattern to filter files"
            }
        },
        "required": ["path"]
    }),
}

2. Write/Modify Tools

Tools that modify state:
Tool {
    name: "write_file".into(),
    description: Some(
        "Write content to a file. Creates parent directories if needed. \
        WARNING: Overwrites existing files.".to_string()
    ),
    input_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "content": {"type": "string"},
            "append": {
                "type": "boolean",
                "description": "Append instead of overwrite",
                "default": false
            }
        },
        "required": ["path", "content"]
    }),
}

3. Query Tools

Tools that search or filter:
Tool {
    name: "search_code".into(),
    description: Some(
        "Search for code patterns using regex. \
        Returns file paths and line numbers.".to_string()
    ),
    input_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "pattern": {
                "type": "string",
                "description": "Regex pattern to search for"
            },
            "directory": {
                "type": "string",
                "description": "Directory to search in"
            },
            "file_pattern": {
                "type": "string",
                "description": "File glob pattern (e.g., '*.rs')"
            }
        },
        "required": ["pattern", "directory"]
    }),
}

4. Action Tools

Tools that perform actions:
Tool {
    name: "execute_command".into(),
    description: Some(
        "Execute a shell command. \
        Returns stdout, stderr, and exit code. \
        WARNING: Commands run with current user permissions.".to_string()
    ),
    input_schema: serde_json::json!({
        "type": "object",
        "properties": {
            "command": {"type": "string"},
            "working_dir": {
                "type": "string",
                "description": "Working directory (default: current)"
            },
            "timeout_seconds": {
                "type": "integer",
                "description": "Max execution time",
                "default": 30
            }
        },
        "required": ["command"]
    }),
}

Error Handling

Informative Error Messages

Provide context in error messages:
match std::fs::read_to_string(&path) {
    Ok(contents) => Ok(CallToolResult {
        content: vec![ToolResponseContent::text(contents)],
        is_error: Some(false),
    }),
    Err(e) => Ok(CallToolResult {
        content: vec![ToolResponseContent::text(
            format!("Failed to read file '{}': {}", path, e)
        )],
        is_error: Some(true),
    }),
}

Validation Errors

if !path.exists() {
    return Ok(CallToolResult {
        content: vec![ToolResponseContent::text(
            format!("File not found: {}", path.display())
        )],
        is_error: Some(true),
    });
}

if !path.is_file() {
    return Ok(CallToolResult {
        content: vec![ToolResponseContent::text(
            format!("Path is not a file: {}", path.display())
        )],
        is_error: Some(true),
    });
}

Permission Errors

match std::fs::metadata(&path) {
    Ok(metadata) => {
        if metadata.permissions().readonly() {
            return Ok(CallToolResult {
                content: vec![ToolResponseContent::text(
                    "Cannot write to read-only file".to_string()
                )],
                is_error: Some(true),
            });
        }
    }
    Err(e) => {
        return Ok(CallToolResult {
            content: vec![ToolResponseContent::text(
                format!("Permission denied: {}", e)
            )],
            is_error: Some(true),
        });
    }
}

Return Types

Text Results

Most common return type:
Ok(CallToolResult {
    content: vec![ToolResponseContent::text(
        "Operation completed successfully".to_string()
    )],
    is_error: Some(false),
})

Structured Data

Return JSON for structured data:
let result = serde_json::json!({
    "files": [
        {"name": "file1.txt", "size": 1024},
        {"name": "file2.txt", "size": 2048}
    ],
    "total": 2
});

Ok(CallToolResult {
    content: vec![ToolResponseContent::text(
        serde_json::to_string_pretty(&result)?
    )],
    is_error: Some(false),
})

Multiple Content Items

Return multiple pieces of content:
Ok(CallToolResult {
    content: vec![
        ToolResponseContent::text("Summary: 5 files found".to_string()),
        ToolResponseContent::text(
            serde_json::to_string_pretty(&files)?
        ),
    ],
    is_error: Some(false),
})

Best Practices

1. Single Responsibility

Each tool should do one thing well: Good:
  • read_file - reads a file
  • write_file - writes a file
  • list_files - lists files
Bad:
  • file_operations - does everything

2. Idempotency

When possible, make tools idempotent:
// Good: Creating directory is idempotent
if !path.exists() {
    std::fs::create_dir_all(&path)?;
}

// Returns success whether dir was created or already existed

3. Safe Defaults

Choose safe defaults for optional parameters:
input_schema: serde_json::json!({
    "properties": {
        "overwrite": {
            "type": "boolean",
            "description": "Overwrite existing file",
            "default": false  // Safe default
        },
        "recursive": {
            "type": "boolean",
            "description": "Delete recursively",
            "default": false  // Safe default
        }
    }
})

4. Timeouts

Set reasonable timeouts for long operations:
use tokio::time::timeout;
use std::time::Duration;

let result = timeout(
    Duration::from_secs(30),
    perform_operation()
).await;

match result {
    Ok(Ok(value)) => Ok(CallToolResult {
        content: vec![ToolResponseContent::text(value)],
        is_error: Some(false),
    }),
    Ok(Err(e)) => Ok(CallToolResult {
        content: vec![ToolResponseContent::text(
            format!("Operation failed: {}", e)
        )],
        is_error: Some(true),
    }),
    Err(_) => Ok(CallToolResult {
        content: vec![ToolResponseContent::text(
            "Operation timed out after 30 seconds".to_string()
        )],
        is_error: Some(true),
    }),
}

5. Input Validation

Validate inputs thoroughly:
// Path validation
let path = PathBuf::from(path_str);
if path.is_absolute() {
    return Err(rmcp::Error::invalid_params(
        "Absolute paths not allowed"
    ));
}

// Pattern validation
let regex = regex::Regex::new(pattern)
    .map_err(|e| rmcp::Error::invalid_params(
        format!("Invalid regex pattern: {}", e)
    ))?;

// Range validation
if timeout_seconds > 300 {
    return Err(rmcp::Error::invalid_params(
        "Timeout cannot exceed 300 seconds"
    ));
}

6. Progress for Long Operations

For operations that may take time, provide feedback:
let mut result = String::new();
result.push_str("Processing files...\n");

for (i, file) in files.iter().enumerate() {
    process_file(file)?;
    if i % 10 == 0 {
        result.push_str(&format!("Processed {}/{}\n", i, files.len()));
    }
}

result.push_str(&format!("Complete: {} files processed\n", files.len()));

Testing Tools

Unit Tests

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_read_file_tool() {
        let server = MyExtension::new();
        
        let params = CallToolRequestParams {
            name: "read_file".into(),
            arguments: serde_json::json!({
                "path": "test.txt"
            }).as_object().unwrap().clone(),
        };
        
        let result = server.call_tool(params).await.unwrap();
        assert!(!result.is_error.unwrap_or(false));
    }
    
    #[tokio::test]
    async fn test_read_file_not_found() {
        let server = MyExtension::new();
        
        let params = CallToolRequestParams {
            name: "read_file".into(),
            arguments: serde_json::json!({
                "path": "nonexistent.txt"
            }).as_object().unwrap().clone(),
        };
        
        let result = server.call_tool(params).await.unwrap();
        assert!(result.is_error.unwrap_or(false));
    }
}

Integration Tests

Test tools through the extension manager:
#[tokio::test]
async fn test_tool_integration() {
    let config = ExtensionConfig::builtin("myextension");
    let manager = ExtensionManager::new(
        vec![config],
        CancellationToken::new(),
    ).await.unwrap();
    
    let result = manager.call_tool(
        "read_file",
        serde_json::json!({"path": "test.txt"}),
    ).await.unwrap();
    
    assert!(!result.is_error.unwrap_or(false));
}

Examples from Goose

See real tool implementations in:
  • crates/goose-mcp/src/memory/ - Memory storage tools
  • crates/goose-mcp/src/computercontroller/ - Desktop automation
  • crates/goose-mcp/src/autovisualiser/ - Visualization generation

Next Steps

Build docs developers (and LLMs) love