Writing Tools (Rust)
Installation
Add protomcp to your Cargo.toml:
[dependencies]protomcp = "0.2"tokio = { version = "1", features = ["full"] }serde_json = "1"The tool() builder
Register tools using the builder pattern. Call tool("name") to start, chain methods, and finish with .register().
use protomcp::{tool, ToolResult, ArgDef};
#[tokio::main]async fn main() { tool("add") .description("Add two numbers") .arg(ArgDef::int("a")) .arg(ArgDef::int("b")) .handler(|_ctx, args| { let a = args["a"].as_i64().unwrap_or(0); let b = args["b"].as_i64().unwrap_or(0); ToolResult::new(format!("{}", a + b)) }) .register();
protomcp::run().await;}Tool metadata
Chain hint methods before .register():
tool("delete_file") .description("Delete a file from disk") .destructive_hint(true) .idempotent_hint(false) .read_only_hint(false) .open_world_hint(false) .arg(ArgDef::string("path")) .handler(|_ctx, args| { let path = args["path"].as_str().unwrap_or(""); std::fs::remove_file(path).ok(); ToolResult::new(format!("Deleted {}", path)) }) .register();| Method | Description |
|---|---|
.description(s) | Human-readable description (required) |
.destructive_hint(bool) | Hint: the tool has destructive side effects |
.idempotent_hint(bool) | Hint: calling multiple times has the same effect as once |
.read_only_hint(bool) | Hint: the tool does not modify state |
.open_world_hint(bool) | Hint: the tool may access external resources |
.task_support_hint(bool) | Hint: the tool supports long-running async task semantics |
Argument types
| Function | JSON Schema type |
|---|---|
ArgDef::int(name) | "integer" |
ArgDef::string(name) | "string" |
ArgDef::number(name) | "number" |
ArgDef::boolean(name) | "boolean" |
ArgDef::array(name, item_type) | {"type": "array", "items": <item_type>} |
ArgDef::union(name, types...) | {"anyOf": [...]} |
ArgDef::literal(name, values...) | {"type": "string", "enum": [...]} |
Arguments are passed as serde_json::Value. Use .as_i64(), .as_f64(), .as_str(), .as_bool() to extract values.
ToolResult
All handlers return a ToolResult.
Success
ToolResult::new("done")Structured error
ToolResult::error( "The file /tmp/data.csv does not exist", "NOT_FOUND", "Check the path and try again", false,)| Field | Type | Description |
|---|---|---|
result_text | String | The result string |
is_error | bool | Whether this is an error |
error_code | String | Machine-readable error code |
message | String | Human-readable error message |
suggestion | String | Actionable suggestion for the caller |
retryable | bool | Whether the caller should retry |
enable_tools | Vec<String> | Tool names to enable after this call |
disable_tools | Vec<String> | Tool names to disable after this call |
Enabling / disabling tools from a result
tool("login") .description("Log in and unlock tools") .arg(ArgDef::string("username")) .arg(ArgDef::string("password")) .handler(|_ctx, args| { let username = args["username"].as_str().unwrap_or(""); let password = args["password"].as_str().unwrap_or(""); if !authenticate(username, password) { return ToolResult::error("Authentication failed", "AUTH_FAILED", "", false); } let mut r = ToolResult::new("Logged in"); r.enable_tools = vec!["delete_file".to_string(), "write_file".to_string()]; r }) .register();Progress Reporting
Use ToolContext to report progress during long-running tool calls.
tool("process_data") .description("Process a large dataset") .arg(ArgDef::string("file_path")) .handler(|ctx, args| { let file_path = args["file_path"].as_str().unwrap_or(""); let rows = load_rows(file_path); let total = rows.len(); for (i, row) in rows.iter().enumerate() { if ctx.is_cancelled() { return ToolResult::error("Cancelled", "CANCELLED", "", false); } process_row(row); ctx.report_progress((i + 1) as i64, total as i64); } ToolResult::new(format!("Processed {} rows", total)) }) .register();ToolContext API
| Method | Description |
|---|---|
report_progress(progress, total) | Send a progress notification to the MCP host |
is_cancelled() -> bool | Returns true if the MCP host has cancelled this call |
Cancellation
Check ctx.is_cancelled() periodically in long-running tools to stop early when the MCP host cancels the request.
tool("slow_compute") .description("Run a slow computation") .arg(ArgDef::int("n")) .handler(|ctx, args| { let n = args["n"].as_i64().unwrap_or(0); let mut result = 0i64; for i in 0..n { if ctx.is_cancelled() { return ToolResult::error("Cancelled by host", "CANCELLED", "", false); } result += expensive_step(i); } ToolResult::new(format!("{}", result)) }) .register();Cancellation is cooperative — protomcp sets the cancelled flag and your tool is responsible for checking it.
Testing tools
Test tool handlers directly by extracting the handler logic into a standalone function:
use protomcp::{ToolResult, ToolContext};use serde_json::json;
fn add_handler(_ctx: ToolContext, args: serde_json::Value) -> ToolResult { let a = args["a"].as_i64().unwrap_or(0); let b = args["b"].as_i64().unwrap_or(0); ToolResult::new(format!("{}", a + b))}
#[test]fn test_add() { let ctx = ToolContext::default(); let result = add_handler(ctx, json!({"a": 2, "b": 3})); assert_eq!(result.result_text, "5"); assert!(!result.is_error);}Then register the same handler in your server setup:
use protomcp::{tool, ArgDef};
tool("add") .description("Add two numbers") .arg(ArgDef::int("a")) .arg(ArgDef::int("b")) .handler(add_handler) .register();Tool Groups
Group related actions under a single tool using tool_group().
use protomcp::{tool_group, action, ToolResult, ArgDef};
tool_group("files") .description("File operations") .strategy("union") .action( action("read") .description("Read a file") .arg(ArgDef::string("path")) .handler(|_ctx, args| { let path = args["path"].as_str().unwrap_or(""); ToolResult::new(std::fs::read_to_string(path).unwrap_or_default()) }), ) .action( action("write") .description("Write a file") .arg(ArgDef::string("path")) .arg(ArgDef::string("content")) .handler(|_ctx, args| { ToolResult::new("written") }), ) .register();Strategy: union (default)
With .strategy("union"), the group registers as a single tool with a discriminated oneOf schema. The caller passes an action field to select the action.
Strategy: separate
With .strategy("separate"), each action becomes its own tool, namespaced as group.action (e.g. files.read, files.write).
Dispatch and fuzzy matching
When an unknown action is passed, the dispatcher returns an error with a fuzzy “Did you mean?” suggestion.
Declarative Validation
Declare validation rules on actions to validate input before the handler runs.
requires
action("deploy") .requires(&["env", "version"]) .handler(deploy_handler)enum_fields
Restrict a field to a set of valid values. Invalid values trigger a “Did you mean?” suggestion.
action("set_env") .enum_field("env", &["dev", "staging", "prod"]) .handler(set_env_handler)cross_rules
Validate relationships between parameters.
action("scale") .cross_rule(|args| { let min = args["min"].as_f64().unwrap_or(0.0); let max = args["max"].as_f64().unwrap_or(0.0); min > max }, "min must be <= max") .handler(scale_handler)hints
Non-blocking advisory messages appended to the result when a condition is met.
action("query") .hint("slow_warning", |args| { args["limit"].as_f64().unwrap_or(0.0) > 1000.0 }, "Large limit may cause slow queries") .handler(query_handler)Server Context
Register resolvers that inject shared parameters into tool handlers automatically.
use protomcp::server_context;
server_context("project_dir") .expose(false) .resolver(|_args| { std::env::current_dir() .map(|p| p.display().to_string()) .unwrap_or_default() .into() }) .register();With .expose(false), the parameter is hidden from the tool schema sent to the MCP host but still injected into the handler’s args.
Local Middleware
Wrap tool handlers with in-process middleware for cross-cutting concerns.
use protomcp::local_middleware;
local_middleware(10, |ctx, tool_name, args, next| { let start = std::time::Instant::now(); let result = next(ctx, args); println!("{} took {:?}", tool_name, start.elapsed()); result});Middleware is sorted by priority (lowest first = outermost). Return a ToolResult directly to short-circuit the chain.
Local vs Go-bridge middleware
Local middleware runs in-process. Go-bridge middleware runs at the transport layer between the MCP host and the server process.
Telemetry
Observe tool calls with fail-safe telemetry sinks. Exceptions in sinks are silently swallowed.
use protomcp::{telemetry_sink, ToolCallEvent};
telemetry_sink(|event: &ToolCallEvent| { println!("[{}] {}: {}", event.phase, event.tool_name, event.message);});ToolCallEvent phases
| Phase | When |
|---|---|
"start" | Before the handler runs |
"success" | After the handler returns successfully |
"error" | After the handler raises or returns an error |
"progress" | When report_progress is called |
Sidecar Management
Declare companion processes that protomcp manages alongside your server.
use protomcp::sidecar;
sidecar("redis") .command(&["redis-server", "--port", "6380"]) .health_check("http://localhost:6380/ping") .start_on("server_start") .health_timeout(std::time::Duration::from_secs(30)) .register();| Method | Description |
|---|---|
.command(args) | Process command and arguments |
.health_check(url) | URL to poll for health (HTTP 200 = healthy) |
.start_on(trigger) | "server_start" or "first_tool_call" |
.health_timeout(d) | Duration to wait for health check to pass |
PID files are stored in ~/.protomcp/sidecars/. Processes receive SIGTERM on shutdown, followed by SIGKILL if they do not stop within the timeout.