Try   HackMD

🦀 MCP in Rust: A Practical Guide using rmcp

This guide demonstrates how to use the Model Context Protocol (MCP) in Rust with the rmcp crate. MCP allows AI systems to interact with tools via JSON-RPC 2.0. Rust's performance and safety make it a great choice for building these tools.

We'll build several example MCP servers, from simple file operations to more complex KYC tasks.

1. Environment Setup

First, add the necessary dependencies to your Cargo.toml:

[dependencies]
rmcp = { version = "0.1", features = ["server", "client"] } # Core MCP library for server/client
tokio = { version = "1", features = ["full"] }           # Async runtime
serde = { version = "1", features = ["derive"] }           # Serialization/Deserialization
schemars = "0.8"                                        # JSON Schema generation for tool inputs
anyhow = "1.0"                                          # Flexible error handling
reqwest = { version = "0.11", features = ["json"] }     # HTTP client (for examples)
serde_json = "1.0"                                      # JSON handling (for examples)
# Note: For OCR example, ensure 'tesseract' CLI tool is installed and in PATH

2. General MCP Server Examples

Let's create some basic tools exposed via MCP.

2.1. File Explorer

This tool provides functions to list directory contents and read files.

use rmcp::{tool, tool_box};
use serde::Deserialize;
use schemars::JsonSchema;
use anyhow::Result; // Using anyhow for convenient error handling

#[derive(Clone)]
struct FileServer;

// Define the structure for requests needing a path
#[derive(Deserialize, JsonSchema)]
struct PathRequest { path: String }

#[tool_box] // Exposes methods within this impl block as MCP tools
impl FileServer {
    #[tool(description = "List directory contents")] // MCP description for this tool/method
    fn list_dir(&self, #[tool(aggr)] req: PathRequest) -> Result<Vec<String>> {
        // #[tool(aggr)] indicates the 'req' struct aggregates all parameters
        Ok(std::fs::read_dir(&req.path)?
            .filter_map(Result::ok) // Iterate, filter out errors
            .map(|e| e.file_name().to_string_lossy().to_string()) // Get filenames
            .collect()) // Collect into a Vec<String>
    }

    #[tool(description = "Read a file")]
    fn read_file(&self, #[tool(aggr)] req: PathRequest) -> Result<String> {
        Ok(std::fs::read_to_string(&req.path)?) // Read file content to string
    }
}

// Note: You'd need to add server setup code (e.g., using rmcp::Server) to run this.
// See rmcp documentation for server initialization examples.
  • #[tool_box] marks the impl block, making its methods discoverable MCP tools.
  • #[tool(description = "...")] provides a human-readable description for the AI model.
  • #[derive(Deserialize, JsonSchema)] on PathRequest allows serde to parse incoming JSON into the struct and schemars to generate a schema for validation/description.
  • #[tool(aggr)] tells rmcp to map the incoming JSON parameters object directly to the fields of the PathRequest struct.
  • Standard Rust std::fs functions are used for the core logic. anyhow::Result simplifies error handling.

2.2. Currency Converter

This tool uses an external API to convert currencies.

use rmcp::{tool, tool_box};
use serde::Deserialize;
use schemars::JsonSchema;
use anyhow::Result;
use reqwest; // HTTP client

#[derive(Clone)] // Added Clone trait
struct CurrencyServer;

#[derive(Debug, Deserialize, JsonSchema)]
struct ConvertRequest {
    from: String,
    to: String,
    amount: f64,
}

// Structure to deserialize the relevant part of the API response
#[derive(Debug, Deserialize)]
struct ApiResponse {
    result: f64,
}

#[tool_box]
impl CurrencyServer {
    #[tool(description = "Convert currency using live exchange rates from api.exchangerate.host")]
    async fn convert(&self, #[tool(aggr)] req: ConvertRequest) -> Result<String> {
        // This method is async because it performs a network request
        let url = format!(
            "https://api.exchangerate.host/convert?from={}&to={}&amount={}",
            req.from, req.to, req.amount
        );

        // Use reqwest to make the HTTP GET request and await the response
        let resp = reqwest::get(&url).await?
                      .json::<ApiResponse>().await?; // Parse JSON response into ApiResponse

        // Format the result nicely
        Ok(format!("{:.2} {}", resp.result, req.to))
    }
}

// Requires a tokio runtime to execute async functions.
  • Uses async fn because reqwest::get is an asynchronous operation. Requires an async runtime like tokio.
  • Makes an HTTP GET request to an external currency API.
  • Uses serde to deserialize the JSON response from the API into the ApiResponse struct.

2.3. Task Manager

A stateful tool to manage a simple list of tasks.

use rmcp::{tool, tool_box};
use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
use anyhow::Result;
use std::sync::{Arc, Mutex}; // For shared mutable state

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct Task {
    title: String,
    done: bool,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct TaskRequest {
    title: String,
    done: bool, // Assuming we want to set status on add
}

#[derive(Clone)] // Clone is needed for the server state to be shared across requests
struct TaskManager {
    // Use Arc<Mutex<T>> for thread-safe shared mutable state
    tasks: Arc<Mutex<Vec<Task>>>,
}

impl TaskManager {
    // Constructor to initialize the state
    fn new() -> Self {
        TaskManager {
            tasks: Arc::new(Mutex::new(Vec::new())),
        }
    }
}

#[tool_box]
impl TaskManager {
    #[tool(description = "Add a new task")]
    fn add_task(&self, #[tool(aggr)] req: TaskRequest) -> Result<String> {
        // Lock the mutex to safely access the shared task list
        let mut tasks = self.tasks.lock().unwrap(); // .unwrap() is used for simplicity here
        tasks.push(Task { title: req.title, done: req.done });
        Ok("Task added.".to_string())
    }

    #[tool(description = "List all tasks")]
    fn list_tasks(&self) -> Result<Vec<Task>> {
        // Lock, clone the current list, and return it
        let tasks = self.tasks.lock().unwrap();
        Ok(tasks.clone()) // Clone the data to release the lock quickly
    }
}
  • Demonstrates state management using Arc<Mutex<Vec<Task>>> for sharing the task list safely across concurrent requests (if the server runs multithreaded).
  • list_tasks returns the list of Task structs directly. rmcp handles serializing this into the JSON-RPC response.

2.4. Weather Forecast

Fetches weather from the wttr.in service.

use rmcp::{tool, tool_box};
use serde::Deserialize;
use schemars::JsonSchema;
use anyhow::Result;
use reqwest;

#[derive(Clone)] // Added Clone trait
struct WeatherServer;

#[derive(Debug, Deserialize, JsonSchema)]
struct CityRequest {
    city: String,
}

#[tool_box]
impl WeatherServer {
    #[tool(description = "Get city weather from wttr.in")]
    async fn get_forecast(&self, #[tool(aggr)] req: CityRequest) -> Result<String> {
        // Async again due to network call
        let url = format!("https://wttr.in/{}?format=3", req.city); // format=3 gives concise output
        let forecast = reqwest::get(&url).await?
                        .text().await?; // Get response body as plain text
        Ok(forecast)
    }
}
  • Similar to the currency converter, uses async fn and reqwest for an external HTTP request.
  • Fetches plain text data from wttr.in.

2.5. JSON Formatter

A simple tool to prettify a JSON string.

use rmcp::{tool, tool_box};
use anyhow::Result;
use serde_json; // Use serde_json for JSON manipulation

#[derive(Clone)] // Added Clone trait
struct JsonServer;

#[tool_box]
impl JsonServer {
    #[tool(description = "Prettify a JSON string")]
    fn format_json(&self, #[tool(param)] raw: String) -> Result<String> {
        // #[tool(param)] indicates 'raw' is the single, unnamed parameter
        // expected in the JSON-RPC request's "params" field.

        // Parse the raw string into a generic JSON value
        let value: serde_json::Value = serde_json::from_str(&raw)?;

        // Serialize the value back to a prettified string
        Ok(serde_json::to_string_pretty(&value)?)
    }
}
  • Uses #[tool(param)] for methods expecting a single unnamed parameter in the params field of the JSON-RPC request (e.g., "params": "{\"key\":\"value\"}").
  • Leverages serde_json to parse and then re-serialize the JSON data with pretty printing.

3. KYC Tool Set — Servers & Client Interaction

Now, let's look at more specialized tools, common in KYC (Know Your Customer) processes. We'll show both the server-side tool definition and how a client might call it.

(Note: These KYC examples are illustrative and simplified.)

3.1. Identity OCR Tool

Server: Extracts text from an image file using the external tesseract OCR tool.

# // --- Server Code ---
# use rmcp::{tool, tool_box};
# use serde::Deserialize;
# use schemars::JsonSchema;
# use anyhow::Result;
# use std::process::Command; // To run external processes
#
# // Assuming KycServer is defined elsewhere or combined
# #[derive(Clone)] struct KycServer;
#
# #[derive(Debug, Deserialize, JsonSchema)]
# struct OcrRequest {
#     image_path: String, // Path to the image file for OCR
# }
#
# #[tool_box]
# impl KycServer { // Assuming methods are added to a common KycServer
    #[tool(description = "Extract text from an ID document image using Tesseract OCR")]
    async fn extract_id_text(&self, #[tool(aggr)] req: OcrRequest) -> Result<String> {
        // Run the tesseract command externally
        // Assumes 'tesseract' executable is in the system's PATH
        let output = Command::new("tesseract")
            .arg(&req.image_path) // Input image file
            .arg("stdout")        // Output result to stdout
            .output()?;           // Execute and wait for completion

        if output.status.success() {
            // Convert stdout bytes to a UTF-8 string (lossy conversion handles potential errors)
            Ok(String::from_utf8_lossy(&output.stdout).to_string())
        } else {
            // If tesseract failed, return an error
            let stderr = String::from_utf8_lossy(&output.stderr);
            Err(anyhow::anyhow!("Tesseract failed: {}", stderr))
        }
    }
# }
  • Uses std::process::Command to execute the tesseract command-line tool.
  • Requires tesseract to be installed on the server machine where this Rust code runs.
  • Handles potential errors from the external process.

Client: Example of calling this tool using rmcp client (raw request).

# // --- Client Code Example ---
# use rmcp::client::Client; // Assuming 'Client' is set up
# use serde_json::json;
# use anyhow::Result;
#
# async fn call_ocr_tool(client: &Client) -> Result<()> {
let request_payload = json!({
    "jsonrpc": "2.0",
    "id": 1, // Request ID
    "method": "extract_id_text", // Matches the server function name
    "params": { // Corresponds to #[tool(aggr)] req: OcrRequest
        "image_path": "./path/to/your/id_photo.png"
    }
});

// Assuming 'client' is an initialized rmcp::client::Client
let result = client.send_raw(request_payload).await?;

println!("OCR Result: {:?}", result); // Prints the raw JSON-RPC response
# Ok(())
# }
  • Constructs a standard JSON-RPC 2.0 request payload using serde_json::json!.
  • Sends the raw request using client.send_raw. The result will be the full JSON-RPC response { "jsonrpc": "2.0", "id": 1, "result": "extracted text..." } or an error object.

3.2. Bank Account Verification

Server: A simplified check matching name and IBAN details.

# // --- Server Code ---
# use rmcp::{tool, tool_box};
# use serde::Deserialize;
# use schemars::JsonSchema;
# use anyhow::Result;
#
# // Assuming KycServer is defined elsewhere or combined
# #[derive(Clone)] struct KycServer;
#
#[derive(Debug, Deserialize, JsonSchema)]
struct BankCheckRequest {
    name: String,
    iban: String,
}

#[tool_box]
impl KycServer { // Assuming methods are added to a common KycServer
    #[tool(description = "Verify bank holder name and IBAN match (simplified check)")]
    async fn verify_bank(&self, #[tool(aggr)] req: BankCheckRequest) -> Result<String> {
        // Basic placeholder logic: Checks if IBAN starts with 'FR' and name contains 'test' (case-insensitive)
        // Replace with actual validation logic (e.g., API call to a verification service)
        if req.iban.starts_with("FR") && req.name.to_lowercase().contains("test") {
            Ok("Match confirmed (Test Logic).".to_string())
        } else {
            Ok("No match found (Test Logic).".to_string())
        }
    }
}
  • Implements placeholder logic for demonstration. A real-world tool would integrate with a banking API or database.

Client: Example call.

# // --- Client Code Example ---
# use rmcp::client::Client; // Assuming 'Client' is set up
# use serde_json::json;
# use anyhow::Result;
#
# async fn call_bank_verify_tool(client: &Client) -> Result<()> {
let request_payload = json!({
    "jsonrpc": "2.0",
    "id": 2, // Different request ID
    "method": "verify_bank",
    "params": { // Corresponds to #[tool(aggr)] req: BankCheckRequest
        "name": "Test User",
        "iban": "FR7612345678901234567890123"
     }
});

let result = client.send_raw(request_payload).await?;

println!("Bank Verification Result: {:?}", result);
# Ok(())
# }
  • Similar structure to the OCR client call, targeting the verify_bank method with appropriate parameters.

4. Conclusion

You've seen how to create various MCP tools in Rust using the rmcp crate, covering stateless and stateful servers, interaction with external APIs, and running external processes. The #[tool_box] and #[tool] attributes, combined with serde and schemars, provide a powerful way to expose Rust functions to AI models or other systems speaking JSON-RPC 2.0.