Chapter 2: Your First MCP Server
In this chapter, you’ll build your first Model Context Protocol server using PMCP. We’ll start with a simple calculator server and gradually add more features.
Basic Server Structure
Every MCP server needs:
- Tool handlers - Functions that clients can call
- Server configuration - Name, version, capabilities
- Transport layer - How clients connect (stdio, WebSocket, HTTP)
Let’s build a calculator server step by step.
Step 1: Project Setup
Create a new Rust project:
cargo new mcp-calculator
cd mcp-calculator
Add dependencies to Cargo.toml
:
[dependencies]
pmcp = { version = "1.4.1", features = ["full"] }
tokio = { version = "1.0", features = ["full"] }
serde_json = "1.0"
async-trait = "0.1"
Step 2: Basic Calculator Tool
Replace src/main.rs
with:
use pmcp::{Server, ToolHandler, RequestHandlerExtra, Result}; use serde_json::{json, Value}; use async_trait::async_trait; // Define our calculator tool handler struct Calculator; #[async_trait] impl ToolHandler for Calculator { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { // Extract arguments let a = args.get("a") .and_then(|v| v.as_f64()) .ok_or_else(|| pmcp::Error::validation("Missing or invalid parameter 'a'"))?; let b = args.get("b") .and_then(|v| v.as_f64()) .ok_or_else(|| pmcp::Error::validation("Missing or invalid parameter 'b'"))?; let operation = args.get("operation") .and_then(|v| v.as_str()) .unwrap_or("add"); // Perform calculation let result = match operation { "add" => a + b, "subtract" => a - b, "multiply" => a * b, "divide" => { if b == 0.0 { return Err(pmcp::Error::validation("Division by zero")); } a / b } _ => return Err(pmcp::Error::validation("Unknown operation")), }; // Return structured response Ok(json!({ "content": [{ "type": "text", "text": format!("{} {} {} = {}", a, operation, b, result) }], "isError": false })) } } #[tokio::main] async fn main() -> Result<()> { // Create and configure the server let server = Server::builder() .name("calculator-server") .version("1.0.0") .tool("calculate", Calculator) .build()?; println!("🧮 Calculator MCP Server starting..."); println!("Connect using any MCP client on stdio"); // Run on stdio (most common for MCP servers) server.run_stdio().await }
Step 3: Test Your Server
Run the server:
cargo run
You should see:
🧮 Calculator MCP Server starting...
Connect using any MCP client on stdio
The server is now running and waiting for MCP protocol messages on stdin/stdout.
Step 4: Test with a Client
Create a simple test client. Add this to src/bin/test-client.rs
:
use pmcp::{Client, ClientCapabilities}; use serde_json::json; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create a client let mut client = Client::builder() .name("calculator-client") .version("1.0.0") .capabilities(ClientCapabilities::default()) .build()?; // Connect via stdio to our server // In practice, you'd connect via WebSocket or HTTP println!("🔗 Connecting to calculator server..."); // For testing, we'll create a manual request let request = json!({ "method": "tools/call", "params": { "name": "calculate", "arguments": { "a": 10, "b": 5, "operation": "multiply" } } }); println!("📤 Sending request: {}", serde_json::to_string_pretty(&request)?); // In a real client, you'd send this via the transport and get a response println!("✅ Calculator server is ready to receive requests!"); Ok(()) }
Build the test client:
cargo build --bin test-client
Step 5: Enhanced Server with Multiple Tools
Let’s add more tools to make our server more useful. Update src/main.rs
:
use pmcp::{Server, ToolHandler, RequestHandlerExtra, Result, ServerCapabilities, ToolCapabilities}; use serde_json::{json, Value}; use async_trait::async_trait; use std::collections::HashMap; // Calculator tool (same as before) struct Calculator; #[async_trait] impl ToolHandler for Calculator { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let a = args.get("a").and_then(|v| v.as_f64()) .ok_or_else(|| pmcp::Error::validation("Missing parameter 'a'"))?; let b = args.get("b").and_then(|v| v.as_f64()) .ok_or_else(|| pmcp::Error::validation("Missing parameter 'b'"))?; let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or("add"); let result = match operation { "add" => a + b, "subtract" => a - b, "multiply" => a * b, "divide" => { if b == 0.0 { return Err(pmcp::Error::validation("Division by zero")); } a / b } _ => return Err(pmcp::Error::validation("Unknown operation")), }; Ok(json!({ "content": [{ "type": "text", "text": format!("{} {} {} = {}", a, operation, b, result) }], "isError": false })) } } // Statistics tool - demonstrates stateful operations struct Statistics { calculations: tokio::sync::Mutex<Vec<f64>>, } impl Statistics { fn new() -> Self { Self { calculations: tokio::sync::Mutex::new(Vec::new()), } } } #[async_trait] impl ToolHandler for Statistics { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let operation = args.get("operation") .and_then(|v| v.as_str()) .ok_or_else(|| pmcp::Error::validation("Missing 'operation' parameter"))?; let mut calculations = self.calculations.lock().await; match operation { "add_value" => { let value = args.get("value").and_then(|v| v.as_f64()) .ok_or_else(|| pmcp::Error::validation("Missing 'value' parameter"))?; calculations.push(value); Ok(json!({ "content": [{ "type": "text", "text": format!("Added {} to statistics. Total values: {}", value, calculations.len()) }], "isError": false })) } "get_stats" => { if calculations.is_empty() { return Ok(json!({ "content": [{ "type": "text", "text": "No data available for statistics" }], "isError": false })); } let sum: f64 = calculations.iter().sum(); let count = calculations.len(); let mean = sum / count as f64; let min = calculations.iter().fold(f64::INFINITY, |a, &b| a.min(b)); let max = calculations.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); Ok(json!({ "content": [{ "type": "text", "text": format!( "Statistics:\n• Count: {}\n• Sum: {:.2}\n• Mean: {:.2}\n• Min: {:.2}\n• Max: {:.2}", count, sum, mean, min, max ) }], "isError": false })) } "clear" => { calculations.clear(); Ok(json!({ "content": [{ "type": "text", "text": "Statistics cleared" }], "isError": false })) } _ => Err(pmcp::Error::validation("Unknown statistics operation")), } } } // System info tool - demonstrates environment interaction struct SystemInfo; #[async_trait] impl ToolHandler for SystemInfo { async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let info = json!({ "server": "calculator-server", "version": "1.0.0", "protocol_version": "2025-06-18", "features": ["calculation", "statistics", "system_info"], "uptime": "Just started", // In a real app, you'd track actual uptime "rust_version": env!("RUSTC_VERSION") }); Ok(json!({ "content": [{ "type": "text", "text": format!("System Information:\n{}", serde_json::to_string_pretty(&info)?) }], "isError": false })) } } #[tokio::main] async fn main() -> Result<()> { // Create shared statistics handler let stats_handler = Statistics::new(); // Create and configure the enhanced server let server = Server::builder() .name("calculator-server") .version("1.0.0") .capabilities(ServerCapabilities { tools: Some(ToolCapabilities { list_changed: Some(true), }), ..Default::default() }) .tool("calculate", Calculator) .tool("statistics", stats_handler) .tool("system_info", SystemInfo) .build()?; println!("🧮 Enhanced Calculator MCP Server starting..."); println!("Available tools:"); println!(" • calculate - Basic arithmetic operations"); println!(" • statistics - Statistical calculations on datasets"); println!(" • system_info - Server information"); println!(); println!("Connect using any MCP client on stdio"); // Run the server server.run_stdio().await }
Step 6: Error Handling Best Practices
PMCP provides comprehensive error handling. Here’s how to handle different error scenarios:
#![allow(unused)] fn main() { use pmcp::Error; // Input validation errors if args.is_null() { return Err(Error::validation("Arguments cannot be null")); } // Protocol errors if unsupported_feature { return Err(Error::protocol( pmcp::ErrorCode::InvalidRequest, "This feature is not supported" )); } // Internal errors if let Err(e) = some_operation() { return Err(Error::internal(format!("Operation failed: {}", e))); } // Custom errors with structured data return Err(Error::custom( -32001, // Custom error code "Custom error occurred", Some(json!({ "error_type": "custom", "context": "additional_info" })) )); }
Step 7: Testing Your Server
Create comprehensive tests in src/lib.rs
:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use serde_json::json; #[tokio::test] async fn test_calculator_basic_operations() { let calculator = Calculator; let extra = RequestHandlerExtra::new( "test".to_string(), tokio_util::sync::CancellationToken::new(), ); // Test addition let args = json!({"a": 5, "b": 3, "operation": "add"}); let result = calculator.handle(args, extra.clone()).await.unwrap(); assert!(!result["isError"].as_bool().unwrap_or(true)); assert!(result["content"][0]["text"].as_str().unwrap().contains("5 add 3 = 8")); // Test division by zero let args = json!({"a": 5, "b": 0, "operation": "divide"}); let result = calculator.handle(args, extra.clone()).await; assert!(result.is_err()); } #[tokio::test] async fn test_statistics() { let stats = Statistics::new(); let extra = RequestHandlerExtra::new( "test".to_string(), tokio_util::sync::CancellationToken::new(), ); // Add some values for value in [1.0, 2.0, 3.0, 4.0, 5.0] { let args = json!({"operation": "add_value", "value": value}); let result = stats.handle(args, extra.clone()).await.unwrap(); assert!(!result["isError"].as_bool().unwrap_or(true)); } // Get statistics let args = json!({"operation": "get_stats"}); let result = stats.handle(args, extra.clone()).await.unwrap(); let text = result["content"][0]["text"].as_str().unwrap(); assert!(text.contains("Count: 5")); assert!(text.contains("Mean: 3.00")); } } }
Run the tests:
cargo test
Step 8: Production Considerations
For production deployment, consider these enhancements:
Logging and Tracing
use tracing::{info, warn, error}; use tracing_subscriber; #[tokio::main] async fn main() -> Result<()> { // Initialize logging tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); info!("Starting calculator server"); // Your server code here... Ok(()) }
Configuration
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] struct Config { server_name: String, max_connections: usize, log_level: String, } fn load_config() -> Config { // Load from environment variables, config file, etc. Config { server_name: std::env::var("SERVER_NAME") .unwrap_or_else(|_| "calculator-server".to_string()), max_connections: std::env::var("MAX_CONNECTIONS") .unwrap_or_else(|_| "100".to_string()) .parse() .unwrap_or(100), log_level: std::env::var("LOG_LEVEL") .unwrap_or_else(|_| "info".to_string()), } } }
Metrics and Health Checks
#![allow(unused)] fn main() { use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; struct Metrics { requests_total: AtomicU64, errors_total: AtomicU64, } impl Metrics { fn new() -> Self { Self { requests_total: AtomicU64::new(0), errors_total: AtomicU64::new(0), } } fn increment_requests(&self) { self.requests_total.fetch_add(1, Ordering::Relaxed); } fn increment_errors(&self) { self.errors_total.fetch_add(1, Ordering::Relaxed); } } // Use metrics in your tool handlers #[async_trait] impl ToolHandler for Calculator { async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> { // Increment request counter self.metrics.increment_requests(); // Your tool logic here... match result { Ok(value) => Ok(value), Err(e) => { self.metrics.increment_errors(); Err(e) } } } } }
What’s Next?
You’ve built a complete MCP server with:
- ✅ Multiple tool handlers
- ✅ Proper error handling
- ✅ Stateful operations
- ✅ Comprehensive tests
- ✅ Production considerations
In the next chapter, we’ll build a client that connects to your server and demonstrates the full request-response cycle.
Complete Example
The complete working example is available in the PMCP repository:
- Server:
examples/02_server_basic.rs
- Enhanced Server:
examples/calculator_server.rs
- Tests:
tests/calculator_tests.rs
Key Takeaways
- Tool handlers are the core - They define what your server can do
- Error handling is crucial - Use PMCP’s error types for protocol compliance
- State management works - Use Rust’s sync primitives for shared state
- Testing is straightforward - PMCP handlers are easy to unit test
- Production readiness matters - Consider logging, metrics, and configuration
Ready to build a client? Let’s go to Chapter 3!