Tools
The Model Context Protocol (MCP) allows servers to expose tools that can be invoked by language models. Tools enable models to interact with external systems, such as querying databases, calling APIs, or performing computations. Each tool is uniquely identified by a name and includes metadata describing its schema.
In the Basics chapter, we learned how to declare a simple tool:
use neva::prelude::*;
#[tool(descr = "A simple 'say hello' tool")]
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
You can achieve the same result without using the procedural macro:
use neva::prelude::*;
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
#[tokio::main]
async fn main() {
let mut mcp_server = App::new()
.with_options(|opt| opt
.with_stdio()
.with_name("Sample MCP Server")
.with_version("1.0.0"));
mcp_server
.map_tool("hello", hello)
.with_description("A simple 'say hello' tool");
mcp_server.run().await;
}
In the example above, the tool name must be set explicitly.
When using the #[tool] attribute macro, the tool name is automatically inferred from the function name.
asyncA tool handler may also be a plain fn returning its value directly, and one
that blocks can be moved onto Tokio's blocking pool with neva::blocking or
#[tool(blocking)]. The published schema, the argument slots and the response
are identical either way. See Handler shapes.
All other tool parameters that can be specified in the attribute macro can also be configured using with_* methods (for example, with_description()).
The map_tool() method registers a tool handler under a specified name and returns a mutable reference to the registered tool.
Input Schema
You can describe an explicit input schema for a tool. If not provided, Neva automatically generates one based on the tool handler’s function signature.
Schemas are full JSON Schema 2020-12 documents — InputSchema over a
serde_json::Value — and the #[tool] macro emits complete 2020-12
documents automatically.
To override the generated schema, you can specify it as a JSON string:
#[tool(
descr = "A simple 'say hello' tool",
input_schema = r#"{
"properties": {
"name": {
"type": "string",
"description": "The name to greet"
}
},
"required": ["name"]
}"#
)]
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
A schema you write is published verbatim. Every keyword neva does not
model itself — default, pattern, examples, $schema, $defs, $ref,
additionalProperties, allOf/anyOf, if/then/else — survives into
the listing untouched, at the root and below it, so a peer validating
against the published schema accepts exactly what the tool accepts.
"integer" is its own type rather than an alias for "number": a field
declared integer rejects 1.5 but still accepts 1.0, since the check
judges the value and not how it was written.
Output Schema
If your tool returns structured data (for example, a JSON object), Neva automatically generates an output schema based on the return type.
Just like with the input schema, you can override it manually:
#[tool(
descr = "A 'say hello' tool with structured output",
output_schema = r#"{
"properties": {
"message": {
"type": "string",
"description": "The generated greeting message"
}
},
"required": ["message"]
}"#
)]
async fn hello(say: String, name: String) -> Json<Results> {
let result = Results {
message: format!("{say}, {name}!")
};
result.into()
}
Optional Arguments
An argument declared Option<T> is published as its inner T but is left
out of required; a call that omits it hands the handler None instead of
failing:
#[tool(descr = "Greets a person, by nickname when there is one")]
async fn greet(name: String, alias: Option<String>) -> String {
format!("Hello, {}!", alias.unwrap_or(name))
}
A tool whose arguments are all optional publishes no required key at
all. The rule follows the resolved type, so a type alias
(type MaybeFloor = Option<i32>;) behaves the same way, and
Option<Json<T>> still describes T in full.
Prompts work the same way — see Prompts → Optional Arguments.
Argument Names
A call's arguments are read by name, not by position — so the names
the handler reads by have to be the names the inputSchema publishes.
With #[tool] there is nothing to do: the macro takes the function's own
parameter names. A bare closure is the exception, because Rust does not
preserve a closure's parameter names — such a tool falls back to publishing
and reading the positional arg0, arg1, … The map_tool! macro reads the
names off the closure for you:
use neva::{App, map_tool};
#[tokio::main]
async fn main() {
let mut app = App::new();
map_tool!(app, "greet", |name: String, age: i32| async move {
format!("Hello, {name}! You are {age}.")
})
.with_description("Greets a person");
app.run().await;
}
with_arg_names()
is the same thing spelled out, for a named function or a handler you did not
declare inline:
use neva::App;
async fn greet(name: String, age: i32) -> String {
format!("Hello, {name}! You are {age}.")
}
#[tokio::main]
async fn main() {
let mut app = App::new();
app.map_tool("greet", greet)
.with_arg_names(["name", "age"]);
app.run().await;
}
Either call renames the generated schema and the extraction names
together, so the two cannot drift. Only the value-carrying parameters
are named: Context, Meta<_> and a DI-injected Dc<T> are skipped here
exactly as they are skipped in the schema. An Option<T> is named — it
occupies an argument slot, it simply is not required.
A schema supplied through input_schema = "..." or
with_input_schema()
is taken verbatim — every key in it was chosen on purpose. Name its
properties as you name the arguments. The two calls may appear in either
order.
Startup Validation
A tool or prompt that publishes arguments its handler does not read cannot be
called successfully by anyone, so App::run refuses to start on the
disagreement instead of failing on a peer's first call — a wrong count of
declared names, a duplicate name, or a schema property the handler never
looks for. ctx.tools().add
and ctx.prompts().add run the same check and return an error, since a
primitive registered while the server runs has no startup left to fail.
Mirroring an Argument into a Header
A tool may ask that one of its arguments also travel as an HTTP header, so
that proxies and gateways can route or rate-limit on it without parsing the
body. Annotate the property in the inputSchema with x-mcp-header, and
clients will mirror the value into Mcp-Param-{name} on tools/call:
#[tool(
descr = "Fetches a tenant's dashboard",
input_schema = r#"{
"properties": {
"tenant": {
"type": "string",
"description": "Tenant identifier",
"x-mcp-header": true
}
},
"required": ["tenant"]
}"#
)]
async fn dashboard(tenant: String) -> String {
format!("Dashboard for {tenant}")
}
Servers may use the annotation; clients must honor it. Neva's own
client records the annotations from tools/list and attaches the headers
automatically, and the server rejects a tools/call whose header and body
disagree with HeaderMismatch (-32020).
The registrations expire with the listing
What a client learned from tools/list is only good for that listing's
ttlMs — an absent ttlMs reads as 0, so those annotations are usable
for that exchange and no longer. Once they have expired, a HeaderMismatch
has the client re-list and retry the call once; that fresh listing counts
for the retry whatever its own TTL, for the refused tool and that one
exchange only.
This matters if you change a tool's x-mcp-header annotations at runtime:
set a ttlMs you are willing to be held to, and expect one extra
tools/list round-trip after a change rather than a permanently wrong
header.
A definition that breaks the spec's constraints — a non-token name, a
duplicate, a non-primitive type, or a property that is not statically
reachable through properties — causes the whole tool to be dropped
from the listing. That is deliberate: one bad definition must not be able to
change what a good one sends. This applies to Streamable HTTP; other
transports may ignore the annotation.
Unknown Attributes Are Rejected
#[tool], #[resource], #[resources], #[prompt] and #[handler] reject
an attribute they do not recognise:
#[tool(descr = "…", visibilty = ["app"])] // error: unknown attribute `visibilty`
An attribute that is quietly dropped is worse than one that fails: a misspelled
visibility would publish an app-only tool to the agent,
a security-relevant setting that looks applied and is not. Rejecting the
spelling is what keeps that from compiling.
Giving a Tool a UI
A tool can point at an HTML document a host renders in a sandboxed iframe —
MCP Apps, behind the apps feature:
#[tool(descr = "Current weather", ui = "ui://weather/dashboard")]
async fn get_weather(city: String) -> String {
format!("Sunny in {city}.")
}
The tool still returns a sentence: a UI-bound tool MUST return a meaningful
content array, because the model reads content and not every client has an
iframe. visibility = ["app"] marks a tool the iframe may call and the model
must not see. See MCP Apps for the resource half.
Listing Order
Tool, prompt, and resource registries are BTreeMap-backed, so tools/list
returns entries ordered by name and the order is stable across calls.
This is what makes cursor pagination
safe — an arbitrary order could skip or repeat entries across pages — and it
lets LLM prompt caches hit on an unchanged tool listing.
MCP Context
For more advanced scenarios - for example, when a tool needs to access resources you also declared in your MCP Server - you can inject the Context into your tool handler:
use neva::prelude::*;
#[tool(descr = "Fetches resource metadata")]
async fn read_resource(ctx: Context, res: Uri) -> Result<Content, Error> {
let result = ctx.resources().read(res).await?;
let resource = result.contents
.into_iter()
.next()
.ok_or_else(|| Error::new(ErrorCode::InternalError, "no resource contents"))?;
Ok(Content::resource(resource))
}
The server's own primitives are grouped the way a client sees them, one
namespace per kind. Each one reads the registry, runs what is in it, and
changes it — and a change emits the matching list_changed to every
subscriber:
| Namespace | Read | Run | Change |
|---|---|---|---|
ctx.tools() | list(), find(name), find_many(names) | call(tool_use), call_all(tool_uses) | add(tool), remove(name) |
ctx.resources() | is_subscribed(&uri) | read(uri) | add(resource), remove(uri), notify_updated(uri) |
ctx.prompts() | list() | get(name, args) | add(prompt), remove(name) |
A call runs through the handler that serves it, so read gets what a client
reading the resource would. With the svir feature, ctx.tools().toolbox()
hands the server's other tools to a model — see the
svir bridge.
Context methods take &self, so the handler parameter is plain ctx: Context
— a mut ctx only draws an unused_mut warning.
Learn By Example
Here you may find the full example