Skip to content

Instantly share code, notes, and snippets.

@bkataru
Created February 24, 2026 16:13
Show Gist options
  • Select an option

  • Save bkataru/aeb256e21bd3eb603b6a9e4f374f04be to your computer and use it in GitHub Desktop.

Select an option

Save bkataru/aeb256e21bd3eb603b6a9e4f374f04be to your computer and use it in GitHub Desktop.
SKILL: Implementing MCP Servers in Rust with rmcp 0.10 — pitfalls, correct patterns, working example

Skill: Implementing MCP Servers in Rust with rmcp 0.10

Accumulated learnings, pitfalls, and working patterns from implementing tablitz-cli MCP server.


Summary

rmcp 0.10 uses a macro-driven approach with #[tool_router] and #[tool_handler] that auto-generates most of the boilerplate. The hard part is getting the macro invocation order and parameter schemas right.


Working Minimal Example

#[cfg(feature = "mcp")]
mod mcp {
    use std::sync::Arc;
    use rmcp::{
        ErrorData as McpError,
        ServerHandler,
        model::{CallToolResult, Content},
        handler::server::tool::ToolRouter,
        handler::server::wrapper::Parameters,
        tool, tool_handler, tool_router,
    };
    use rmcp::schemars::JsonSchema;  // ← NOT `use schemars::JsonSchema`
    use serde::Deserialize;

    #[derive(Clone)]
    pub struct MyMcpServer {
        tool_router: ToolRouter<Self>,
    }

    #[tool_router]   // ← generates Self::tool_router()
    impl MyMcpServer {
        pub fn new() -> Self {
            Self {
                tool_router: Self::tool_router(),
            }
        }

        #[tool(name = "my_tool", description = "Does something useful")]
        async fn my_tool(
            &self,
            Parameters(params): Parameters<MyToolParams>,
        ) -> Result<CallToolResult, McpError> {
            Ok(CallToolResult::success(vec![Content::text("result")]))
        }
    }

    #[derive(Deserialize, JsonSchema)]
    struct MyToolParams {
        query: String,
        limit: Option<usize>,
    }

    #[tool_handler(router = self.tool_router)]  // ← auto-generates call_tool + list_tools
    impl ServerHandler for MyMcpServer {
        fn get_info(&self) -> rmcp::model::ServerInfo {
            use rmcp::model::{Implementation, ServerCapabilities};
            rmcp::model::ServerInfo {
                protocol_version: Default::default(),
                capabilities: ServerCapabilities {
                    tools: Some(Default::default()),
                    ..Default::default()
                },
                server_info: Implementation {
                    name: "my-server".to_string(),
                    version: env!("CARGO_PKG_VERSION").to_string(),
                    ..Default::default()
                },
                instructions: None,
            }
        }
    }
}

Stdio transport (Claude Desktop / Claude Code integration):

use rmcp::{ServiceExt, transport::stdio};

let server = mcp::MyMcpServer::new();
let service = server.serve(stdio()).await?;
service.waiting().await?;

Pitfalls and Mistakes

❌ Pitfall 1: Manually implementing call_tool and list_tools

// WRONG — do NOT do this
impl ServerHandler for MyServer {
    async fn call_tool(&self, ...) { ... }
    async fn list_tools(&self, ...) { ... }
}

#[tool_handler(router = self.tool_router)] auto-generates both. Adding manual implementations causes a compile error (duplicate method definitions).


❌ Pitfall 2: #[tool(description)] on individual plain parameters

// WRONG — this syntax does not work in rmcp 0.10
#[tool(description = "Search for tabs")]
async fn search_tabs(
    &self,
    #[tool(description = "The query string")] query: String,   // ← NOT supported
    #[tool(description = "Max results")] limit: Option<usize>, // ← NOT supported
) -> Result<CallToolResult, McpError>

Individual parameter description attributes require the Parameters<T> struct wrapper:

// CORRECT
#[tool(name = "search_tabs", description = "Search for tabs")]
async fn search_tabs(
    &self,
    Parameters(params): Parameters<SearchTabsParams>,
) -> Result<CallToolResult, McpError>

#[derive(Deserialize, JsonSchema)]
struct SearchTabsParams {
    #[schemars(description = "The query string")]
    query: String,
    #[schemars(description = "Max results to return")]
    limit: Option<usize>,
}

❌ Pitfall 3: Adding schemars as an explicit dependency

# WRONG — do not add this to Cargo.toml
schemars = { version = "0.8" }

schemars is re-exported by rmcp. Use it via:

use rmcp::schemars::JsonSchema;

Adding it as a direct dep can create a version conflict (rmcp may use a different schemars version than you specify), causing JsonSchema derive failures.

Exception: If you need schemars features beyond what rmcp re-exports (like chrono04), pin to the same version rmcp uses:

# Only if you truly need it directly — pin to match rmcp's transitive version
schemars = { version = "1.0", features = ["chrono04"] }

❌ Pitfall 4: Wrong ServerInfo / InitializeResult field names

// WRONG field names (from older rmcp versions or incorrect docs)
rmcp::model::ServerInfo {
    name: "server".to_string(),                  // ← field doesn't exist
    version: "0.1.0".to_string(),                // ← field doesn't exist
    capabilities: Default::default(),            // ← wrong type
}
// CORRECT field names in rmcp 0.10
rmcp::model::ServerInfo {
    protocol_version: Default::default(),         // ← ProtocolVersion
    capabilities: ServerCapabilities {
        tools: Some(Default::default()),
        ..Default::default()
    },
    server_info: Implementation {                  // ← nested Implementation struct
        name: "server".to_string(),
        version: "0.1.0".to_string(),
        ..Default::default()
    },
    instructions: None,
}

❌ Pitfall 5: ToolRouter has no call_tool method

// WRONG — ToolRouter does not expose call_tool directly
self.tool_router.call_tool(name, args).await

ToolRouter exposes call(context) and list_all() internally. The #[tool_handler] macro wires these into ServerHandler. You should never call ToolRouter methods directly.


Cargo.toml Setup

[dependencies]
rmcp = { version = "0.10", features = ["server", "transport-io", "macros", "schemars"], optional = true }

[features]
mcp = ["dep:rmcp"]

Verify macros and schemars features are enabled — without macros, the proc macros (#[tool_router], etc.) don't compile.


Key API Facts (rmcp 0.10)

Macro Where What it generates
#[tool_router] on impl MyServer Self::tool_router() -> ToolRouter<Self>
#[tool(name=..., description=...)] on method registers method in the tool router
#[tool_handler(router = self.tool_router)] on impl ServerHandler call_tool() + list_tools()
Type Notes
ServerInfo Type alias for InitializeResult
Parameters<T> Wrapper that deserializes JSON args into struct T
McpError::internal_error(msg, None) Generic server error
McpError::invalid_params(msg, None) Invalid input error
CallToolResult::success(vec![Content::text(s)]) Successful text result

Error Handling Pattern

let result = some_async_op().await
    .map_err(|e| McpError::internal_error(e.to_string(), None))?;

Verified Working Environment

  • rmcp 0.10.0
  • Rust edition 2021
  • Verified against source: /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rmcp-0.10.0/
  • Test file reference: rmcp-0.10.0/tests/test_tool_macros.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment