Accumulated learnings, pitfalls, and working patterns from implementing
tablitz-cliMCP server.
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.
#[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?;// 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).
// 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>,
}# 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"] }// 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,
}// WRONG — ToolRouter does not expose call_tool directly
self.tool_router.call_tool(name, args).awaitToolRouter exposes call(context) and list_all() internally. The #[tool_handler] macro wires these into ServerHandler. You should never call ToolRouter methods directly.
[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.
| 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 |
let result = some_async_op().await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;- 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