Error Handling
The way errors behave in Neva depends on which kind of handler returns them.
Tool Handlers: All Errors Become Tool Errors
For #[tool] handlers, any error returned from the handler always becomes a tool error — a successful JSON-RPC response with is_error: true in the content. The AI model receives it as readable content and can reason about it (retry, rephrase, fallback).
This applies whether you return Err(...) from a Result, propagate with ?, or call CallToolResponse::error() explicitly:
use neva::prelude::*;
#[tool(descr = "Reads a record by ID")]
async fn get_record(id: String) -> Result<String, Error> {
if id.is_empty() {
// This becomes a tool error visible to the model
return Err(Error::new(ErrorCode::InvalidParams, "id must not be empty"));
}
let record = load(&id)
.await
.map_err(|e| Error::new(ErrorCode::InternalError, e.to_string()))?;
Ok(record)
}
The ? operator works naturally — any type that implements Into<Error> can be propagated.
You can also signal a tool error explicitly when you want to stay on the CallToolResponse return path:
#[tool(descr = "Searches the catalog")]
async fn search(query: String) -> CallToolResponse {
match catalog_search(&query).await {
Ok(results) if results.is_empty() => {
CallToolResponse::error(format!("No results found for '{query}'"))
}
Ok(results) => CallToolResponse::json(results),
Err(e) => CallToolResponse::error(format!("Search failed: {e}")),
}
}
Resource and Prompt Handlers: Errors Become JSON-RPC Errors
For #[resource] and #[prompt] handlers, returning Err(e) propagates as a JSON-RPC error response — the request itself fails and the error is returned to the client at the protocol level, not as readable content.
#[resource(uri = "file://{path}", title = "Read file")]
async fn read_file(uri: Uri, path: String) -> Result<ResourceContents, Error> {
let content = tokio::fs::read_to_string(&path).await?; // JSON-RPC error on failure
Ok(ResourceContents::new(uri).with_text(content))
}
Infrastructure-Level JSON-RPC Errors
Some errors are produced automatically by the framework, before any handler runs:
| Situation | Error code |
|---|---|
| Tool name not registered | MethodNotFound (-32601) |
| Resource URI not matched | ErrorCode::RESOURCE_NOT_FOUND — InvalidParams (-32602) |
| Malformed JSON-RPC message | ParseError (-32700) |
| Invalid request structure | InvalidRequest (-32600) |
Missing a mandatory _meta key | InvalidParams (-32602) + HTTP 400 |
| A routing header disagrees with the body | HeaderMismatch (-32020) + HTTP 400 |
| A request relies on an undeclared client capability | MissingRequiredClientCapability (-32021) + HTTP 400 |
| The peer names a protocol version the server does not speak | UnsupportedProtocolVersion (-32022) + HTTP 400 |
The Error Type
Error wraps a JSON-RPC error code and a message:
use neva::prelude::*;
let err = Error::new(ErrorCode::InvalidParams, "Missing required field: name");
Error Codes
ErrorCode variant | JSON-RPC code | Description |
|---|---|---|
ParseError | -32700 | Malformed JSON received |
InvalidRequest | -32600 | Not a valid JSON-RPC object |
MethodNotFound | -32601 | Method does not exist |
InvalidParams | -32602 | Parameters are missing or wrong type |
InternalError | -32603 | Unexpected server-side failure |
UrlElicitationRequiredError | -32042 | The interaction requires URL elicitation |
HeaderMismatch | -32020 | A routing header disagrees with the request body |
MissingRequiredClientCapability | -32021 | The request needs a capability the client did not declare |
UnsupportedProtocolVersion | -32022 | The requested protocol version is not supported |
ResourceNotFound is deprecatedMCP 2026-07-28 dropped the dedicated -32002 code — "resource not found" is
an InvalidParams (-32602) now. Reference the version-dependent constant
ErrorCode::RESOURCE_NOT_FOUND
instead of naming the variant or hard-coding InvalidParams, and the wire
code follows the active generation automatically:
let err = Error::new(ErrorCode::RESOURCE_NOT_FOUND, "no such resource");
A handler that still returns the old variant keeps working — wire_code()
remaps it on the way out.
Protocol Errors with a data Payload
The three MCP 2026-07-28 codes carry the structured data the spec defines,
attached with
Error::with_data:
use neva::prelude::*;
use serde_json::json;
let err = Error::new(ErrorCode::UnsupportedProtocolVersion, "unsupported version")
.with_data(json!({
"supported": ["2026-07-28"],
"requested": "2025-06-18"
}));
| Code | data members |
|---|---|
HeaderMismatch | — |
MissingRequiredClientCapability | requiredCapabilities |
UnsupportedProtocolVersion | supported, requested |
All three answer HTTP 400. Neva raises them for you on the HTTP transport;
you only construct them by hand in a custom
handler or engine.
Automatic Conversions
Neva implements From for common error types so they can be propagated with ?:
use neva::prelude::*;
#[tool(descr = "Parses a JSON payload")]
async fn parse_data(raw: String) -> Result<String, Error> {
// serde_json::Error → Error, result becomes a tool error
let value: serde_json::Value = serde_json::from_str(&raw)?;
Ok(value.to_string())
}
#[resource(uri = "file://{path}", title = "Read file")]
async fn read_file(uri: Uri, path: String) -> Result<ResourceContents, Error> {
// std::io::Error → Error, result becomes a JSON-RPC error
let content = tokio::fs::read_to_string(&path).await?;
Ok(ResourceContents::new(uri).with_text(content))
}
Errors in Middleware
Middleware receives a MwContext and returns a Response. To short-circuit with an error, construct an error response directly:
use neva::prelude::*;
async fn auth_check(ctx: MwContext, next: Next) -> Response {
if !is_authorized(&ctx) {
let err = Error::new(ErrorCode::InvalidParams, "Unauthorized");
return Response::error(ctx.id(), err);
}
next(ctx).await
}