Skip to main content

Basics

Let's build a simple MCP server with Neva and add a tool, prompt and resource handlers.

Create an app​

Create a new binary-based app:

cargo new neva-mcp-server
cd neva-mcp-server

Add the following dependencies in your Cargo.toml:

[dependencies]
neva = { version = "...", features = "server-full" }
tokio = { version = "1", features = ["full"] }

Setup a tool​

Let's start by adding a simple tool - a function that greets a user by name.

Create your main application in main.rs:

use neva::prelude::*;

#[tool(descr = "A say hello tool")]
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}

#[tokio::main]
async fn main() {
App::new()
.with_options(|opt| opt
.with_stdio()
.with_name("Sample MCP server")
.with_version("1.0.0"))
.run()
.await;
}

In the code above configured the MCP Server that runs on stdio transport and declared an async tool handler by using a tool attribute macro that extracts the name parameter into a String and expects another result String to be returned. The macro registers our hello tool with the specified description.

Besides the descr, you can configure your tool with:

  • title - Tool title.
  • input_schema - Schema for the tool input.
  • output_schema - Schema for the tool output.
  • annotations - Arbitrary metadata.
  • roles & permissions - Define which users can run the tool when using Streamable HTTP transport with OAuth.

Testing the MCP Server​

For testing purposes you may leverage the MCP Inspector by running the following command:

npx @modelcontextprotocol/inspector cargo run

This launches the MCP Inspector UI, allowing you to explore your server’s tools, prompts, and resources interactively.

Adding a prompt handler​

Next, we'll similarly add the prompt handler by using the prompt attribute macro:

#[prompt(descr = "Generates a user message requesting a hello world code generation.")]
async fn hello_world_code(lang: String) -> PromptMessage {
PromptMessage::user()
.with(format!("Write a hello-world function on {lang}"))
}

Adding a resource tempate handler​

Same idea as above, with the special resource attribute macro you can define a resource handler with minimal boilerplate:

#[resource(
uri = "res://{name}",
title = "Read resource",
descr = "Some details about resource",
mime = "application/octet-stream",
annotations = r#"{
"audience": ["user"],
"priority": 1.0
}"#
)]
async fn get_res(uri: Uri, name: String) -> ResourceContents {
let data = "some file contents"; // Read a resource from some source

ResourceContents::new(uri)
.with_title(name)
.with_blob(data)
}

Custom Handlers​

Use #[handler] to respond to any raw JSON-RPC method — including custom methods outside the standard MCP specification. This is useful for implementing proprietary protocol extensions or server-to-server coordination messages.

The command parameter specifies the JSON-RPC method name to handle:

use neva::prelude::*;

#[handler(command = "custom/ping")]
async fn ping_handler() {
eprintln!("pong");
}
note

MCP 2026-07-28 removed the protocol's own ping method, so a health-check method is now genuinely yours to define — pick a name outside the standard namespace, as above.

Handler functions can accept any parameters that implement FromHandlerParams:

use neva::prelude::*;

/// Receives the full request and the current MCP context
#[handler(command = "custom/status")]
async fn status_handler(ctx: Context, req: Request) -> String {
let session = ctx.session_id
.map(|id| id.to_string())
.unwrap_or_else(|| "none".into());
format!("session={session} method={}", req.method)
}

Available built-in parameter types:

TypeProvides
ContextCurrent request context (mutate the tool/prompt/resource registries, notify subscribers, ask the client for input, etc.)
RequestRaw JSON-RPC request (method, params, headers, claims)
RequestIdThe request's identifier
RuntimeMcpOptionsServer configuration at runtime

Handlers also support the middleware parameter, just like #[tool] and #[prompt]:

async fn audit(ctx: MwContext, next: Next) -> Response {
tracing::info!("custom command called");
next(ctx).await
}

#[handler(command = "custom/action", middleware = [audit])]
async fn action_handler(req: Request) {
tracing::info!("handling {}", req.method);
}

How a Client Connects​

There is no initialize / initialized handshake. A client opens with a single server/discover request, and neva answers it for you — the DiscoverResult advertises the versions the server speaks (supportedVersions) plus its capabilities, assembled from what you registered and configured.

Your server identifies itself in every result's _meta, under io.modelcontextprotocol/serverInfo, from the with_name() / with_version() values above.

See MCP 2026-07-28 for the full picture, or Legacy spec if you need to serve pre-2026-07-28 clients.

JSON-RPC 2.0 Batch Support​

Neva servers handle JSON-RPC 2.0 batch requests automatically on both stdio and HTTP transports — no extra configuration is required. When a client sends a batch (a JSON array of request objects), the server processes each request and returns the responses as a JSON array in the same order.

See the client Batch Requests guide for the client-side API.

Learn By Example​

Here you may find the full example