Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing Tools

This walkthrough creates a new tool from scratch. We will build a hypothetical word_count tool that counts words in a file, then register it with the MCP server. The same pattern applies to every tool in the codebase.


Step 1: Create the tool struct

Each tool is a unit struct. Create a new file or add to an existing tool module (e.g. src/tools/file.rs for file-related tools):

#![allow(unused)]
fn main() {
pub struct WordCount;
}

That is it. Tools carry no state — all runtime state lives in ToolContext, which is passed to every call().


Step 2: Implement the Tool trait

The Tool trait lives in src/tools/mod.rs:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
    /// Tool name as exposed over MCP (e.g. "symbols")
    fn name(&self) -> &str;

    /// Short description shown to the LLM
    fn description(&self) -> &str;

    /// JSON Schema for the input parameters
    fn input_schema(&self) -> Value;

    /// Execute the tool with the given input (already parsed from JSON)
    async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value>;
}
}

Here is the complete implementation for WordCount:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use anyhow::Result;
use serde_json::{json, Value};
use crate::tools::{Tool, ToolContext};
use crate::tools::output::OutputGuard;

pub struct WordCount;

#[async_trait]
impl Tool for WordCount {
    fn name(&self) -> &str {
        "word_count"
    }

    fn description(&self) -> &str {
        "Count words in a file. Returns total word count and line count."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "required": ["path"],
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path (absolute or relative to project root)"
                }
            }
        })
    }

    async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
        // 1. Read and validate parameters
        let path_str = input["path"]
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?;

        // 2. Resolve and security-validate the path
        let project_root = ctx.agent.project_root().await;
        let security = ctx.agent.security_config().await;
        let path = crate::util::path_security::validate_read_path(
            path_str,
            project_root.as_deref(),
            &security,
        )?;

        // 3. Do the actual work
        if !path.exists() {
            anyhow::bail!("File not found: {}", path.display());
        }
        let content = std::fs::read_to_string(&path)?;
        let word_count = content.split_whitespace().count();
        let line_count = content.lines().count();

        // 4. Return results as JSON
        Ok(json!({
            "file": path.display().to_string(),
            "words": word_count,
            "lines": line_count,
        }))
    }
}
}

Key patterns in call()

Parameter extraction. The input value is already parsed JSON. Use input["field"].as_str(), .as_u64(), .as_bool(), etc. Always handle missing required fields with a clear error message.

Path resolution. For any tool that reads files, always validate through validate_read_path() (or validate_write_path() for write tools). This resolves relative paths against the project root and blocks access to sensitive system directories.

#![allow(unused)]
fn main() {
let project_root = ctx.agent.project_root().await;
let security = ctx.agent.security_config().await;
let path = crate::util::path_security::validate_read_path(
    path_str,
    project_root.as_deref(),
    &security,
)?;
}

Using ctx.agent. The agent provides project state:

  • ctx.agent.project_root().await — active project root (Option<PathBuf>)
  • ctx.agent.require_project_root().await? — same but returns an error if no project is active
  • ctx.agent.security_config().await — path security configuration
  • ctx.agent.with_project(|proj| { ... }).await? — access config, memory store

Using ctx.lsp. The LSP manager provides language server access:

  • ctx.lsp.get_or_start(language, workspace_root).await? — get or launch an LSP client
  • ctx.lsp.get(language).await — get an existing client without starting one

Error handling. Use anyhow::bail!() for validation errors and ? for propagating internal errors. The server catches all errors and surfaces them to the LLM as text content (see The Tool Trait for details).

Return value. Always return Ok(json!({...})). The server serializes this to pretty-printed JSON and wraps it in an MCP CallToolResult.


Step 3: Register the tool

Add the tool to the tool vector in src/server.rs in the from_parts() method:

#![allow(unused)]
fn main() {
pub async fn from_parts(agent: Agent, lsp: Arc<LspManager>) -> Self {
    // ...
    let tools: Vec<Arc<dyn Tool>> = vec![
        // File tools
        Arc::new(ReadFile),
        Arc::new(ListDir),
        Arc::new(SearchForPattern),
        Arc::new(CreateTextFile),
        Arc::new(FindFile),
        Arc::new(ReplaceContent),
        Arc::new(EditLines),
        // ... other categories ...
        // Add your tool:
        Arc::new(WordCount),
    ];
    // ...
}
}

Tools are dispatched dynamically by name. The list_tools handler iterates this vector to build the MCP tool list, and call_tool looks up tools by matching tool.name() against the request. No other registration is needed.

Remember to add the use import at the top of server.rs:

#![allow(unused)]
fn main() {
use crate::tools::file::WordCount;  // or wherever you placed it
}

Step 4: Using OutputGuard

If your tool returns a list of items that could be large, integrate the progressive disclosure system. OutputGuard enforces two modes:

  • Exploring (default): caps output at 200 items, no pagination
  • Focused (detail_level: "full"): paginated with offset/limit

Add the standard parameters to your schema:

#![allow(unused)]
fn main() {
fn input_schema(&self) -> Value {
    json!({
        "type": "object",
        "required": ["path"],
        "properties": {
            "path": {
                "type": "string",
                "description": "File path or directory"
            },
            "detail_level": {
                "type": "string",
                "description": "Output detail: omit for compact (default), 'full' for complete"
            },
            "offset": {
                "type": "integer",
                "description": "Skip this many results (focused mode pagination)"
            },
            "limit": {
                "type": "integer",
                "description": "Max results per page (focused mode, default 50)"
            }
        }
    })
}
}

Then use OutputGuard in your call():

#![allow(unused)]
fn main() {
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
    let guard = OutputGuard::from_input(&input);

    // ... collect all results ...
    let all_items: Vec<SomeType> = do_work();

    // Cap the output according to the active mode
    let (items, overflow) = guard.cap_items(all_items, "Narrow with a path filter");

    let mut result = json!({
        "items": items,
        "total": items.len(),
    });

    // Attach overflow metadata so the LLM knows there is more
    if let Some(info) = overflow {
        result["overflow"] = OutputGuard::overflow_json(&info);
    }

    Ok(result)
}
}

The overflow JSON tells the LLM how many results exist and how to get the next page:

{
  "shown": 200,
  "total": 1423,
  "hint": "Narrow with a path filter",
  "next_offset": 200
}

Use cap_items() for result lists and cap_files() for file lists. The semantics are the same; the distinction exists so you can configure different caps for items vs files if needed.

Use guard.should_include_body() to decide whether to include full source bodies in symbol results:

#![allow(unused)]
fn main() {
if guard.should_include_body() {
    // Include the "body" field with source text
}
}

See The Tool Trait for the full OutputGuard API reference.


Step 5: Testing

Tools are tested by constructing a ToolContext with a test agent and calling tool.call() directly. The pattern from src/server.rs tests:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;
    use crate::agent::Agent;
    use crate::lsp::LspManager;
    use std::sync::Arc;

    async fn make_ctx() -> (tempfile::TempDir, ToolContext) {
        let dir = tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
        let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
        let lsp = Arc::new(LspManager::new());
        let ctx = ToolContext { agent, lsp };
        (dir, ctx)
    }

    #[tokio::test]
    async fn word_count_basic() {
        let (dir, ctx) = make_ctx().await;

        // Create a test file
        let test_file = dir.path().join("hello.txt");
        std::fs::write(&test_file, "hello world\nfoo bar baz\n").unwrap();

        let tool = WordCount;
        let result = tool.call(
            json!({ "path": test_file.to_str().unwrap() }),
            &ctx,
        ).await.unwrap();

        assert_eq!(result["words"], 5);
        assert_eq!(result["lines"], 2);
    }

    #[tokio::test]
    async fn word_count_missing_file() {
        let (_dir, ctx) = make_ctx().await;

        let tool = WordCount;
        let result = tool.call(
            json!({ "path": "/nonexistent/file.txt" }),
            &ctx,
        ).await;

        assert!(result.is_err());
    }
}
}

Run with:

cargo test word_count

The server-level tests in src/server.rs also verify invariants across all registered tools:

  • server_registers_all_tools — checks that every tool appears in list_tools
  • tool_names_are_unique — no two tools share a name
  • all_tools_have_valid_schemas — every schema is valid JSON with a type field
  • all_tools_have_descriptions — no empty descriptions

These run automatically when you add your tool to from_parts().


Summary

The full recipe:

  1. Create a struct: pub struct MyTool;
  2. Implement Tool with name(), description(), input_schema(), call()
  3. Register in from_parts() with Arc::new(MyTool)
  4. Add OutputGuard if the tool returns unbounded lists
  5. Write tests against ToolContext

For the API reference of Tool, ToolContext, OutputGuard, and error handling, see The Tool Trait.