The Tool Trait
API reference for the Tool trait and its supporting types. For a tutorial
walkthrough, see Writing Tools.
The Tool Trait
Defined 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").
/// Must be unique across all registered tools.
fn name(&self) -> &str;
/// Short description shown to the LLM in the tool listing.
/// Should be one or two sentences explaining what the tool does.
fn description(&self) -> &str;
/// JSON Schema for the tool's input parameters.
/// Must return a valid JSON Schema object with at minimum a "type" field.
fn input_schema(&self) -> Value;
/// Execute the tool with the given input.
/// `input` is already parsed from the MCP request's JSON arguments.
/// Returns a JSON value that will be serialized and sent to the LLM.
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value>;
}
}
name()
Returns the tool’s MCP identifier. This is the string the LLM uses to invoke
the tool. Must be unique across all registered tools — the
tool_names_are_unique test enforces this.
Convention: snake_case, matching the struct name in lowercase
(e.g. Symbols -> "symbols").
description()
A brief explanation shown in the MCP list_tools response. The LLM reads this
to decide which tool to use, so be precise about what the tool does and what it
does not do. Must not be empty.
input_schema()
Returns a JSON Schema object describing the tool’s parameters. Built with
serde_json::json!():
#![allow(unused)]
fn main() {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "File path (absolute or relative to project root)"
},
"detail_level": {
"type": "string",
"description": "Output detail: omit for compact, 'full' for complete"
},
"offset": {
"type": "integer",
"description": "Skip this many results (focused mode pagination)"
},
"limit": {
"type": "integer",
"description": "Max results per page (default 50)"
}
}
})
}
}
Must have "type": "object" at the root. The all_tools_have_valid_schemas
test verifies this for every registered tool.
call()
The main execution method. Receives the parsed JSON input and a ToolContext.
Returns Result<Value> — see Error Handling below.
ToolContext
Defined in src/tools/mod.rs:
#![allow(unused)]
fn main() {
pub struct ToolContext {
pub agent: Agent,
pub lsp: Arc<LspManager>,
}
}
A ToolContext is constructed fresh for each tool invocation in the server’s
call_tool() handler. Both fields are cheaply cloneable (Agent wraps an
Arc internally).
agent: Agent
The Agent holds the active project state. Key methods:
| Method | Returns | Description |
|---|---|---|
project_root().await | Option<PathBuf> | Active project root, or None |
require_project_root().await? | Result<PathBuf> | Same, but errors if no project |
security_config().await | PathSecurityConfig | Path deny-list configuration |
with_project(|proj| { ... }).await? | Result<T> | Access ActiveProject (config, memory) |
activate(path).await? | Result<()> | Switch active project |
The ActiveProject struct (accessible via with_project) contains:
| Field | Type | Description |
|---|---|---|
root | PathBuf | Project root directory |
config | ProjectConfig | Settings from .codescout/project.toml |
memory | MemoryStore | Markdown-based key-value store |
lsp: Arc<LspManager>
The LspManager manages LSP server lifecycles. Key methods:
| Method | Returns | Description |
|---|---|---|
get_or_start(lang, root).await? | Result<Arc<LspClient>> | Get or launch an LSP server |
get(lang).await | Option<Arc<LspClient>> | Get existing client without starting |
active_languages().await | Vec<String> | Languages with running servers |
shutdown_all().await | () | Stop all servers |
get_or_start() is the primary entry point. It starts a new server if none
exists, or returns the existing one if it is alive and pointed at the correct
workspace root. Dead or mismatched servers are automatically replaced.
OutputGuard
The progressive disclosure system. Defined in src/tools/output.rs.
OutputMode
#![allow(unused)]
fn main() {
pub enum OutputMode {
/// Compact output, capped at max_results / max_files.
Exploring,
/// Full detail with offset/limit pagination.
Focused,
}
}
OutputGuard struct
#![allow(unused)]
fn main() {
pub struct OutputGuard {
pub mode: OutputMode,
pub max_files: usize, // Default: 200
pub max_results: usize, // Default: 200
pub offset: usize, // Default: 0
pub limit: usize, // Default: 50
}
}
OutputGuard::from_input(input: &Value) -> Self
Constructs an OutputGuard from a tool’s JSON input by reading three optional
fields:
| Input field | Effect |
|---|---|
detail_level: "full" | Switches to Focused mode |
offset: N | Sets pagination offset (default 0) |
limit: N | Sets page size (default 50); also caps exploring mode when explicit |
Any other detail_level value (or omission) defaults to Exploring mode.
#![allow(unused)]
fn main() {
let guard = OutputGuard::from_input(&input);
}
guard.should_include_body() -> bool
Returns true in Focused mode, false in Exploring. Use this to decide
whether to include source code bodies in symbol results.
guard.cap_items<T>(items: Vec<T>, hint: &str) -> (Vec<T>, Option<OverflowInfo>)
Caps a list of items according to the active mode.
- Exploring: Keeps the first
max_resultsitems. If truncated, returnsOverflowInfowithnext_offset: None. - Focused: Applies
offset/limitpagination. If more pages remain, returnsOverflowInfowithnext_offset: Some(offset + limit).
The hint parameter is a human-readable suggestion included in the overflow
metadata (e.g. "Narrow with a path filter" or "Use offset/limit to paginate").
Returns (truncated_items, None) if everything fits.
guard.cap_files<T>(files: Vec<T>, hint: &str) -> (Vec<T>, Option<OverflowInfo>)
Same as cap_items() but uses max_files instead of max_results for the
exploring-mode cap. Use this for file-list results.
OutputGuard::overflow_json(info: &OverflowInfo) -> Value
Serializes overflow metadata to JSON for inclusion in tool responses:
{
"shown": 200,
"total": 1423,
"hint": "Narrow with a path filter",
"next_offset": 200
}
The next_offset field is only present in focused mode when more pages exist.
OverflowInfo
#![allow(unused)]
fn main() {
pub struct OverflowInfo {
pub shown: usize,
pub total: usize,
pub hint: String,
/// In focused mode, the offset for the next page (None in exploring mode).
pub next_offset: Option<usize>,
}
}
Typical usage pattern
#![allow(unused)]
fn main() {
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
let guard = OutputGuard::from_input(&input);
let all_results = do_expensive_work();
let (results, overflow) = guard.cap_items(all_results, "Use offset/limit to paginate");
let mut response = json!({
"results": results,
});
if let Some(info) = overflow {
response["overflow"] = OutputGuard::overflow_json(&info);
}
Ok(response)
}
}
Error Handling
Tool errors are content, not protocol errors
When a tool’s call() returns Err(e), the server does not return an MCP
protocol error. Instead, it wraps the error message in a CallToolResult with
is_error: true:
#![allow(unused)]
fn main() {
// From src/server.rs call_tool():
match tool.call(input, &ctx).await {
Ok(output) => {
let text = serde_json::to_string_pretty(&output)
.unwrap_or_else(|_| output.to_string());
Ok(CallToolResult::success(vec![Content::text(text)]))
}
Err(e) => {
// Error surfaces to the LLM as text, not a protocol error
Ok(CallToolResult::error(vec![Content::text(e.to_string())]))
}
}
}
This means the LLM sees the error message and can react to it (e.g., try a
different path, fix a parameter). Protocol-level errors (McpError) are only
used for truly invalid requests like unknown tool names.
Error patterns
Validation errors — use anyhow::bail!() for clear, immediate failures:
#![allow(unused)]
fn main() {
if path_str.is_empty() {
anyhow::bail!("path must not be empty");
}
}
Propagation — use ? to propagate errors from I/O, LSP calls, etc.:
#![allow(unused)]
fn main() {
let content = std::fs::read_to_string(&path)?;
let client = ctx.lsp.get_or_start(lang, &root).await?;
}
Missing required parameters:
#![allow(unused)]
fn main() {
let path = input["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?;
}
Tool access control — the server checks tool access before dispatching.
Restricted tools (like run_command) are blocked at the server level,
not inside the tool itself.
What not to do
- Do not
panic!()in tools. Panics crash the server process. - Do not return
Errfor “no results found” — return an empty result set instead. Errors mean something went wrong, not that the result is empty. - Do not return
McpErrorfrom tools. That type is for the server layer only.
The #[async_trait] Requirement
All tools must be Send + Sync because the MCP server is async and supports
multiple concurrent connections. The Tool trait enforces this:
#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync { ... }
}
In practice, this means:
- Tool structs must not hold non-
Sendtypes (useArc<Mutex<_>>if you need shared mutable state). - Unit structs (
pub struct MyTool;) are alwaysSend + Sync. - The
async fn call()implementation uses#[async_trait]to enable async methods in the trait. This desugars toPin<Box<dyn Future + Send>>. - Holding a
MutexGuardacross an.awaitpoint will cause a compile error. Release the guard before awaiting.
Tool Registration
Tools are registered in src/server.rs in the from_parts() method as a
Vec<Arc<dyn Tool>>. The server uses this vector for two things:
list_tools— iterates all tools and builds MCPToolInfofromname(),description(), andinput_schema().call_tool— looks up a tool by matchingtool.name()against the request’s tool name, then callstool.call().
There is no macro, attribute, or inventory system. Adding a tool is adding one line to the vector.