Skip to content

Tool — the one interface

Rust · crate toolnexus · SPEC §1

pub struct Tool {
pub name: String,
pub description: String,
pub input_schema: Value,
pub source: ToolSource,
pub execute: ExecuteFn,
pub hints: Option<ToolHints>,
}

The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.

When you need to hold, inspect, wrap or build a tool by hand: every source (MCP, skill, builtin, native, http) produces this one struct, so a toolkit, the adapters and the client loop treat them identically.

Build one with define_tool / define_typed_tool / http_tool rather than a struct literal: they fill source, the default schema and the error mapping for you.

use std::sync::Arc;
use serde_json::json;
use toolnexus::{Tool, ToolContext, ToolResult, ToolSource, empty_schema};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Every field is public; `execute` returns a boxed future of a ToolResult.
let ping = Tool {
concurrency_safe: None,
name: "ping".into(),
description: "Reply pong".into(),
input_schema: empty_schema(),
source: ToolSource::Custom,
hints: None,
execute: Arc::new(|_args, _ctx| Box::pin(async { ToolResult::ok("pong") })),
};
// `call` passes a null args value through as `{}`.
let r = ping.call(json!(null), ToolContext::default()).await;
assert_eq!(r.output, "pong");
assert!(!r.is_error);
println!("ok: {} -> {}", ping.name, r.output);
Ok(())
}

2. Error result vs panic — the distinction that matters

Section titled “2. Error result vs panic — the distinction that matters”

A Rust tool has one return channel: the ToolResult. A failure the model should read and recover from is ToolResult::error(..); define_tool maps an Err(e) to exactly that. Nothing you return is an exception — panicking inside execute is a bug in your code, not a tool outcome.

use serde_json::json;
use toolnexus::{ToolContext, define_tool};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let divide = define_tool(
"divide",
"Divide a by b",
Some(json!({"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]})),
|args, _ctx| async move {
let a = args["a"].as_f64().ok_or("a must be a number")?;
let b = args["b"].as_f64().ok_or("b must be a number")?;
if b == 0.0 {
return Err("cannot divide by zero");
}
Ok((a / b).to_string())
},
);
let ok = divide.call(json!({"a": 6, "b": 3}), ToolContext::default()).await;
let bad = divide.call(json!({"a": 1, "b": 0}), ToolContext::default()).await;
assert_eq!((ok.output.as_str(), ok.is_error), ("2", false));
// The error is data: the loop sends this text back to the model as the tool's answer.
assert_eq!((bad.output.as_str(), bad.is_error), ("cannot divide by zero", true));
println!("ok: {} / {}", ok.output, bad.output);
Ok(())
}

3. A generated tool source — the real reason this type is public

Section titled “3. A generated tool source — the real reason this type is public”

Producing many tools from data is where you build Tool directly. sanitize makes each name schema-safe.

use std::sync::Arc;
use serde_json::json;
use toolnexus::{Tool, ToolContext, ToolResult, ToolSource, sanitize};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let endpoints = [("get user", "/users/:id"), ("list orders", "/orders")];
let tools: Vec<Tool> = endpoints
.iter()
.map(|&(key, path)| Tool {
concurrency_safe: None,
// Names must match [a-zA-Z0-9_-]; sanitize does exactly that.
name: sanitize(key),
description: format!("Call {path}"),
input_schema: json!({"type":"object","properties":{"id":{"type":"string"}}}),
source: ToolSource::Custom,
hints: None,
execute: Arc::new(move |args, ctx| {
Box::pin(async move {
if ctx.cancel.is_cancelled() {
return ToolResult::error("cancelled");
}
ToolResult::ok(format!("{path} <- {}", args["id"].as_str().unwrap_or("")))
})
}),
})
.collect();
assert_eq!(tools[0].name, "get_user");
assert_eq!(tools[1].name, "list_orders");
let r = tools[0].call(json!({"id": "42"}), ToolContext::default()).await;
assert_eq!(r.output, "/users/:id <- 42");
println!("ok: {}, {}", tools[0].name, tools[1].name);
Ok(())
}
  • ToolResult — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension.
  • ContentPart — The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path.
  • ToolContext — Optional per-call context handed to execute: cancellation, identity, and host-supplied state.