Minimalist MCP server using official Rust SDK for MCP (rmcp 0.3)
rmcp was updated July 16 2025 – this article looks as how to use the latest custom procedural macro attributes and build a minimalist example. There are some significant differences to earlier versions and we analyze them here.
What you read here is not in the documentation, I have spent a few hours identifying the changes and testing the new macros. We’ll also look at how to pass in args
#[tool_router]
#[tool_handler]
Note : They have replaced #[tool(tool_box)] from earlier versions

Let’s build using the official Rust SDK for the Model Context Protocol
Example 1 – basic counter
[package]
name = "mcp-calculator-2"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
rmcp = { version = "0.3.0", features = ["server", "transport-io", "transport-sse-server", "transport-streamable-http-server"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio = { version = "1.46.1", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }Example 2 - passing in args
/// main.rs
/// ProtocolVersion::V_2025_03_26
/// rmcp = { version = "0.3.0", features = ["server", "transport-io", "transport-sse-server", "transport-streamable-http-server"] }
use rmcp::{
ServerHandler, ServiceExt,
handler::server::router::tool::ToolRouter,
model::{ProtocolVersion, ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router,
transport::stdio,
};
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let service = HelloWorld::new()
.serve(stdio())
.await
.inspect_err(|e| eprintln!("{e}"))?;
service.waiting().await?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct HelloWorld {
counter: Arc<Mutex<i32>>,
tool_router: ToolRouter<Self>,
}
/// not #[tool(tool_box)] !! as previously used
#[tool_router]
impl HelloWorld {
pub fn new() -> Self {
Self {
counter: Arc::new(Mutex::new(0)),
tool_router: Self::tool_router(),
}
}
#[tool(description = "Increment the counter")]
async fn increment(&self) -> String {
let mut counter = self.counter.lock().await;
*counter += 1;
counter.to_string()
}
#[tool(description = "Get the current counter value")]
async fn get_value(&self) -> String {
let counter = self.counter.lock().await;
counter.to_string()
}
#[tool(description = "Say Something")]
fn echo(&self) -> String {
"hello from your first MCP server".to_string()
}
}
/// not #[tool(tool_box)] !! as previously used
#[tool_handler]
impl ServerHandler for HelloWorld {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
instructions: Some(
"This server provides a counter tool that can increment and get current value"
.to_string(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
..Default::default()
}
}
}

#[derive(Debug, Clone)]
pub struct HelloWorld {
tool_router: ToolRouter<Self>, // just once here
}
#[tool_router]
impl HelloWorld {
// multiple tool functions here
fn echo(...) -> ... { ... }
fn reverse(...) -> ... { ... }
}
Why tool_router: ToolRouter<Self>
??
You only need the tool_router: ToolRouter<Self>
field and the #[tool_router] impl
on the struct once, regardless of how many tool functions you add inside that impl block.
In other words:
- The
ToolRouter<Self>
field belongs to the main struct (HelloWorld
in the main example). - The
#[tool_router]
attribute goes on theimpl
block where you define all your tool functions (e.g.,echo
,reverse
, etc.). - You do not add
tool_router
fields or#[tool_router]
attributes separately per function.
Example 2 – passing in args
Parameters<EchoRequest>
The `Parameters<EchoRequest>` wrapper is rmcp 0.3.0’s way of handling tool function arguments. It automatically deserializes JSON-RPC call parameters into your defined struct, leveraging serde for type conversion and schemars for JSON schema generation.
With the destructuring pattern `Parameters(EchoRequest { message })` it extracts the inner struct cleanly, making parameter handling type-safe and ergonomic while maintaining MCP protocol compliance.
See line 56 below
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, tool::Parameters},
model::{ProtocolVersion, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router,
transport::stdio,
};
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct EchoRequest {
#[schemars(description = "The message to echo back")]
pub message: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let service = HelloWorld::new()
.serve(stdio())
.await
.inspect_err(|e| eprintln!("{e}"))?;
service.waiting().await?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct HelloWorld {
counter: Arc<Mutex<i32>>,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl HelloWorld {
pub fn new() -> Self {
Self {
counter: Arc::new(Mutex::new(0)),
tool_router: Self::tool_router(),
}
}
#[tool(description = "Increment the counter")]
async fn increment(&self) -> String {
let mut counter = self.counter.lock().await;
*counter += 1;
counter.to_string()
}
#[tool(description = "Get the current counter value")]
async fn get_value(&self) -> String {
let counter = self.counter.lock().await;
counter.to_string()
}
#[tool(description = "Echo back a message")]
fn echo(&self, Parameters(EchoRequest { message }): Parameters<EchoRequest>) -> String {
format!("Echo: {}", message)
}
}
#[tool_handler]
impl ServerHandler for HelloWorld {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
instructions: Some(
"This server provides a counter tool that can increment and get current value, and an echo tool"
.to_string(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
..Default::default()
}
}
}
