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 activectx.agent.security_config().await— path security configurationctx.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 clientctx.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 withoffset/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 inlist_toolstool_names_are_unique— no two tools share a nameall_tools_have_valid_schemas— every schema is valid JSON with atypefieldall_tools_have_descriptions— no empty descriptions
These run automatically when you add your tool to from_parts().
Summary
The full recipe:
- Create a struct:
pub struct MyTool; - Implement
Toolwithname(),description(),input_schema(),call() - Register in
from_parts()withArc::new(MyTool) - Add
OutputGuardif the tool returns unbounded lists - Write tests against
ToolContext
For the API reference of Tool, ToolContext, OutputGuard, and error
handling, see The Tool Trait.