Skip to content

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();
MethodDescription
.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

FunctionJSON 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,
)
FieldTypeDescription
result_textStringThe result string
is_errorboolWhether this is an error
error_codeStringMachine-readable error code
messageStringHuman-readable error message
suggestionStringActionable suggestion for the caller
retryableboolWhether the caller should retry
enable_toolsVec<String>Tool names to enable after this call
disable_toolsVec<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

MethodDescription
report_progress(progress, total)Send a progress notification to the MCP host
is_cancelled() -> boolReturns 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

PhaseWhen
"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();
MethodDescription
.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.