# with its default features
off, so a schema is never resolved from the network or the filesystem.
```toml
tower-mcp = { version = "0.23", features = ["schema-validation"] }
```
## What is checked
With the feature compiled in, every tool is validated by default:
- `arguments` against the tool's `inputSchema`, before the handler runs. This
covers typed handlers, `RawArgs` and extractor handlers, tools whose schema
was set with `.input_schema(Value)`, and `McpTool` implementations. An
omitted `arguments` is checked as an empty object.
- `structuredContent` against the tool's `outputSchema`, when one is declared,
before the result is returned. A result with `isError: true`, or with no
`structuredContent`, is not checked.
`format` is enforced (`email`, `uuid`, `date-time`, `uri`, and the other
standard formats), rather than treated as an annotation.
Each schema is compiled once, when the tool is built. Checking a call does not
recompile anything.
## Where the check runs
The check wraps the tool itself, in the same places `Tool::with_guard` wraps
it, so it applies wherever the tool is called from:
- ordinary `tools/call` requests;
- task-backed calls, both replayed handlers and live task handlers;
- MRTR handlers (with `protocol-2026-07-28`), on every attempt;
- tools registered through the builders, `McpTool::into_tool`, the extractor
handlers, and dynamic registries.
Middleware added with `.layer()` runs inside the check. A guard added with
`.guard()` on the builder does too. A guard added to the built tool with
`Tool::with_guard` runs first.
One path is not covered: the task preparation callback
(`.task_preparation(...)`) runs when a task is created, before the handler is
invoked, and sees the arguments unchecked. The handler then still refuses them.
## What a failure looks like
A failure is a tool execution error, `isError: true`, and not a protocol
error, so a model can read it and correct the call. The text names the JSON
location and the rule that was violated:
```text
Invalid input: the arguments do not match the tool's input schema.
- at /age: 200 is greater than the maximum of 120 (maximum)
- at /handle: "Bad" does not match "^[a-z]+$" (pattern)
```
`(root)` stands for the arguments object itself, for example when a required
property is missing. The first line uses the same `Invalid input` prefix as the
serde failure a typed handler reports, so a caller sees one shape either way.
At most five violations are listed, followed by `and more violations`, and a
single long message is truncated, so the result stays bounded whatever the
caller sent. The handler is not invoked.
A result that does not match the output schema is replaced by an error result
in the same format:
```text
Tool returned structured content that does not match its output schema.
- at /count: -1 is less than the minimum of 0 (minimum)
```
Both cases emit a `tracing` event on the `mcp::tools` target with the tool
name and the same report. Input failures are logged at `debug`, since they are
the caller's mistake. Output failures are logged at `warn`, since they mean the
server is returning something it advertises it will not.
## Deriving an output schema
`ToolBuilder::output_schema_for::<T>()` derives the output schema from a type
with schemars, the way input schemas are derived. It is available with or
without the feature.
```rust
use schemars::JsonSchema;
use serde::Serialize;
use tower_mcp::{CallToolResult, NoParams, ToolBuilder};
#[derive(Serialize, JsonSchema)]
struct Forecast {
#[schemars(range(min = -90, max = 60))]
celsius: i32,
summary: String,
}
let tool = ToolBuilder::new("forecast")
.description("Current forecast")
.output_schema_for::<Forecast>()
.handler(|_input: NoParams| async {
CallToolResult::from_serialize(&Forecast {
celsius: 18,
summary: "clear".to_string(),
})
})
.build();
assert_eq!(tool.definition().output_schema.unwrap()["type"], "object");
```
The handler still builds its own `CallToolResult`. `McpTool::Output` is not
required to implement `JsonSchema`, so existing implementations keep
compiling; to derive a schema for one, call `output_schema_for` on a builder,
or set `.output_schema(...)` yourself. The MCP specification requires an
output schema to describe a JSON object, and neither method rewrites the
schema it is given.
## Opting out
`ToolBuilder::skip_input_validation` and `ToolBuilder::skip_output_validation`
turn each check off for one tool, independently:
```rust
use serde_json::json;
use tower_mcp::extract::RawArgs;
use tower_mcp::{CallToolResult, ToolBuilder};
let tool = ToolBuilder::new("passthrough")
.input_schema(json!({ "type": "object", "required": ["query"] }))
.skip_input_validation()
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::json(args))
})
.build();
```
Both methods exist whether or not the feature is compiled in, and do nothing
without it, so a library that calls them builds either way.
The feature is additive: enabling it anywhere in a dependency graph turns
validation on for every tool built by tower-mcp in that graph, unless the tool
opts out.
## A schema that does not compile
Building a tool never panics over its schemas. If a schema fails to compile
(for example a `required` that is not an array), an `error` event is logged on
the `mcp::tools` target and that schema is left unchecked. The other schema of
the same tool is still checked. This matters for tools built from someone
else's schema, such as a proxy or a dynamic registration, where a panic would
take the process down.
A `$ref` to a remote or file location is not resolved, so such a schema does
not compile and is left unchecked as described above. Bundle the referenced
definitions into the schema (`$defs`) instead.
"####]