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

Build using the official Rust SDK for the Model

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()
        }
    }
}
inspector
#[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 the impl 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()
        }
    }
}
foobar mcp!
Rust Programming

Previous article

Factory + Builder Pattern
AI ML

Next article

Qdrant & Rust Client