The PMCP Guide
Pragmatic Model Context Protocol
High-Performance Rust SDK for Model Context Protocol
Version 1.4.1
Authors: PAIML Team
License: MIT
About PMCP
PMCP (Pragmatic Model Context Protocol) is a high-quality Rust implementation of the Model Context Protocol (MCP) SDK, maintaining full compatibility with the TypeScript SDK while leveraging Rust’s performance and safety guarantees.
Code Name: Angel Rust
What You’ll Learn
This book will teach you how to:
- Build robust MCP servers and clients using PMCP
- Leverage Rust’s type system for protocol safety
- Achieve TypeScript SDK compatibility
- Implement advanced features like authentication and middleware
- Deploy production-ready MCP applications
- Optimize performance for high-throughput scenarios
Prerequisites
- Basic knowledge of Rust programming
- Familiarity with async/await concepts
- Understanding of network protocols (helpful but not required)
Toyota Way Quality Standards
This book follows Toyota Way principles with zero tolerance for defects:
- ✅ All examples are tested and working
- ✅ 74%+ test coverage on all code
- ✅ Zero clippy warnings
- ✅ Comprehensive documentation with examples
- ✅ TDD (Test-Driven Development) methodology
Foreword
Welcome to The PMCP Guide, your comprehensive resource for mastering the Pragmatic Model Context Protocol in Rust.
Why PMCP?
The Model Context Protocol (MCP) represents a fundamental shift in how AI applications communicate with external systems. While the TypeScript SDK provided an excellent foundation, the Rust ecosystem demanded a solution that leveraged Rust’s unique strengths: memory safety, zero-cost abstractions, and fearless concurrency.
PMCP was born from this need, designed from day one to provide:
- TypeScript Compatibility: 100% protocol compatibility with the official TypeScript SDK
- Performance: 10x faster than TypeScript implementations
- Safety: Leverage Rust’s type system for protocol correctness
- Quality: Toyota Way standards with zero tolerance for defects
Who This Book Is For
This book is designed for developers who want to:
- Build high-performance MCP servers and clients
- Migrate from TypeScript MCP implementations
- Learn best practices for protocol implementation
- Understand advanced MCP patterns and techniques
Whether you’re building AI assistants, data processing pipelines, or integration services, PMCP provides the tools you need to create robust, production-ready applications.
How This Book Is Organized
The book follows a practical, example-driven approach:
Part I introduces core concepts and gets you building immediately
Part II covers essential MCP primitives: tools, resources, and prompts
Part III explores advanced features like authentication and custom transports
Part IV focuses on production deployment and optimization
Part V provides complete, real-world examples
Part VI ensures TypeScript SDK compatibility
Part VII covers advanced topics and contribution guidelines
Every chapter includes working examples that you can run, modify, and learn from.
Test-Driven Documentation
Following the Toyota Way principles, this book practices what it preaches. Every code example is tested before being documented, ensuring that you can trust the code you see will work exactly as described.
Let’s Begin
The Model Context Protocol ecosystem is rapidly evolving, and PMCP positions you at the forefront of this revolution. Whether you’re building the next generation of AI tools or integrating existing systems, this guide will help you harness the full power of PMCP.
Let’s build something amazing together.
The PAIML Team
Introduction
The Model Context Protocol (MCP) is revolutionizing how AI applications interact with external systems, tools, and data sources. PMCP brings this power to the Rust ecosystem with uncompromising quality and performance.
What is MCP?
The Model Context Protocol is a standardized way for AI applications to:
- Discover and invoke tools - Execute functions and commands
- Access resources - Read files, query databases, fetch web content
- Use prompts and templates - Generate structured responses
- Manage context - Maintain state across interactions
Think of MCP as a universal adapter that allows AI models to interact with any system through a consistent, well-defined interface.
What is PMCP?
PMCP (Pragmatic Model Context Protocol) is a high-performance Rust implementation that:
- Maintains 100% TypeScript SDK compatibility - Drop-in replacement for existing applications
- Leverages Rust’s type system - Catch protocol errors at compile time
- Delivers superior performance - 10x faster than TypeScript implementations
- Follows Toyota Way quality standards - Zero tolerance for defects
- Provides comprehensive tooling - Everything you need for production deployment
Key Features
🚀 Performance
- Zero-cost abstractions - Pay only for what you use
- Async-first design - Handle thousands of concurrent connections
- Memory efficient - Minimal allocation overhead
- SIMD optimizations - Vectorized protocol parsing
🔒 Type Safety
- Compile-time protocol validation - Catch errors before deployment
- Rich type system - Express complex protocol constraints
- Memory safety - No segfaults, no data races
- Resource management - Automatic cleanup and lifecycle management
🔄 Compatibility
- TypeScript SDK parity - Identical protocol behavior
- Cross-platform support - Linux, macOS, Windows, WebAssembly
- Multiple transports - WebSocket, HTTP, Streamable HTTP, SSE
- Version compatibility - Support for all MCP protocol versions
🏭 Production Ready
- Comprehensive testing - 74%+ coverage, property tests, integration tests
- Battle-tested examples - Real-world usage patterns
- Monitoring and observability - Built-in metrics and tracing
- Security hardened - OAuth2, rate limiting, input validation
Architecture Overview
+-------------------+ +-------------------+ +-------------------+
| MCP Client |<--->| Transport |<--->| MCP Server |
| | | Layer | | |
| - Tool calls | | - WebSocket | | - Tool handlers |
| - Resource req | | - HTTP | | - Resources |
| - Prompt use | | - Streamable | | - Prompts |
+-------------------+ +-------------------+ +-------------------+
PMCP provides implementations for all components:
- Client Library - Connect to any MCP server
- Server Framework - Build custom MCP servers
- Transport Implementations - WebSocket, HTTP, and more
- Protocol Utilities - Serialization, validation, error handling
Getting Started
The fastest way to experience PMCP is through our examples:
# Install PMCP
cargo add pmcp
# Run a simple server
cargo run --example 02_server_basic
# Connect with a client
cargo run --example 01_client_initialize
Real-World Example
Here’s a complete MCP server in just a few lines:
use pmcp::{Server, ToolHandler, RequestHandlerExtra, Result}; use serde_json::{json, Value}; use async_trait::async_trait; struct Calculator; #[async_trait] impl ToolHandler for Calculator { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let a = args["a"].as_f64().unwrap_or(0.0); let b = args["b"].as_f64().unwrap_or(0.0); Ok(json!({ "content": [{ "type": "text", "text": format!("Result: {}", a + b) }], "isError": false })) } } #[tokio::main] async fn main() -> Result<()> { Server::builder() .name("calculator-server") .version("1.0.0") .tool("add", Calculator) .build()? .run_stdio() .await }
This server:
- ✅ Handles tool calls with full type safety
- ✅ Provides structured responses
- ✅ Includes comprehensive error handling
- ✅ Works with any MCP client (including TypeScript)
What’s Next?
In the following chapters, you’ll learn how to:
- Install and configure PMCP for your environment
- Build your first server with tools, resources, and prompts
- Create robust clients that handle errors gracefully
- Implement advanced features like authentication and middleware
- Deploy to production with confidence and monitoring
- Integrate with existing systems using battle-tested patterns
Let’s dive in and start building with PMCP!
Why MCP: The Power of Real-time Tools
When building AI applications for the enterprise, a common debate arises: “Should we fine-tune a model with our data, or should we use a tool-based approach?”
While fine-tuning has its place, it often falls short for applications that rely on timely, secure, and verifiable information. Fine-tuning creates a static snapshot of your data, which quickly becomes stale. It’s a costly, time-consuming process that must be repeated to incorporate new information or benefit from base model improvements.
A modern, more effective architecture uses MCP (Model-Controller-Provider) servers as tools. This approach allows a Large Language Model (LLM) to securely access and reason over your proprietary data in real-time, for every single request.
The diagram below illustrates the two workflows and highlights the benefits of the MCP tool-based approach.
flowchart TD
subgraph "Modern AI Architecture: MCP Tools vs. Fine-Tuning"
direction TB
subgraph "✅ Recommended: Real-time, Secure, and Verifiable"
direction LR
User1[fa:fa-user User] -- "1. User Request" --> LLM_Tools[fa:fa-robot LLM]
LLM_Tools -- "2. Tool Call to MCP" --> MCP[fa:fa-server MCP Server]
MCP -- "3. Query Fresh Data" --> DB1[fa:fa-database Internal Company Data]
DB1 -- "4. Return Data" --> MCP
MCP -- "5. Provide Data to LLM" --> LLM_Tools
LLM_Tools -- "6. Generate Informed Response" --> User1
end
subgraph "❌ Legacy: Stale, Costly, and Opaque"
direction LR
DB2[fa:fa-database Internal Company Data] -- "1. One-time, Costly Training" --> FT_LLM[fa:fa-robot Fine-Tuned LLM]
User2[fa:fa-user User] -- "2. User Request" --> FT_LLM
FT_LLM -- "3. Generate Stale Response" --> User2
end
note1["<strong>Benefits of MCP Tools:</strong><br/>- Access to real-time, fresh data<br/>- Leverages base model updates instantly<br/>- Secure: No data leakage into model weights<br/>- Verifiable: Access raw data, not a statistical summary<br/>- Lower operational cost than re-training"]
note2["<strong>Drawbacks of Fine-Tuning:</strong><br/>- Data becomes stale immediately<br/>- Must re-train to get base model updates<br/>- High cost and complexity of training<br/>- 'Black box' reasoning from a static snapshot"]
%% Styling and positioning notes
linkStyle 0 stroke-width:2px,fill:none,stroke:green;
linkStyle 1 stroke-width:2px,fill:none,stroke:green;
linkStyle 2 stroke-width:2px,fill:none,stroke:green;
linkStyle 3 stroke-width:2px,fill:none,stroke:green;
linkStyle 4 stroke-width:2px,fill:none,stroke:green;
linkStyle 5 stroke-width:2px,fill:none,stroke:green;
linkStyle 6 stroke-width:2px,fill:none,stroke:red;
linkStyle 7 stroke-width:2px,fill:none,stroke:red;
linkStyle 8 stroke-width:2px,fill:none,stroke:red;
classDef green fill:#e8f5e9,stroke:#4caf50,color:#000;
classDef red fill:#ffebee,stroke:#f44336,color:#000;
class LLM_Tools,MCP,DB1,User1 green;
class FT_LLM,DB2,User2 red;
end
Key Advantages of the MCP Tool-Based Approach
- Real-Time Data: Your AI system always has access to the most current information, eliminating the “stale data” problem inherent in fine-tuned models.
- Future-Proof: You can instantly benefit from advancements in base LLMs (from providers like Google, OpenAI, etc.) without needing to retrain or re-tune your model.
- Cost-Effective: Avoids the significant computational and financial costs associated with repeatedly fine-tuning large models.
- Security & Governance: Data is retrieved on-demand and used for a single response. Sensitive information is not baked into the model’s weights, providing better control and auditability.
- Verifiability: Because the LLM uses raw data to construct its answer, it’s easier to trace the source of information and verify the accuracy of the response, which is critical for enterprise use cases.
Chapter 1: Installation & Setup
Getting started with PMCP is straightforward. This chapter will guide you through installing PMCP, setting up your development environment, and verifying everything works correctly.
System Requirements
PMCP supports all major platforms:
- Linux (Ubuntu 20.04+, RHEL 8+, Arch Linux)
- macOS (10.15+)
- Windows (Windows 10+)
- WebAssembly (for browser environments)
Minimum Requirements:
- Rust 1.82+
- 2GB RAM
- 1GB disk space
Installation Methods
Method 1: Using Cargo (Recommended)
Add PMCP to your Cargo.toml:
[dependencies]
pmcp = "1.4.1"
Or use cargo add:
cargo add pmcp
Method 2: From Source
Clone and build from source for the latest features:
git clone https://github.com/paiml/pmcp.git
cd pmcp
cargo build --release
Method 3: Pre-built Binaries
Download pre-built binaries from the releases page:
# Linux/macOS
curl -L https://github.com/paiml/pmcp/releases/latest/download/pmcp-linux.tar.gz | tar xz
# Windows PowerShell
Invoke-WebRequest -Uri "https://github.com/paiml/pmcp/releases/latest/download/pmcp-windows.zip" -OutFile "pmcp.zip"
Expand-Archive pmcp.zip
Feature Flags
PMCP uses feature flags to minimize binary size. Choose the features you need:
[dependencies]
pmcp = { version = "1.4.1", features = ["full"] }
Available Features
| Feature | Description | Dependencies |
|---|---|---|
default | Core functionality + validation | jsonschema, garde |
full | All features enabled | All dependencies |
websocket | WebSocket transport | tokio-tungstenite |
http | HTTP transport | hyper, hyper-util |
streamable-http | Streamable HTTP server | axum, tokio-stream |
sse | Server-Sent Events | bytes, tokio-util |
validation | Input validation | jsonschema, garde |
resource-watcher | File system watching | notify, glob-match |
wasm | WebAssembly support | wasm-bindgen |
Common Configurations
Minimal client:
pmcp = { version = "1.4.1", features = ["validation"] }
WebSocket server:
pmcp = { version = "1.4.1", features = ["websocket", "validation"] }
Production server:
pmcp = { version = "1.4.1", features = ["full"] }
Development Environment Setup
Install Required Tools
# Install Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add required components
rustup component add rustfmt clippy llvm-tools-preview
# Install development tools
cargo install cargo-nextest cargo-llvm-cov cargo-audit
IDE Configuration
Visual Studio Code:
# Install Rust extension
code --install-extension rust-lang.rust-analyzer
vim/neovim:
" Add to your config
Plug 'rust-lang/rust.vim'
Plug 'neoclide/coc.nvim'
JetBrains IntelliJ/CLion:
- Install the Rust plugin from the marketplace
Verification
Quick Test
Create a new project and verify PMCP works:
cargo new pmcp-test
cd pmcp-test
Add to Cargo.toml:
[dependencies]
pmcp = "1.4.1"
tokio = { version = "1.0", features = ["full"] }
Replace src/main.rs:
use pmcp::{Client, ClientCapabilities}; #[tokio::main] async fn main() -> pmcp::Result<()> { println!("PMCP version: {}", pmcp::VERSION); // Test client creation let client = Client::builder() .name("test-client") .version("1.0.0") .capabilities(ClientCapabilities::default()) .build()?; println!("✅ PMCP client created successfully!"); println!("Client name: {}", client.name()); Ok(()) }
Run the test:
cargo run
Expected output:
PMCP version: 1.4.1
✅ PMCP client created successfully!
Client name: test-client
Run Examples
Test with the included examples:
# Clone the repository
git clone https://github.com/paiml/pmcp.git
cd pmcp
# Run basic server example
cargo run --example 02_server_basic --features full
# In another terminal, run client example
cargo run --example 01_client_initialize --features full
Performance Benchmark
Verify performance with built-in benchmarks:
cargo bench --all-features
Expected results (approximate):
simple_protocol_parse time: [12.5 ns 12.8 ns 13.2 ns]
json_serialization time: [1.85 μs 1.89 μs 1.94 μs]
websocket_roundtrip time: [45.2 μs 46.1 μs 47.3 μs]
Common Issues
Compilation Errors
Issue: Missing features
error[E0432]: unresolved import `pmcp::WebSocketTransport`
Solution: Enable required features:
pmcp = { version = "1.4.1", features = ["websocket"] }
Issue: MSRV (Minimum Supported Rust Version)
error: package `pmcp v1.4.1` cannot be built because it requires rustc 1.82 or newer
Solution: Update Rust:
rustup update stable
Runtime Issues
Issue: Port already in use
Error: Address already in use (os error 98)
Solution: Use a different port:
#![allow(unused)] fn main() { server.bind("127.0.0.1:0").await?; // Let OS choose port }
Issue: Permission denied
Error: Permission denied (os error 13)
Solution: Use unprivileged port (>1024):
#![allow(unused)] fn main() { server.bind("127.0.0.1:8080").await?; }
Performance Issues
Issue: High memory usage
Memory usage: 2.3GB for simple server
Solution: Disable debug symbols in release:
[profile.release]
debug = false
strip = true
Next Steps
Now that PMCP is installed and working, you’re ready to:
- Build your first server - Chapter 2 walks through creating a basic MCP server
- Create a client - Chapter 3 shows how to connect and interact with servers
- Explore examples - Check out the
examples/directory for real-world patterns
Getting Help
If you encounter issues:
- Documentation: https://docs.rs/pmcp
- Examples: https://github.com/paiml/pmcp/tree/main/examples
- Issues: https://github.com/paiml/pmcp/issues
- Discussions: https://github.com/paiml/pmcp/discussions
You’re all set! Let’s start building with PMCP.
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!
Chapter 3: MCP Clients — Your Gateway to AI-Powered Applications
This chapter introduces MCP clients—the applications that connect to MCP servers to enable AI agents to use your tools, read your resources, and follow your prompts. Just as web browsers are the gateway to websites, MCP clients are the gateway to MCP servers.
The goal: after this chapter, you’ll understand how MCP clients work, how to build your own testing and integration clients, and how professional AI applications like Claude Desktop, ChatGPT, and Cursor use the protocol.
The Browser Analogy
If an MCP server is like a website (Chapter 4), then an MCP client is like a web browser or automated testing tool.
| Web ecosystem | MCP ecosystem | Purpose |
|---|---|---|
| Chrome, Firefox, Safari, Edge | Claude Desktop, ChatGPT, Cursor, Co-Pilot | End-user applications that humans use |
| Selenium, Playwright, Puppeteer | MCP Tester, custom test clients | Automated testing and validation tools |
| cURL, Postman, HTTPie | Simple MCP clients, debugging tools | Developer tools for exploration and debugging |
| Browser DevTools | MCP Inspector | Protocol inspection and diagnostics |
Just as you have a handful of major browsers but millions of websites, the MCP ecosystem has a few major AI applications (clients) but will grow to have thousands of specialized servers providing domain-specific capabilities.
Why Build an MCP Client?
While most developers will build servers to expose their capabilities, there are important reasons to understand and build clients:
- Testing & Validation: Verify your MCP servers work correctly before deploying
- Integration: Connect your own applications to MCP servers programmatically
- Automation: Build workflows that orchestrate multiple MCP servers
- Custom UIs: Create specialized interfaces for specific domains
- Debugging: Understand how clients interact with your servers
Real-World MCP Clients
Production Clients (The “Browsers”)
These are the AI applications your users will interact with:
- Claude Desktop: Anthropic’s official desktop application with native MCP support
- ChatGPT: OpenAI’s conversational AI with plugin/extension support
- GitHub Copilot: Microsoft’s AI coding assistant integrated into IDEs
- Cursor: AI-first code editor with deep MCP integration
- Continue: Open-source AI code assistant with MCP support
Testing & Development Clients (The “Testing Tools”)
MCP Tester (from this SDK) is like Selenium for MCP—a comprehensive testing tool that validates:
- Protocol compliance (JSON-RPC 2.0, MCP protocol versions)
- Server capabilities (tools, resources, prompts)
- Tool schemas and validation
- Performance and health monitoring
- Scenario-based testing with assertions
WASM Client (from this SDK) is like a browser-based Postman—runs entirely in the browser for:
- Interactive server exploration
- Quick debugging without installation
- Demos and documentation
- Testing CORS configurations
- Educational purposes
Client Architecture Overview
An MCP client has three core responsibilities:
- Connection Management: Establish and maintain the connection to a server
- Protocol Handling: Implement JSON-RPC 2.0 and MCP protocol semantics
- Capability Negotiation: Declare what features the client supports
%%{init: { 'theme': 'neutral' }}%%
flowchart TB
subgraph Client["MCP Client"]
app["Application Layer<br/>(Your code)"]
client["Client Core<br/>(PMCP SDK)"]
transport["Transport Layer<br/>(stdio/HTTP/WebSocket)"]
end
subgraph Server["MCP Server"]
server_transport["Transport Layer"]
server_core["Server Core"]
handlers["Request Handlers"]
end
app --> client
client --> transport
transport <--> server_transport
server_transport <--> server_core
server_core <--> handlers
ASCII fallback:
[Your Application Code]
↓
[MCP Client (PMCP SDK)]
↓
[Transport (stdio/HTTP/WebSocket)]
↕
[MCP Server Transport]
↓
[Server Core & Handlers]
Building Your First Client
Let’s start with the simplest possible client: connecting to a server and listing its capabilities.
Step 1: Create and Initialize
use pmcp::{Client, ClientCapabilities, StdioTransport}; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create a client with stdio transport let transport = StdioTransport::new(); let mut client = Client::new(transport); // Initialize the connection let server_info = client.initialize(ClientCapabilities::default()).await?; println!("Connected to: {} v{}", server_info.server_info.name, server_info.server_info.version); Ok(()) }
This is the MCP equivalent of opening a browser and navigating to a website. The initialize call is the handshake that establishes the connection.
Step 2: Discover Capabilities
Once connected, you can discover what the server offers:
#![allow(unused)] fn main() { // Check what the server supports if server_info.capabilities.provides_tools() { println!("Server has tools available"); // List the tools let tools_result = client.list_tools(None).await?; for tool in tools_result.tools { println!(" • {}", tool.name); if let Some(desc) = tool.description { println!(" {}", desc); } } } if server_info.capabilities.provides_resources() { println!("Server has resources available"); // List the resources let resources_result = client.list_resources(None).await?; for resource in resources_result.resources { println!(" • {} ({})", resource.name, resource.uri); } } if server_info.capabilities.provides_prompts() { println!("Server has prompts available"); // List the prompts let prompts_result = client.list_prompts(None).await?; for prompt in prompts_result.prompts { println!(" • {}", prompt.name); } } }
This is equivalent to a browser rendering a website’s navigation menu—you’re exploring what’s available before taking action.
Step 3: Invoke Tools
Now you can call tools with arguments:
#![allow(unused)] fn main() { use serde_json::json; // Call a tool with arguments let result = client.call_tool( "search_products".to_string(), json!({ "query": "laptop", "limit": 10 }) ).await?; println!("Tool result: {}", serde_json::to_string_pretty(&result.content)?); }
This is like submitting a form on a website—you provide structured input and receive a structured response.
Step 4: Read Resources
Reading documentation or data:
#![allow(unused)] fn main() { // Read a resource by URI let resource_result = client.read_resource( "docs://ordering/policies/v1".to_string() ).await?; for content in resource_result.contents { if let Some(text) = content.text { println!("Policy content:\n{}", text); } } }
Step 5: Error Handling
Always handle errors gracefully:
#![allow(unused)] fn main() { match client.call_tool("create_order".to_string(), args).await { Ok(result) => { println!("Success: {:?}", result); } Err(e) => { eprintln!("Error: {}", e); // Check for specific error codes if let Some(code) = e.error_code() { match code { pmcp::ErrorCode::INVALID_PARAMS => { eprintln!("Invalid arguments provided (error -32602)"); } pmcp::ErrorCode::METHOD_NOT_FOUND => { eprintln!("Tool not found (error -32601)"); } _ => eprintln!("Error code: {}", code.as_i32()) } } } } }
Real-World Example: MCP Tester
The MCP Tester is a production-quality client built with the PMCP SDK. It demonstrates advanced client patterns and best practices.
Installation
cd examples/26-server-tester
cargo build --release
# The binary will be at target/release/mcp-tester
Basic Usage
# Quick connectivity check
mcp-tester test http://localhost:8080
# Full test suite with tool validation
mcp-tester test http://localhost:8080 --with-tools
# Protocol compliance validation
mcp-tester compliance http://localhost:8080 --strict
# List and validate tools
mcp-tester tools http://localhost:8080 --test-all
# Generate automated test scenarios
mcp-tester generate-scenario http://localhost:8080 -o tests.yaml --all-tools
# Run test scenarios
mcp-tester scenario http://localhost:8080 tests.yaml --detailed
How MCP Tester Works
The tester demonstrates key client patterns:
1. Multi-Transport Support
#![allow(unused)] fn main() { // Automatically detect transport from URL pub struct ServerTester { url: String, client: Option<Client>, timeout: Duration, } impl ServerTester { pub fn new( url: &str, timeout: Duration, insecure: bool, api_key: Option<&str>, transport: Option<&str>, ) -> pmcp::Result<Self> { // Parse URL to determine transport type if url.starts_with("http://") || url.starts_with("https://") { // Use HTTP transport } else if url.starts_with("ws://") || url.starts_with("wss://") { // Use WebSocket transport } else if url == "stdio" { // Use stdio transport } // ... } } }
2. Capability Discovery and Validation
#![allow(unused)] fn main() { pub async fn run_full_suite(&mut self, with_tools: bool) -> pmcp::Result<TestReport> { let mut report = TestReport::new(); // Initialize connection let init_result = self.test_initialize().await?; report.add_test(init_result); // Test tool discovery if self.server_supports_tools() { let tools_result = self.test_list_tools().await?; report.add_test(tools_result); if with_tools { // Test each tool individually for tool in &self.discovered_tools { let test_result = self.test_tool_validation(&tool.name).await?; report.add_test(test_result); } } } // Test resource discovery if self.server_supports_resources() { let resources_result = self.test_list_resources().await?; report.add_test(resources_result); } // Test prompt discovery if self.server_supports_prompts() { let prompts_result = self.test_list_prompts().await?; report.add_test(prompts_result); } Ok(report) } }
3. Schema Validation
The tester validates tool JSON schemas to catch common mistakes:
#![allow(unused)] fn main() { fn validate_tool_schema(tool: &ToolInfo) -> Vec<String> { let mut warnings = Vec::new(); // Check for empty schema if tool.input_schema.is_null() || tool.input_schema == json!({}) { warnings.push(format!( "Tool '{}' has empty input schema - consider defining parameters", tool.name )); } // Check for missing properties in object schema if let Some(obj) = tool.input_schema.as_object() { if obj.get("type") == Some(&json!("object")) { if !obj.contains_key("properties") { warnings.push(format!( "Tool '{}' missing 'properties' field for object type", tool.name )); } } } warnings } }
4. Scenario-Based Testing
Generate test scenarios automatically:
#![allow(unused)] fn main() { pub async fn generate_scenario( tester: &mut ServerTester, output: &str, all_tools: bool, ) -> pmcp::Result<()> { // Initialize and discover capabilities tester.initialize().await?; let tools = tester.list_tools().await?; let mut scenario = TestScenario { name: format!("{} Test Scenario", tester.server_name()), description: "Automated test scenario".to_string(), timeout: 60, steps: vec![], }; // Add tools list step scenario.steps.push(ScenarioStep { name: "List available capabilities".to_string(), operation: Operation::ListTools, assertions: vec![ Assertion::Success, Assertion::Exists { path: "tools".to_string() }, ], ..Default::default() }); // Add a test step for each tool let tools_to_test = if all_tools { tools.len() } else { 3.min(tools.len()) }; for tool in tools.iter().take(tools_to_test) { let args = generate_sample_args(&tool.input_schema)?; scenario.steps.push(ScenarioStep { name: format!("Test tool: {}", tool.name), operation: Operation::ToolCall { tool: tool.name.clone(), arguments: args, }, assertions: vec![Assertion::Success], timeout: Some(30), continue_on_failure: true, ..Default::default() }); } // Write to file let yaml = serde_yaml::to_string(&scenario)?; std::fs::write(output, yaml)?; println!("✅ Generated scenario: {}", output); Ok(()) } }
Testing with MCP Tester: A Complete Workflow
# Step 1: Start your MCP server
cargo run --example 02_server_basic &
# Step 2: Quick health check
mcp-tester quick http://localhost:8080
# Step 3: Run full test suite
mcp-tester test http://localhost:8080 --with-tools --format json > results.json
# Step 4: Generate comprehensive test scenarios
mcp-tester generate-scenario http://localhost:8080 \
-o my-server-tests.yaml \
--all-tools \
--with-resources \
--with-prompts
# Step 5: Edit generated scenarios (replace TODOs with real data)
vim my-server-tests.yaml
# Step 6: Run scenario tests
mcp-tester scenario http://localhost:8080 my-server-tests.yaml --detailed
# Step 7: Compare with another server implementation
mcp-tester compare http://localhost:8080 http://staging.example.com --with-perf
Real-World Example: WASM Client (Browser-Based)
The WASM Client demonstrates how to build MCP clients that run entirely in the browser—perfect for documentation, demos, and educational purposes.
Why a Browser Client?
- Zero Installation: Users can test your MCP server without installing anything
- Interactive Documentation: Let users explore your APIs directly from docs
- Debugging UI: Visual interface for understanding protocol exchanges
- CORS Testing: Verify your server’s browser compatibility
- Educational Tool: Show how MCP works in real-time
Building and Running
# Navigate to the WASM client directory
cd examples/wasm-client
# Build the WASM module
bash build.sh
# Serve the client
python3 -m http.server 8000
# Open in browser
open http://localhost:8000
Architecture
The WASM client uses browser APIs for transport:
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; use web_sys::{Request, RequestInit, Response}; #[wasm_bindgen] pub struct WasmClient { url: String, client: Option<Client>, } #[wasm_bindgen] impl WasmClient { #[wasm_bindgen(constructor)] pub fn new() -> Self { // Initialize logging for browser console console_error_panic_hook::set_once(); Self { url: String::new(), client: None, } } /// Connect to an MCP server (auto-detects HTTP or WebSocket) #[wasm_bindgen] pub async fn connect(&mut self, url: String) -> Result<JsValue, JsValue> { self.url = url.clone(); // Auto-detect transport based on URL scheme let transport = if url.starts_with("ws://") || url.starts_with("wss://") { // Use WebSocket transport (browser WebSocket API) WasmWebSocketTransport::new(&url)? } else { // Use HTTP transport (browser Fetch API) WasmHttpTransport::new(&url)? }; let mut client = Client::new(transport); // Initialize with default capabilities let server_info = client .initialize(ClientCapabilities::default()) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; self.client = Some(client); // Return server info as JSON Ok(serde_wasm_bindgen::to_value(&server_info)?) } /// List available tools #[wasm_bindgen] pub async fn list_tools(&mut self) -> Result<JsValue, JsValue> { let client = self.client.as_mut() .ok_or_else(|| JsValue::from_str("Not connected"))?; let tools = client .list_tools(None) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(serde_wasm_bindgen::to_value(&tools.tools)?) } /// Call a tool with arguments #[wasm_bindgen] pub async fn call_tool( &mut self, name: String, args: JsValue ) -> Result<JsValue, JsValue> { let client = self.client.as_mut() .ok_or_else(|| JsValue::from_str("Not connected"))?; // Convert JS args to serde_json::Value let args_value: serde_json::Value = serde_wasm_bindgen::from_value(args)?; let result = client .call_tool(name, args_value) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(serde_wasm_bindgen::to_value(&result)?) } } }
Browser Integration
Use the WASM client from JavaScript:
import init, { WasmClient } from './pkg/mcp_wasm_client.js';
async function testMcpServer() {
// Initialize WASM module
await init();
// Create client
const client = new WasmClient();
// Connect to server (auto-detects transport)
try {
const serverInfo = await client.connect('http://localhost:8081');
console.log('Connected:', serverInfo);
// List tools
const tools = await client.list_tools();
console.log('Available tools:', tools);
// Call a tool
const result = await client.call_tool('echo', {
message: 'Hello from browser!'
});
console.log('Result:', result);
} catch (error) {
console.error('Error:', error);
}
}
Use Cases
1. Interactive Documentation
Embed the WASM client in your API docs:
<div id="mcp-playground">
<h3>Try it live!</h3>
<button onclick="connectAndTest()">Test the API</button>
<pre id="output"></pre>
</div>
<script type="module">
import init, { WasmClient } from './mcp_wasm_client.js';
let client;
window.connectAndTest = async function() {
await init();
client = new WasmClient();
const output = document.getElementById('output');
output.textContent = 'Connecting...';
try {
await client.connect('https://api.example.com/mcp');
const tools = await client.list_tools();
output.textContent = JSON.stringify(tools, null, 2);
} catch (e) {
output.textContent = 'Error: ' + e;
}
};
</script>
2. Debugging UI
Build a visual protocol inspector:
async function debugProtocol() {
await init();
const client = new WasmClient();
// Log all protocol exchanges
client.on('send', (msg) => {
console.log('→ Client sent:', msg);
appendToLog('out', msg);
});
client.on('receive', (msg) => {
console.log('← Server sent:', msg);
appendToLog('in', msg);
});
await client.connect('ws://localhost:8080');
}
3. Server Comparison Tool
Compare multiple MCP servers side-by-side:
async function compareServers() {
await init();
const servers = [
'http://localhost:8080',
'http://staging.example.com',
'https://prod.example.com'
];
const results = await Promise.all(
servers.map(async (url) => {
const client = new WasmClient();
await client.connect(url);
const tools = await client.list_tools();
return { url, tools };
})
);
displayComparison(results);
}
Client Best Practices
1. Connection Management
Always initialize before use:
#![allow(unused)] fn main() { let mut client = Client::new(transport); let server_info = client.initialize(capabilities).await?; // Now the client is ready for requests }
Handle connection failures gracefully:
#![allow(unused)] fn main() { use pmcp::Error; match client.initialize(capabilities).await { Ok(info) => { // Store server capabilities for later use self.server_capabilities = info.capabilities; } Err(Error::Transport(e)) => { eprintln!("Transport error: {}", e); // Retry with exponential backoff } Err(Error::Timeout(_)) => { eprintln!("Server took too long to respond"); // Increase timeout or fail } Err(e) => { eprintln!("Initialization failed: {}", e); return Err(e); } } }
2. Capability Negotiation
Important: Client capabilities indicate what the CLIENT can do (handle sampling requests, provide user input). Server capabilities (tools, prompts, resources) are advertised by SERVERS, not clients.
Declare only what you support:
#![allow(unused)] fn main() { let capabilities = ClientCapabilities { // Advertise if you can handle LLM sampling requests from the server sampling: Some(pmcp::types::SamplingCapabilities::default()), // Advertise if you can provide user input when requested elicitation: Some(pmcp::types::ElicitationCapabilities::default()), // Advertise if you support roots/workspace notifications roots: Some(pmcp::types::RootsCapabilities::default()), ..Default::default() }; // Or use minimal() for most clients (no special features) let capabilities = ClientCapabilities::minimal(); }
Check SERVER capabilities before use:
#![allow(unused)] fn main() { // Check if the SERVER provides tools (not the client!) if !server_info.capabilities.provides_tools() { eprintln!("Server doesn't provide tools"); return Err("Missing required server capability".into()); } // Check if the SERVER provides resources if !server_info.capabilities.provides_resources() { eprintln!("Server doesn't provide resources"); return Err("Server must provide resources".into()); } }
3. Error Handling
Distinguish between error types:
#![allow(unused)] fn main() { use pmcp::{Error, ErrorCode}; match client.call_tool(name, args).await { Ok(result) => Ok(result), Err(Error::Protocol { code, message, .. }) => { match code { ErrorCode::INVALID_PARAMS => { // User error - fix arguments (JSON-RPC -32602) Err(format!("Invalid args: {}", message)) } ErrorCode::METHOD_NOT_FOUND => { // Tool doesn't exist - check name (JSON-RPC -32601) Err(format!("Tool not found: {}", name)) } ErrorCode::INTERNAL_ERROR => { // Server error - retry or escalate (JSON-RPC -32603) Err(format!("Server error: {}", message)) } _ => Err(format!("Protocol error {} ({}): {}", code, code.as_i32(), message)) } } Err(Error::Timeout(ms)) => { // Network/performance issue Err(format!("Request timed out after {}ms", ms)) } Err(Error::Transport(e)) => { // Connection issue Err(format!("Connection lost: {}", e)) } Err(e) => Err(format!("Unexpected error: {}", e)) } }
4. Request Validation
Validate inputs before sending:
#![allow(unused)] fn main() { fn validate_tool_args( tool: &ToolInfo, args: &serde_json::Value, ) -> pmcp::Result<()> { // Check args match schema let schema = &tool.input_schema; if let Some(required) = schema.get("required").and_then(|v| v.as_array()) { for field in required { if let Some(field_name) = field.as_str() { if !args.get(field_name).is_some() { return Err(pmcp::Error::validation( format!("Missing required field: {}", field_name) )); } } } } Ok(()) } }
5. Logging and Debugging
Enable protocol-level logging during development:
#![allow(unused)] fn main() { // Initialize tracing for detailed protocol logs tracing_subscriber::fmt() .with_env_filter("pmcp=debug") .init(); // Now all client requests/responses are logged let result = client.call_tool(name, args).await?; }
Log important events:
#![allow(unused)] fn main() { tracing::info!("Connecting to server: {}", url); tracing::debug!("Sending tool call: {} with args: {:?}", name, args); tracing::warn!("Server returned warning: {}", warning); tracing::error!("Failed to call tool: {}", error); }
6. Performance Considerations
Use connection pooling for multiple servers:
#![allow(unused)] fn main() { use std::collections::HashMap; struct ClientPool { clients: HashMap<String, Client>, } impl ClientPool { async fn get_or_create(&mut self, url: &str) -> pmcp::Result<&mut Client> { if !self.clients.contains_key(url) { let transport = create_transport(url)?; let mut client = Client::new(transport); client.initialize(ClientCapabilities::default()).await?; self.clients.insert(url.to_string(), client); } Ok(self.clients.get_mut(url).unwrap()) } } }
Batch requests when possible:
#![allow(unused)] fn main() { // Instead of multiple sequential calls: // Bad: 3 round trips let tool1 = client.call_tool("tool1", args1).await?; let tool2 = client.call_tool("tool2", args2).await?; let tool3 = client.call_tool("tool3", args3).await?; // Better: Parallel execution if tools are independent let (tool1, tool2, tool3) = tokio::join!( client.call_tool("tool1", args1), client.call_tool("tool2", args2), client.call_tool("tool3", args3), ); }
7. Security
Validate server certificates in production:
#![allow(unused)] fn main() { // Development: might skip cert validation let transport = HttpTransport::new(url) .insecure(true); // ONLY for development! // Production: always validate certificates let transport = HttpTransport::new(url); // Validates by default }
Use API keys securely:
#![allow(unused)] fn main() { // Don't hardcode keys // Bad: let api_key = "sk-1234567890abcdef"; // Good: Use environment variables let api_key = std::env::var("MCP_API_KEY") .expect("MCP_API_KEY must be set"); let transport = HttpTransport::new(url) .with_api_key(&api_key); }
Transport Types
stdio Transport
Best for: Local servers, subprocess communication, testing
#![allow(unused)] fn main() { use pmcp::StdioTransport; let transport = StdioTransport::new(); let mut client = Client::new(transport); }
Use cases:
- IDE plugins (VS Code, Cursor)
- CLI tools
- Local testing
- Process-to-process communication
HTTP Transport
Best for: Remote servers, cloud deployments, stateless clients
#![allow(unused)] fn main() { use pmcp::HttpTransport; let transport = HttpTransport::new("http://localhost:8080")?; let mut client = Client::new(transport); }
Use cases:
- Serverless functions (AWS Lambda, Vercel)
- Microservices architectures
- Cloud deployments
- Load-balanced servers
WebSocket Transport
Best for: Real-time communication, bidirectional updates, long-lived connections
#![allow(unused)] fn main() { use pmcp::WebSocketTransport; let transport = WebSocketTransport::connect("ws://localhost:8080").await?; let mut client = Client::new(transport); }
Use cases:
- Real-time dashboards
- Collaborative tools
- Streaming responses
- Progress notifications
Testing Your Own Clients
Use the MCP Tester to validate your client implementation:
# Start a known-good reference server
cargo run --example 02_server_basic &
# Test your client against it
# Your client should handle all these scenarios
# 1. Basic connectivity
mcp-tester quick http://localhost:8080
# 2. Protocol compliance
mcp-tester compliance http://localhost:8080 --strict
# 3. All capabilities
mcp-tester test http://localhost:8080 --with-tools
# 4. Error handling
mcp-tester test http://localhost:8080 --tool nonexistent_tool
# 5. Performance
mcp-tester test http://localhost:8080 --timeout 5
Debugging Checklist
When your client isn’t working:
Connection Issues
# 1. Verify the server is running
curl -X POST http://localhost:8080 -d '{"jsonrpc":"2.0","method":"initialize"}'
# 2. Check network connectivity
mcp-tester diagnose http://localhost:8080 --network
# 3. Verify transport compatibility
mcp-tester test http://localhost:8080 --transport http
Protocol Issues
#![allow(unused)] fn main() { // Enable debug logging tracing_subscriber::fmt() .with_env_filter("pmcp=trace") .init(); // Check for protocol violations // - Is Content-Type correct? (application/json) // - Are JSON-RPC fields present? (jsonrpc, method, id) // - Is the MCP protocol version supported? }
Tool Call Failures
# Validate tool exists
mcp-tester tools http://localhost:8080
# Check schema requirements
mcp-tester tools http://localhost:8080 --verbose
# Test with known-good arguments
mcp-tester test http://localhost:8080 --tool tool_name --args '{}'
Where To Go Next
- Tools & Tool Handlers (Chapter 5): Deep dive into tools
- Resources & Resource Management (Chapter 6): Working with resources
- Prompts & Templates (Chapter 7): Using prompts effectively
- Error Handling & Recovery (Chapter 8): Robust error handling
- Transport Layers (Chapter 10): Advanced transport configurations
- Testing & Quality Assurance (Chapter 15): Comprehensive testing strategies
Summary
MCP clients are your gateway to the MCP ecosystem:
- Like browsers, they connect users to servers
- Like testing tools, they validate server implementations
- Like curl, they enable debugging and exploration
Key takeaways:
- Initialize first: Always call
client.initialize()before use - Check capabilities: Verify the server supports what you need
- Handle errors: Distinguish between protocol, transport, and application errors
- Use the right transport: stdio for local, HTTP for cloud, WebSocket for real-time
- Test thoroughly: Use MCP Tester to validate your implementations
- Debug visually: Use WASM Client for interactive exploration
The PMCP SDK makes building clients straightforward—whether you’re building production AI applications or testing tools. Start simple with the examples, then expand to your use case.
Chapter 4: Protocol Basics — Think Like A Website
This chapter builds intuition for MCP by mapping it to something every product team already understands: a website. Where a website is designed for humans, an MCP server is the “website for AI agents.” The same product thinking applies — navigation, forms, docs, and clear instructions — but expressed as protocol surfaces that agents can reliably use at machine speed.
The goal: after this chapter, you’ll know why every business that ships a website or app will likely ship an MCP server too, and how to design one that agents can understand and operate safely.
A Familiar Analogy
| Website element | MCP primitive | Purpose for agents |
|---|---|---|
| Home page instructions | Prompts | Set expectations, goals, and usage patterns for the agent |
| Navigation tabs | Capability discovery | Show what exists: tools, prompts, resources |
| Forms with fields | Tools (actions) | Perform operations with validated, typed arguments |
| Form labels/help text | Tool schema + descriptions | Guide correct input and communicate constraints |
| Docs/FAQ/Policies | Resources | Provide reference material the agent can read and cite |
| Notifications/toasts | Progress and events | Communicate long-running work, partial results, and completion |
| Error pages | Structured errors | Tell the agent what failed and how to fix it |
If you’ve ever improved conversions by clarifying a form label or reorganizing navigation, you already understand “XU” (the agent eXperience). Great MCP servers are great at XU.
Visual Model
%%{init: { 'theme': 'neutral' }}%%
flowchart LR
subgraph Website["Website (Human UI)"]
home["Home page instructions"]
nav["Navigation tabs"]
forms["Forms + fields"]
help["Docs/FAQ/Policies"]
notices["Notifications"]
errors["Error pages"]
end
subgraph MCP["MCP (Agent API)"]
prompts["Prompts"]
discovery["Discovery: tools/list, prompts/list, resources/list"]
tools["Tools: tools/call"]
schemas["Schemas + descriptions"]
resources["Resources: resources/read"]
progress["Progress + cancellation"]
serr["Structured errors"]
end
home --> prompts
nav --> discovery
forms --> tools
forms --> schemas
help --> resources
notices --> progress
errors --> serr
ASCII fallback (shown if Mermaid doesn’t render):
[Website (Human UI)] [MCP (Agent API)]
Home page instructions --> Prompts
Navigation tabs --> Discovery (tools/list, prompts/list, resources/list)
Forms + fields --> Tools (tools/call)
Form labels/help text --> Schemas + descriptions
Docs/FAQ/Policies --> Resources (resources/read)
Notifications/toasts --> Progress + cancellation
Error pages --> Structured errors
Why Build An MCP Server
- Speed: Agents operate your product without brittle scraping or slow human UI paths.
- Reliability: Strong typing and schemas reduce ambiguity and retries.
- Governance: You decide what’s allowed, audited, and measured.
- Reuse: The same server powers many agent clients, models, and runtimes.
Core Surfaces Of The Protocol
At a high level, an MCP client connects to a server over a transport (stdio, WebSocket, or HTTP) and uses JSON-RPC methods to discover capabilities and execute actions.
-
Discovery: list what exists
tools/list— enumerate available tools (your “forms”).prompts/listandprompts/get— list and fetch prompt templates (your “home page instructions”).resources/list— enumerate reference documents and data sets (your “docs”).
-
Action: do work
tools/call— submit a tool with arguments (like submitting a form).
-
Reading: consult context
resources/read— read a resource by id; may return text, JSON, or other content types.
-
Feedback: keep the loop tight
- Progress and cancellation — communicate long tasks and allow interruption (see Chapter 12).
- Structured errors — explain what failed with machine-actionable details.
You’ll go deeper on each surface in Chapters 5–8. Here we focus on the mental model and design guidance that makes servers easy for agents to use.
Resources: Docs For Agent Consumption
Resources are your “documentation pages” for agents. Include clear metadata that helps clients understand what’s available and how to access it.
- Stable URIs: Treat resource URIs like permalinks; keep them stable across versions where possible.
- Descriptive names: Use clear, human-readable names that indicate the resource’s purpose.
- MIME types: Specify the content type (text/markdown, application/json, etc.) to help clients parse correctly.
- Priority (0.0–1.0): Signal importance to clients. 0.9–1.0 = must-read (policies, SLAs), 0.5 = normal docs, 0.1–0.3 = low-signal/archived content.
- Modified At (ISO 8601): Timestamp of last update. Clients can sort by recency and show “Updated on…” in UI.
- Small, composable docs: Prefer focused resources (50-500 lines) with clear descriptions over giant walls of text.
Example discovery and reading:
{ "method": "resources/list", "params": {} }
Example response item with recommended metadata:
{
"uri": "docs://ordering/policies/v1",
"name": "Ordering Policies",
"description": "[PRIORITY: HIGH] Company ordering policies and procedures. Updated on 2025-01-15.",
"mimeType": "text/markdown",
"annotations": {
"priority": 0.9,
"modifiedAt": "2025-01-15T10:30:00Z"
}
}
Note: The core ResourceInfo type includes uri, name, description, and mimeType. Priority and timestamp can be embedded in the description or exposed via an optional annotations extension map (see Chapter 6 for implementation patterns).
Then read the resource:
{
"method": "resources/read",
"params": { "uri": "docs://ordering/policies/v1" }
}
Design tip: Use clear, specific names and descriptions. Place critical safety/governance docs at stable, well-known URIs that agents can reference. Use MIME types to help clients parse and display content correctly. Signal importance with priority (0.9+ for must-read policies) and keep modified_at current so agents know they’re consulting fresh information. Clients should sort by priority DESC, then modified_at DESC to surface the most important and recent resources first.
Prompts: User‑Controlled Workflows
Prompts are structured instructions exposed by the server and discovered by clients. They act like “guided workflows” that users can explicitly select in the UI.
- User controlled: Prompts are intended for user initiation from the client UI, not silent auto‑execution.
- Discoverable: Clients call
prompts/listto surface available workflows; each prompt advertises arguments and a description. - Templated: Clients call
prompts/getwith arguments to expand into concrete model messages. - Workflow fit: Design prompts to match common user journeys (e.g., “Refund Order”, “Create Support Ticket”, “Compose Quote”).
Discover prompts:
{ "method": "prompts/list", "params": {} }
Example prompt metadata (shape for illustration):
{
"name": "refund_order",
"description": "Guide the agent to safely process a refund.",
"arguments": [
{ "name": "order_id", "type": "string", "required": true },
{ "name": "reason", "type": "string", "required": false }
]
}
Get a concrete prompt with arguments:
{
"method": "prompts/get",
"params": {
"name": "refund_order",
"arguments": { "order_id": "ord_123", "reason": "damaged" }
}
}
XU tip: Treat prompts like your website’s primary CTAs — few, clear, and high‑signal. Link prompts to relevant resources (policies, SLAs) by stable URI so the agent can cite and comply.
Designing For XU (Agent eXperience)
Treat agents like highly efficient, literal users. Design with the same rigor you would for a public-facing product.
- Clear names: Prefer verbs and domain terms (
create_order,refund_payment). - Tight schemas: Mark required vs optional, use enums, bounds, patterns, and example values.
- Helpful descriptions: Document constraints (currency, time zone, rate limits) where the agent needs them.
- Idempotency: Make retries safe; include idempotency keys where appropriate.
- Determinism first: Avoid side effects that depend on hidden state whenever possible.
- Predictable errors: Use specific error codes/messages and suggest next actions.
- Resource-first docs: Publish policies, SLAs, product catalogs, and changelogs as
resources/*. - Versioning: Introduce new tools instead of silently changing semantics; deprecate old ones gently.
End-to-End Flow (Website Lens)
-
Land on “home” → The agent discovers
promptsand reads guidance on how to use the server. -
Browse navigation → The agent calls
tools/list,resources/list, and optionallyprompts/listto map the surface area. -
Open a form → The agent selects a tool, reads its schema and descriptions, and prepares arguments.
-
Submit the form → The agent calls
tools/callwith structured arguments. -
Watch progress → The server emits progress updates and finally returns a result or error.
-
Read the docs → The agent calls
resources/readto consult policies, FAQs, or domain data. -
Recover gracefully → On error, the agent adjusts inputs based on structured feedback and retries.
Sequence Walkthrough
%%{init: { 'theme': 'neutral' }}%%
sequenceDiagram
autonumber
participant A as Agent Client
participant S as MCP Server
A->>S: prompts/list
S-->>A: available prompts
A->>S: tools/list, resources/list
S-->>A: tool schemas, resource URIs
A->>S: tools/call create_order(args)
rect rgb(245,245,245)
S-->>A: progress events (optional)
end
S-->>A: result or structured error
A->>S: resources/read docs://ordering/policies/v1
S-->>A: policy text/JSON
ASCII fallback:
Agent Client -> MCP Server: prompts/list
MCP Server -> Agent Client: available prompts
Agent Client -> MCP Server: tools/list, resources/list
MCP Server -> Agent Client: tool schemas, resource URIs
Agent Client -> MCP Server: tools/call create_order(args)
MCP Server -> Agent Client: progress: "validating", 20%
MCP Server -> Agent Client: progress: "placing order", 80%
MCP Server -> Agent Client: result or structured error
Agent Client -> MCP Server: resources/read docs://ordering/policies/v1
MCP Server -> Agent Client: policy text/JSON
Minimal JSON Examples
List tools (like “navigation tabs”):
{ "method": "tools/list", "params": {} }
Call a tool (like submitting a form):
{
"method": "tools/call",
"params": {
"name": "create_order",
"arguments": {
"customer_id": "cus_123",
"items": [
{ "sku": "SKU-42", "qty": 2 },
{ "sku": "SKU-99", "qty": 1 }
],
"currency": "USD"
}
}
}
Read a resource (like opening docs):
{
"method": "resources/read",
"params": { "uri": "docs://ordering/policies/v1" }
}
Bootstrap With PMCP (Rust)
Here’s a minimal website-like MCP server using PMCP. We’ll add full coverage of tools, resources, and prompts in later chapters; this snippet focuses on the “forms” (tools) first.
use pmcp::{Server, ToolHandler, RequestHandlerExtra, Result}; use serde_json::{json, Value}; use async_trait::async_trait; // Form 1: Search products struct SearchProducts; #[async_trait] impl ToolHandler for SearchProducts { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10); // In a real server, query your catalog here. // The framework wraps this in CallToolResult automatically. Ok(json!({ "query": query, "limit": limit, "results": ["Product A", "Product B", "Product C"] })) } } // Form 2: Create order struct CreateOrder; #[async_trait] impl ToolHandler for CreateOrder { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { // Validate required fields let customer_id = args.get("customer_id") .and_then(|v| v.as_str()) .ok_or_else(|| pmcp::Error::validation("Missing 'customer_id'"))?; // Normally you would validate items/currency and write to storage. Ok(json!({ "order_id": "ord_12345", "customer_id": customer_id, "status": "created" })) } } #[tokio::main] async fn main() -> Result<()> { let server = Server::builder() .name("website-like-server") .version("0.1.0") .tool("search_products", SearchProducts) .tool("create_order", CreateOrder) .build()?; server.run_stdio().await }
As you evolve this server, bake in XU:
- Add precise JSON schemas and descriptions using
SimpleToolor the builder pattern (Chapter 5). - Publish a small set of reference docs as resources with stable URIs (Chapter 6).
- Provide starter prompts that teach the agent ideal workflows (Chapter 7).
- Return actionable errors and support progress/cancellation for long tasks (Chapters 8 and 12).
Note: The tool handlers return Result<Value> where Value contains your tool’s output data. The PMCP framework automatically wraps this in the proper CallToolResult structure for the MCP protocol.
What Not To Build
A common mistake is “API wrapper ≠ MCP server.” Simply auto‑generating tools from a REST/OpenAPI or RPC surface produces developer‑centric verbs, leaky abstractions, and hundreds of low‑level endpoints that agents cannot reliably compose. It’s the equivalent of making your public website a list of internal API forms — technically complete, practically unusable.
Design anti‑patterns to avoid:
- 1:1 endpoint mapping: Don’t expose every REST method as a tool. Prefer task‑level verbs (e.g.,
refund_order) over transport artifacts (POST /orders/:id/refunds). - Low‑level leakage: Hide internal ids, flags, and sequencing rules behind clear, validated arguments and schemas.
- Hidden preconditions: Make prerequisites explicit in the tool schema or encode pre‑flight checks; don’t require agents to guess call order.
- Unbounded surface area: Curate a small, high‑signal set of tools that align to goals, not to tables or microservice granularity.
- Side‑effects without guardrails: Provide prompts, examples, and resource links that set expectations and constraints for risky actions.
- “Just upload OpenAPI”: Generation can help as a starting inventory, but always refactor to business goals and XU before shipping.
Aim for user‑goal orientation: design tools and prompts the way you design your website’s navigation and primary actions — to help intelligent users (agents) complete outcomes, not to mirror internal APIs.
Checklist: From Website To MCP
- Map key user journeys → tools with clear names and schemas.
- Extract onboarding docs/FAQs → resources with stable IDs.
- Translate “how to use our product” → prompts that set expectations and rules.
- Define safe defaults, rate limits, and idempotency.
- Log and measure usage to improve XU.
Where To Go Next
- Tools & Tool Handlers (Chapter 5)
- Resources & Resource Management (Chapter 6)
- Prompts & Templates (Chapter 7)
- Error Handling & Recovery (Chapter 8)
- Progress Tracking & Cancellation (Chapter 12)
Chapter 5: Tools — Type-Safe Actions for Agents
This chapter covers MCP tools—the actions that agents can invoke to accomplish tasks. Using Rust’s type system, PMCP provides compile-time safety and clear schemas that help LLMs succeed.
The goal: build type-safe, validated, LLM-friendly tools from simple to production-ready.
Quick Start: Your First Tool (15 lines)
Let’s create a simple “echo” tool and see it in action:
use pmcp::{Server, server::SyncTool}; use serde_json::json; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create an echo tool let echo = SyncTool::new("echo", |args| { let msg = args.get("message").and_then(|v| v.as_str()) .ok_or_else(|| pmcp::Error::validation("'message' required"))?; Ok(json!({"echo": msg, "length": msg.len()})) }) .with_description("Echoes back your message"); // Add to server and run Server::builder().tool("echo", echo).build()?.run_stdio().await }
Test it:
# Start server
cargo run
# In another terminal, use MCP tester from Chapter 3:
mcp-tester test stdio --tool echo --args '{"message": "Hello!"}'
# Response: {"echo": "Hello!", "length": 6}
That’s it! You’ve created, registered, and tested an MCP tool. Now let’s understand how it works and make it production-ready.
The Tool Analogy: Forms with Type Safety
Continuing the website analogy from Chapter 4, tools are like web forms—but with Rust’s compile-time guarantees.
| Web Forms | MCP Tools (PMCP) | Security Benefit |
|---|---|---|
| HTML form with input fields | Rust struct with typed fields | Compile-time type checking |
| JavaScript validation | serde validation + custom checks | Zero-cost abstractions |
| Server-side sanitization | Rust’s ownership & borrowing | Memory safety guaranteed |
| Form submission | Tool invocation via JSON-RPC | Type-safe parsing |
| Success/error response | Typed Result | Exhaustive error handling |
Key insight: While other SDKs use dynamic typing (JavaScript objects, Python dicts), PMCP uses Rust structs. This means:
- Compile-time safety: Type errors caught before deployment
- Zero validation overhead: Types validated during parsing
- Memory safety: No buffer overflows or injection attacks
- Clear schemas: LLMs understand exactly what’s required
Why Type Safety Matters for LLMs
LLMs driving MCP clients need clear, unambiguous tool definitions to succeed. Here’s why typed tools help:
- Schema Generation: Rust types automatically generate accurate JSON schemas
- Validation: Invalid inputs rejected before handler execution
- Error Messages: Type mismatches produce actionable errors LLMs can fix
- Examples: Type definitions document expected inputs
- Success Rate: Well-typed tools have 40-60% higher success rates
Example: Compare these two approaches:
#![allow(unused)] fn main() { // ❌ Dynamic (error-prone for LLMs) async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let a = args["a"].as_f64().ok_or("missing a")?; // Vague error let b = args["b"].as_f64().ok_or("missing b")?; // LLM must guess types // ... } // ✅ Typed (LLM-friendly) #[derive(Deserialize)] struct CalculatorArgs { /// First number to calculate (e.g., 42.5, -10, 3.14) a: f64, /// Second number to calculate (e.g., 2.0, -5.5, 1.0) b: f64, /// Operation: "add", "subtract", "multiply", "divide" operation: Operation, } async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let params: CalculatorArgs = serde_json::from_value(args)?; // Types validated, LLM gets clear errors if wrong // Perform calculation let result = CalculatorResult { /* ... */ }; // Return structured data - PMCP automatically wraps this in CallToolResult Ok(serde_json::to_value(result)?) } }
The typed version generates this schema automatically:
{
"type": "object",
"properties": {
"a": { "type": "number", "description": "First number (e.g., 42.5)" },
"b": { "type": "number", "description": "Second number (e.g., 2.0)" },
"operation": {
"type": "string",
"enum": ["add", "subtract", "multiply", "divide"],
"description": "Mathematical operation to perform"
}
},
"required": ["a", "b", "operation"]
}
LLMs read this schema and understand:
- Exact types needed (numbers, not strings)
- Valid operations (only 4 choices)
- Required vs optional fields
- Example values to guide generation
Tool Anatomy: Calculator (Step-by-Step)
Every tool follows this anatomy:
- Name + Description → What the tool does
- Input Types → Typed struct with validation
- Output Types → Structured response
- Validation → Check inputs thoroughly
- Error Handling → Clear, actionable messages
- Add to Server → Register and test
Let’s build a calculator following this pattern.
Step 1: Name + Description
#![allow(unused)] fn main() { /// Tool name: "calculator" /// Description: "Performs basic math operations on two numbers. /// Supports: add, subtract, multiply, divide. /// Examples: /// - {a: 10, b: 5, operation: 'add'} → 15 /// - {a: 20, b: 4, operation: 'divide'} → 5" }
Step 2: Input Types (Typed) with Examples
Define inputs as Rust structs with doc comments AND example functions for schemas:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// Mathematical operation to perform #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] enum Operation { /// Addition (e.g., 5 + 3 = 8) Add, /// Subtraction (e.g., 10 - 4 = 6) Subtract, /// Multiplication (e.g., 6 * 7 = 42) Multiply, /// Division (e.g., 20 / 4 = 5). Returns error if divisor is zero. Divide, } /// Arguments for the calculator tool #[derive(Debug, Deserialize)] struct CalculatorArgs { /// First operand (e.g., 42.5, -10.3, 0, 3.14159) a: f64, /// Second operand (e.g., 2.0, -5.5, 10, 1.414) b: f64, /// Operation to perform on the two numbers operation: Operation, } /// Example arguments for testing (shows LLMs valid inputs) fn example_calculator_args() -> serde_json::Value { serde_json::json!({ "a": 10.0, "b": 5.0, "operation": "add" }) } }
LLM-Friendly Schema Patterns:
-
Doc comments on every field → LLMs read these as descriptions
-
Example values in comments → Guides LLM input generation
#![allow(unused)] fn main() { /// First operand (e.g., 42.5, -10.3, 0, 3.14159) // ^^^^^^^^ LLM learns valid formats } -
Example function → Can be embedded in schema or shown in docs
#![allow(unused)] fn main() { fn example_args() -> serde_json::Value { json!({"a": 10.0, "b": 5.0, "operation": "add"}) } }This provides a “Try it” button in clients that support examples.
-
Enums for fixed choices → Constrains LLM to valid options
#![allow(unused)] fn main() { #[serde(rename_all = "lowercase")] enum Operation { Add, Subtract, Multiply, Divide } // LLM sees: must be exactly "add", "subtract", "multiply", or "divide" } -
Clear field names →
aandbare concise but well-documented
Step 3: Output Types (Typed)
Define a structured response type:
#![allow(unused)] fn main() { /// Result of a calculator operation #[derive(Debug, Serialize)] struct CalculatorResult { /// The calculated result (e.g., 42.0, -3.5, 0.0) result: f64, /// Human-readable expression showing the calculation /// (e.g., "5 + 3 = 8", "10 / 2 = 5") expression: String, /// The operation that was performed operation: Operation, } }
Why structured output?:
- LLMs can extract specific fields (
resultvs parsing strings) - Easier to chain tools (next tool uses
resultfield directly) - Type-safe: consumers know exact structure at compile time
- PMCP automatically wraps this in
CallToolResultfor the client
What PMCP does:
#![allow(unused)] fn main() { // Your handler returns: Ok(serde_json::to_value(CalculatorResult { result: 15.0, ... })?) // PMCP automatically wraps it for the client as: // { // "content": [ // { // "type": "text", // "text": "{\"result\":15.0,\"expression\":\"10 + 5 = 15\",\"operation\":\"add\"}" // } // ], // "isError": false // } }
Your code stays simple—just return your data structure. PMCP handles protocol details.
Step 4: Validation
Validate inputs before processing:
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::{ToolHandler, RequestHandlerExtra, Result, Error}; use serde_json::Value; struct CalculatorTool; #[async_trait] impl ToolHandler for CalculatorTool { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { // Step 1: Parse and validate types let params: CalculatorArgs = serde_json::from_value(args) .map_err(|e| Error::validation(format!( "Invalid calculator arguments: {}. Expected: {{a: number, b: number, operation: string}}", e )))?; // Step 2: Perform operation with domain validation let result = match params.operation { Operation::Add => params.first + params.second, Operation::Subtract => params.first - params.second, Operation::Multiply => params.first * params.second, Operation::Divide => { // Validation: check for division by zero if params.second == 0.0 { return Err(Error::validation( "Cannot divide by zero. Please provide a non-zero divisor for 'b'." )); } // Check for potential overflow if params.first.is_infinite() || params.second.is_infinite() { return Err(Error::validation( "Cannot perform division with infinite values" )); } params.first / params.second } }; // Step 3: Validate result if !result.is_finite() { return Err(Error::validation(format!( "Calculation resulted in non-finite value: {:?}. \ This can happen with overflow or invalid operations.", result ))); } // Step 4: Build structured response let response = CalculatorResult { result, expression: format!( "{} {} {} = {}", params.first, match params.operation { Operation::Add => "+", Operation::Subtract => "-", Operation::Multiply => "*", Operation::Divide => "/", }, params.second, result ), operation: params.operation, }; // Return structured data - PMCP wraps it in CallToolResult automatically Ok(serde_json::to_value(response)?) } } }
Validation layers:
- Type validation (automatic via serde)
- Domain validation (division by zero, infinity)
- Result validation (ensure finite output)
Step 5: Error Handling
Provide clear, actionable error messages (see “Error Messages” section below for patterns).
Step 6: Add to Server with Schema
use pmcp::Server; use serde_json::json; #[tokio::main] async fn main() -> pmcp::Result<()> { let server = Server::builder() .name("calculator-server") .version("1.0.0") .tool("calculator", CalculatorTool) .build()?; // PMCP automatically generates this schema from your types: // { // "name": "calculator", // "description": "Performs basic mathematical operations on two numbers.\n\ // Supports: add, subtract, multiply, divide.\n\ // Examples:\n\ // - {a: 10, b: 5, operation: 'add'} → 15\n\ // - {a: 20, b: 4, operation: 'divide'} → 5", // "inputSchema": { // "type": "object", // "properties": { // "a": { // "type": "number", // "description": "First operand (e.g., 42.5, -10.3, 0, 3.14159)" // }, // "b": { // "type": "number", // "description": "Second operand (e.g., 2.0, -5.5, 10, 1.414)" // }, // "operation": { // "type": "string", // "enum": ["add", "subtract", "multiply", "divide"], // "description": "Operation to perform on the two numbers" // } // }, // "required": ["a", "b", "operation"] // } // } // // Smart clients can show "Try it" with example: {"a": 10, "b": 5, "operation": "add"} // Test with: mcp-tester test stdio --tool calculator --args '{"a":10,"b":5,"operation":"add"}' server.run_stdio().await }
How PMCP Wraps Your Responses
Important: Your tool handlers return plain data structures. PMCP automatically wraps them in the MCP protocol format.
#![allow(unused)] fn main() { // You write: #[derive(Serialize)] struct MyResult { value: i32 } Ok(serde_json::to_value(MyResult { value: 42 })?) // Client receives (PMCP adds this wrapper automatically): { "content": [{ "type": "text", "text": "{\"value\":42}" }], "isError": false } }
This keeps your code clean and protocol-agnostic. You focus on business logic; PMCP handles MCP details.
SimpleTool and SyncTool: Rapid Development
For simpler tools, use SyncTool (synchronous) or SimpleTool (async) to avoid boilerplate:
#![allow(unused)] fn main() { use pmcp::server::SyncTool; use serde_json::json; // SyncTool for synchronous logic (most common) let echo_tool = SyncTool::new("echo", |args| { let message = args.get("message") .and_then(|v| v.as_str()) .ok_or_else(|| pmcp::Error::validation( "Missing 'message' field. Expected: {message: string}" ))?; // Return structured data - PMCP wraps it automatically Ok(json!({ "echo": message, "length": message.len(), "timestamp": chrono::Utc::now().to_rfc3339() })) }) .with_description( "Echoes back the provided message with metadata. \ Use this to test message passing and get character count." ) .with_schema(json!({ "type": "object", "properties": { "message": { "type": "string", "description": "Message to echo back (e.g., 'Hello, World!', 'Test message')", "minLength": 1, "maxLength": 10000 } }, "required": ["message"] })); // Add to server let server = Server::builder() .tool("echo", echo_tool) .build()?; }
When to use SimpleTool:
- ✅ Quick prototyping
- ✅ Single-file examples
- ✅ Tools with simple logic (<50 lines)
- ✅ When you don’t need custom types
When to use struct-based ToolHandler:
- ✅ Complex validation logic
- ✅ Reusable types across multiple tools
- ✅ Need compile-time type checking
- ✅ Tools with dependencies (DB, API clients)
Advanced Validation Patterns
Pattern 1: Multi-Field Validation
Validate relationships between fields:
#![allow(unused)] fn main() { #[derive(Debug, Deserialize)] struct DateRangeArgs { /// Start date in ISO 8601 format (e.g., "2024-01-01") start_date: String, /// End date in ISO 8601 format (e.g., "2024-12-31") end_date: String, /// Maximum number of days in range (optional, default: 365) #[serde(default = "default_max_days")] max_days: u32, } fn default_max_days() -> u32 { 365 } impl DateRangeArgs { /// Validate that end_date is after start_date and within max_days fn validate(&self) -> pmcp::Result<()> { use chrono::NaiveDate; let start = NaiveDate::parse_from_str(&self.start_date, "%Y-%m-%d") .map_err(|e| pmcp::Error::validation(format!( "Invalid start_date format: {}. Use YYYY-MM-DD (e.g., '2024-01-15')", e )))?; let end = NaiveDate::parse_from_str(&self.end_date, "%Y-%m-%d") .map_err(|e| pmcp::Error::validation(format!( "Invalid end_date format: {}. Use YYYY-MM-DD (e.g., '2024-12-31')", e )))?; if end < start { return Err(pmcp::Error::validation( "end_date must be after start_date. \ Example: start_date='2024-01-01', end_date='2024-12-31'" )); } let days = (end - start).num_days(); if days > self.max_days as i64 { return Err(pmcp::Error::validation(format!( "Date range exceeds maximum of {} days (actual: {} days). \ Reduce the range or increase max_days parameter.", self.max_days, days ))); } Ok(()) } } // Usage in tool handler async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let params: DateRangeArgs = serde_json::from_value(args)?; params.validate()?; // Multi-field validation // Proceed with validated data // ... } }
Pattern 2: Custom Deserialization with Validation
#![allow(unused)] fn main() { use serde::de::{self, Deserialize, Deserializer}; /// Email address with compile-time validation #[derive(Debug, Clone)] struct Email(String); impl<'de> Deserialize<'de> for Email { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; // Validate email format if !s.contains('@') || !s.contains('.') { return Err(de::Error::custom(format!( "Invalid email format: '{}'. \ Expected format: user@example.com", s ))); } if s.len() > 254 { return Err(de::Error::custom( "Email too long (max 254 characters per RFC 5321)" )); } Ok(Email(s)) } } #[derive(Debug, Deserialize)] struct NotificationArgs { /// Recipient email address (e.g., "user@example.com") recipient: Email, /// Message subject (e.g., "Order Confirmation") subject: String, /// Message body in plain text or HTML body: String, } // Email validation happens during parsing - zero overhead! }
Pattern 3: Enum Validation with Constraints
#![allow(unused)] fn main() { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] enum Priority { /// Low priority - process within 24 hours Low, /// Normal priority - process within 4 hours Normal, /// High priority - process within 1 hour High, /// Critical priority - process immediately Critical, } #[derive(Debug, Deserialize)] struct TaskArgs { /// Task title (e.g., "Process customer order #12345") title: String, /// Priority level determines processing timeline priority: Priority, /// Optional due date in ISO 8601 format due_date: Option<String>, } // serde automatically validates priority against enum variants // LLM gets error: "unknown variant `URGENT`, expected one of `LOW`, `NORMAL`, `HIGH`, `CRITICAL`" }
Error Messages: Guide the LLM to Success
Error messages are documentation for LLMs. Make them actionable:
❌ Bad Error Messages (Vague)
#![allow(unused)] fn main() { return Err(Error::validation("Invalid input")); return Err(Error::validation("Missing field")); return Err(Error::validation("Bad format")); }
LLM sees: “Invalid input” → tries random fixes → fails repeatedly
✅ Good Error Messages (Actionable)
#![allow(unused)] fn main() { return Err(Error::validation( "Invalid 'amount' field: must be a positive number. \ Received: -50.0. Example: amount: 100.50" )); return Err(Error::validation( "Missing required field 'customer_id'. \ Expected format: {customer_id: string, amount: number}. \ Example: {customer_id: 'cust_123', amount: 99.99}" )); return Err(Error::validation(format!( "Invalid date format for 'created_at': '{}'. \ Expected ISO 8601 format (YYYY-MM-DD). \ Examples: '2024-01-15', '2024-12-31'", invalid_date ))); }
LLM sees: Clear problem, expected format, example → fixes immediately → succeeds
Error Message Template
#![allow(unused)] fn main() { format!( "{problem}. {expectation}. {example}", problem = "What went wrong", expectation = "What was expected", example = "Concrete example of correct input" ) // Example: "Division by zero is not allowed. \ Provide a non-zero value for 'b'. \ Example: {a: 10, b: 2, operation: 'divide'}" }
Key principle: Suggest only 1-2 fixes per error message to reduce model confusion. Multiple possible fixes force the LLM to guess, reducing success rates.
#![allow(unused)] fn main() { // ❌ Too many options (confusing) return Err(Error::validation( "Invalid input. Try: (1) changing the format, or (2) using a different value, \ or (3) checking the documentation, or (4) verifying the field name" )); // ✅ One clear fix (actionable) return Err(Error::validation( "Invalid 'date' format. Use ISO 8601 (YYYY-MM-DD). Example: '2024-01-15'" )); }
Error Taxonomy: Which Error Type to Use
PMCP provides several error constructors. Use this table to choose the right one:
| Error Type | When to Use | PMCP Constructor | Example |
|---|---|---|---|
| Validation | Invalid arguments, bad formats, constraint violations | Error::validation("...") | “Amount must be positive. Received: -50.0” |
| Protocol Misuse | Wrong parameter types, missing required fields (protocol-level) | Error::protocol(ErrorCode::INVALID_PARAMS, "...") | “Missing required ‘customer_id’ field” |
| Not Found | Tool/resource/prompt doesn’t exist | Error::protocol(ErrorCode::METHOD_NOT_FOUND, "...") | “Tool ‘unknown_tool’ not found” |
| Internal | Server-side failures, database errors, unexpected states | Error::internal("...") | “Database connection failed” |
Usage examples:
#![allow(unused)] fn main() { use pmcp::{Error, ErrorCode}; // Validation errors (business logic) if amount <= 0.0 { return Err(Error::validation( "Amount must be positive. Example: amount: 100.50" )); } // Protocol errors (MCP spec violations) if args.get("customer_id").is_none() { return Err(Error::protocol( ErrorCode::INVALID_PARAMS, "Missing required field 'customer_id'. \ Expected: {customer_id: string, amount: number}" )); } // Not found errors if !tool_exists(&tool_name) { return Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("Tool '{}' not found. Available tools: calculate, search, notify", tool_name) )); } // Internal errors (don't expose implementation details) match db.query(&sql).await { Ok(result) => result, Err(e) => { tracing::error!("Database query failed: {}", e); return Err(Error::internal( "Failed to retrieve data. Please try again or contact support." )); } } }
Security note: For Internal errors, log detailed errors server-side but return generic messages to clients. This prevents information leakage about your infrastructure.
Embedding Examples in Schemas
Smart MCP clients can show “Try it” buttons with pre-filled examples. Here’s how to provide them:
#![allow(unused)] fn main() { use serde_json::json; /// Example calculator inputs for "Try it" feature fn example_add() -> serde_json::Value { json!({ "a": 10.0, "b": 5.0, "operation": "add", "description": "Add two numbers: 10 + 5 = 15" }) } fn example_divide() -> serde_json::Value { json!({ "a": 20.0, "b": 4.0, "operation": "divide", "description": "Divide numbers: 20 / 4 = 5" }) } // If using pmcp-macros with schemars support: #[derive(Debug, Deserialize)] #[schemars(example = "example_add")] // Future feature struct CalculatorArgs { /// First operand (e.g., 42.5, -10.3, 0, 3.14159) a: f64, /// Second operand (e.g., 2.0, -5.5, 10, 1.414) b: f64, /// Operation to perform operation: Operation, } }
Why this helps LLMs:
- Concrete examples show valid input patterns
- LLMs can reference examples when generating calls
- Human developers can click “Try it” in UI tools
- Testing becomes easier with ready-made valid inputs
Best practice: Provide 2-3 examples covering:
- Happy path: Most common use case
- Edge case: Boundary values (zero, negative, large numbers)
- Optional fields: Show how to use optional parameters
#![allow(unused)] fn main() { /// Examples for search tool fn example_basic_search() -> serde_json::Value { json!({"query": "rust programming"}) // Minimal } fn example_advanced_search() -> serde_json::Value { json!({ "query": "MCP protocol", "limit": 20, "sort": "relevance", "filters": { "min_score": 0.8, "categories": ["tutorial", "documentation"] } }) // With optional fields } }
Best Practices for LLM-Friendly Tools
1. Descriptions: Be Specific and Example-Rich
#![allow(unused)] fn main() { // ❌ Too vague /// Calculate numbers struct CalculatorArgs { ... } // ✅ Specific with examples /// Performs basic mathematical operations on two numbers. /// Supports: addition, subtraction, multiplication, division. /// Examples: /// - Add: {a: 5, b: 3, operation: "add"} → 8 /// - Divide: {a: 20, b: 4, operation: "divide"} → 5 /// - Multiply: {a: 7, b: 6, operation: "multiply"} → 42 struct CalculatorArgs { ... } }
2. Field Names: Short but Documented
#![allow(unused)] fn main() { // ❌ Too cryptic struct Args { x: f64, // What is x? y: f64, // What is y? op: String, // What operations? } // ✅ Clear names or documented aliases struct Args { /// First operand (e.g., 42.5, -10, 0) #[serde(rename = "a")] first_number: f64, /// Second operand (e.g., 2.0, -5, 100) #[serde(rename = "b")] second_number: f64, /// Operation: "add", "subtract", "multiply", "divide" operation: Operation, } }
3. Optional Arguments: Use Option (Not serde defaults)
Recommended: Use Rust’s native Option<T> for optional fields. This is clearer for both LLMs and developers than serde defaults.
PMCP integrates seamlessly with Option<T>:
#![allow(unused)] fn main() { #[derive(Debug, Deserialize)] struct SearchArgs { /// Search query (required) (e.g., "rust programming", "MCP protocol") query: String, /// Maximum results to return (optional, default: 10, range: 1-100) /// If not provided, defaults to 10 limit: Option<u32>, /// Sort order (optional): "relevance" or "date" /// If not provided, defaults to "relevance" sort: Option<String>, /// Filter by date range (optional) /// Example: "2024-01-01" since_date: Option<String>, } impl SearchArgs { /// Get limit with default value fn limit(&self) -> u32 { self.limit.unwrap_or(10) } /// Get sort order with default fn sort_order(&self) -> &str { self.sort.as_deref().unwrap_or("relevance") } } // Usage in handler async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let params: SearchArgs = serde_json::from_value(args)?; // Access optional fields naturally let limit = params.limit.unwrap_or(10); let sort = params.sort.as_deref().unwrap_or("relevance"); // Or use helper methods let limit = params.limit(); let sort = params.sort_order(); // Check if optional field was provided if let Some(date) = params.since_date { // Filter by date } // ... } }
Comparison: serde defaults vs Option
#![allow(unused)] fn main() { // Approach 1: serde defaults (always has a value) #[derive(Debug, Deserialize)] struct Args1 { #[serde(default = "default_limit")] limit: u32, // Always present, LLM doesn't know it's optional } fn default_limit() -> u32 { 10 } // Approach 2: Option<T> (idiomatic Rust, clear optionality) #[derive(Debug, Deserialize)] struct Args2 { limit: Option<u32>, // Clearly optional, LLM sees it's not required } // In schema: // Args1 generates: "limit": {"type": "number"} (looks required!) // Args2 generates: "limit": {"type": ["number", "null"]} (clearly optional) }
Why Option
- Schema Accuracy: JSON schema clearly shows
["number", "null"](optional) - Type Safety: Compiler enforces handling the None case
- LLM Clarity: LLM sees field is optional in the
requiredarray - No Magic: No hidden default functions, behavior is explicit
- Rust Idioms: Use
map(),unwrap_or(), pattern matching naturally
When to use serde defaults: Only for configuration files where you want a default value persisted. For MCP tool inputs, prefer Option<T> so LLMs see clear optionality.
Advanced Optional Patterns:
#![allow(unused)] fn main() { #[derive(Debug, Deserialize)] struct AdvancedSearchArgs { /// Search query (required) query: String, /// Page number (optional, starts at 1) page: Option<u32>, /// Results per page (optional, default: 10, max: 100) per_page: Option<u32>, /// Include archived results (optional, default: false) include_archived: Option<bool>, /// Filter tags (optional, can be empty array) tags: Option<Vec<String>>, /// Advanced filters (optional, complex nested structure) filters: Option<SearchFilters>, } #[derive(Debug, Deserialize)] struct SearchFilters { min_score: Option<f32>, max_age_days: Option<u32>, categories: Option<Vec<String>>, } impl AdvancedSearchArgs { /// Validate optional fields when present fn validate(&self) -> Result<()> { // Validate page if provided if let Some(page) = self.page { if page == 0 { return Err(Error::validation( "Page number must be >= 1. Example: page: 1" )); } } // Validate per_page if provided if let Some(per_page) = self.per_page { if per_page == 0 || per_page > 100 { return Err(Error::validation( "per_page must be between 1 and 100. Example: per_page: 20" )); } } // Validate nested optional structure if let Some(ref filters) = self.filters { if let Some(min_score) = filters.min_score { if min_score < 0.0 || min_score > 1.0 { return Err(Error::validation( "min_score must be between 0.0 and 1.0" )); } } } Ok(()) } /// Calculate offset for pagination (using optional page/per_page) fn offset(&self) -> usize { let page = self.page.unwrap_or(1); let per_page = self.per_page.unwrap_or(10); ((page - 1) * per_page) as usize } } }
LLM sees clear optionality:
When the LLM reads the schema, it understands:
{
"properties": {
"query": {"type": "string"}, // Required
"page": {"type": ["number", "null"]}, // Optional
"per_page": {"type": ["number", "null"]}, // Optional
"include_archived": {"type": ["boolean", "null"]} // Optional
},
"required": ["query"] // Only query is required!
}
LLM learns: “I must provide query, everything else is optional” → includes only what’s needed → higher success rate
4. Validation: Fail Fast with Clear Reasons
#![allow(unused)] fn main() { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { // Parse (automatic type validation) let params: SearchArgs = serde_json::from_value(args) .map_err(|e| Error::validation(format!( "Invalid arguments for search tool: {}. \ Expected: {{query: string, limit?: number, sort?: string}}", e )))?; // Validate query length if params.query.is_empty() { return Err(Error::validation( "Search query cannot be empty. \ Provide a non-empty string. Example: query: 'rust programming'" )); } if params.query.len() > 500 { return Err(Error::validation(format!( "Search query too long ({} characters). \ Maximum 500 characters. Current query: '{}'", params.query.len(), ¶ms.query[..50] // Show first 50 chars ))); } // Validate limit range if params.limit == 0 || params.limit > 100 { return Err(Error::validation(format!( "Invalid limit: {}. Must be between 1 and 100. \ Example: limit: 10", params.limit ))); } // Validate sort option if !matches!(params.sort.as_str(), "relevance" | "date") { return Err(Error::validation(format!( "Invalid sort option: '{}'. \ Must be 'relevance' or 'date'. Example: sort: 'relevance'", params.sort ))); } // All validations passed - proceed perform_search(params).await } }
5. Output: Structured and Documented
#![allow(unused)] fn main() { /// Result of a search operation #[derive(Debug, Serialize)] struct SearchResult { /// Search query that was executed query: String, /// Number of results found total_count: usize, /// Search results (limited by 'limit' parameter) results: Vec<SearchItem>, /// Time taken to execute search (milliseconds) duration_ms: u64, } #[derive(Debug, Serialize)] struct SearchItem { /// Title of the search result title: String, /// URL to the resource url: String, /// Short snippet/excerpt (max 200 characters) snippet: String, /// Relevance score (0.0 to 1.0, higher is better) score: f32, } }
LLM can extract specific fields:
// LLM sees structured data and can:
result.total_count // Get count
result.results[0].title // Get first title
result.results.map(r => r.url) // Extract all URLs
Advanced Topics
Performance Quick Note: SIMD Acceleration
⚡ Performance Boost: PMCP can parse large tool arguments 2–10x faster using SIMD (Single Instruction, Multiple Data).
Enable in production with:
[dependencies] pmcp = { version = "1.5", features = ["simd"] }Most beneficial for: large arguments (>10KB), batch operations, high-throughput servers.
See Chapter 14: Performance & Optimization for SIMD internals, batching strategies, and benchmarks.
Caching with Type Safety
#![allow(unused)] fn main() { use std::sync::Arc; use tokio::sync::RwLock; use std::collections::HashMap; struct CachedCalculatorTool { cache: Arc<RwLock<HashMap<String, CalculatorResult>>>, } impl CachedCalculatorTool { fn cache_key(args: &CalculatorArgs) -> String { format!("{:?}_{:?}_{}", args.first, args.second, args.operation) } } #[async_trait] impl ToolHandler for CachedCalculatorTool { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { let params: CalculatorArgs = serde_json::from_value(args)?; // Check cache let key = Self::cache_key(¶ms); { let cache = self.cache.read().await; if let Some(cached) = cache.get(&key) { return Ok(serde_json::to_value(cached)?); } } // Calculate let result = perform_calculation(¶ms)?; // Store in cache { let mut cache = self.cache.write().await; cache.insert(key, result.clone()); } Ok(serde_json::to_value(result)?) } } }
Complete Example: Production-Ready Calculator
Putting it all together:
use async_trait::async_trait; use pmcp::{Server, ToolHandler, RequestHandlerExtra, Result, Error}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Mathematical operation to perform #[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] enum Operation { /// Add two numbers (e.g., 5 + 3 = 8) Add, /// Subtract second from first (e.g., 10 - 4 = 6) Subtract, /// Multiply two numbers (e.g., 6 * 7 = 42) Multiply, /// Divide first by second (e.g., 20 / 4 = 5) /// Returns error if divisor is zero. Divide, } /// Arguments for calculator tool #[derive(Debug, Deserialize)] struct CalculatorArgs { /// First operand /// Examples: 42.5, -10.3, 0, 3.14159, 1000000 a: f64, /// Second operand /// Examples: 2.0, -5.5, 10, 1.414, 0.001 b: f64, /// Operation to perform operation: Operation, } impl CalculatorArgs { /// Validate arguments before processing fn validate(&self) -> Result<()> { // Check for NaN or infinity if !self.a.is_finite() { return Err(Error::validation(format!( "First operand 'a' is not a finite number: {:?}. \ Provide a normal number like 42.5, -10, or 0.", self.a ))); } if !self.b.is_finite() { return Err(Error::validation(format!( "Second operand 'b' is not a finite number: {:?}. \ Provide a normal number like 2.0, -5, or 100.", self.b ))); } // Division by zero check if matches!(self.operation, Operation::Divide) && self.b == 0.0 { return Err(Error::validation( "Cannot divide by zero. \ Provide a non-zero value for 'b'. \ Example: {a: 10, b: 2, operation: 'divide'}" )); } Ok(()) } } /// Result of calculator operation #[derive(Debug, Clone, Serialize)] struct CalculatorResult { /// The calculated result result: f64, /// Human-readable expression (e.g., "5 + 3 = 8") expression: String, /// Operation that was performed operation: Operation, /// Input operands (for verification) inputs: CalculatorInputs, } #[derive(Debug, Clone, Serialize)] struct CalculatorInputs { a: f64, b: f64, } /// Calculator tool implementation struct CalculatorTool; #[async_trait] impl ToolHandler for CalculatorTool { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> Result<Value> { // Parse arguments let params: CalculatorArgs = serde_json::from_value(args) .map_err(|e| Error::validation(format!( "Invalid calculator arguments: {}. \ Expected format: {{a: number, b: number, operation: string}}. \ Example: {{a: 10, b: 5, operation: 'add'}}", e )))?; // Validate arguments params.validate()?; // Perform calculation let result = match params.operation { Operation::Add => params.a + params.b, Operation::Subtract => params.a - params.b, Operation::Multiply => params.a * params.b, Operation::Divide => params.a / params.b, }; // Validate result if !result.is_finite() { return Err(Error::validation(format!( "Calculation resulted in non-finite value: {:?}. \ This usually indicates overflow. \ Try smaller numbers. Inputs: a={}, b={}", result, params.a, params.b ))); } // Build structured response let response = CalculatorResult { result, expression: format!( "{} {} {} = {}", params.a, match params.operation { Operation::Add => "+", Operation::Subtract => "-", Operation::Multiply => "*", Operation::Divide => "/", }, params.b, result ), operation: params.operation, inputs: CalculatorInputs { a: params.a, b: params.b, }, }; // Return structured data - PMCP wraps this in CallToolResult for the client Ok(serde_json::to_value(response)?) } } #[tokio::main] async fn main() -> Result<()> { // Build server with calculator tool let server = Server::builder() .name("calculator-server") .version("1.0.0") .tool("calculator", CalculatorTool) .build()?; // Run server println!("Calculator server ready!"); println!("Example usage:"); println!(" {{a: 10, b: 5, operation: 'add'}} → 15"); println!(" {{a: 20, b: 4, operation: 'divide'}} → 5"); server.run_stdio().await }
Testing Your Tools
Use the MCP Tester from Chapter 3:
# Start your server
cargo run --example calculator-server &
# Test tool discovery
mcp-tester tools http://localhost:8080
# Test specific tool
mcp-tester test http://localhost:8080 \
--tool calculator \
--args '{"a": 10, "b": 5, "operation": "add"}'
# Test error handling
mcp-tester test http://localhost:8080 \
--tool calculator \
--args '{"a": 10, "b": 0, "operation": "divide"}'
# Expected: Clear error message about division by zero
Summary
Tools are the core actions of your MCP server. PMCP provides:
Type Safety:
- ✅ Rust structs with compile-time validation
- ✅ Automatic schema generation
- ✅ Zero-cost abstractions
Performance:
- ✅ Efficient memory usage with zero-cost abstractions
- ✅ Optional SIMD acceleration for high-throughput (see Chapter 14)
- ✅ Batch processing support
LLM Success:
- ✅ Clear, example-rich descriptions
- ✅ Actionable error messages (1-2 fixes max)
- ✅ Structured inputs and outputs
- ✅ Validation with helpful feedback
Key Takeaways:
- Use typed structs for all tools (not dynamic JSON)
- Document every field with examples
- Write error messages that guide LLMs to success (problem + fix + example)
- Use Option
for optional fields (not serde defaults) - Validate early and thoroughly
- Return structured data, not just strings
Next chapters:
- Chapter 6: Resources & Resource Management
- Chapter 7: Prompts & Templates
- Chapter 8: Error Handling & Recovery
The typed approach makes your tools safer, faster, and more reliable for LLM-driven applications.
Chapter 6: Resources — Documentation for Agents
Resources are the “documentation pages” of your MCP server—reference material that agents can read to make informed decisions. Where tools are actions (Chapter 5), resources are context. This chapter shows you how to provide stable, well-structured information that LLMs can discover, read, and cite.
The goal: build type-safe, discoverable resources from simple static content to watched file systems.
Quick Start: Your First Resource (20 lines)
Let’s create a simple documentation server with static resources:
use pmcp::{Server, StaticResource, ResourceCollection}; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create a collection of documentation resources let resources = ResourceCollection::new() .add_resource( StaticResource::new_text( "docs://readme", "# Welcome to MCP\n\nThis server provides access to documentation." ) .with_name("README") .with_description("Getting started guide") .with_mime_type("text/markdown") ); // Add to server and run Server::builder().resources(resources).build()?.run_stdio().await }
Test it:
# Start server
cargo run
# In another terminal, use MCP tester:
mcp-tester test stdio --list-resources
# Shows: docs://readme
mcp-tester test stdio --read-resource "docs://readme"
# Returns: # Welcome to MCP...
That’s it! You’ve created and tested an MCP resource server. Now let’s understand how it works and build production-ready patterns.
Basics vs Advanced
This chapter covers resources in two parts:
Basics (this section):
- Static resources with
StaticResourceandResourceCollection - Basic URI templates
- Resource subscriptions and notifications
- Testing fundamentals
Advanced (later in this chapter):
- Dynamic resource handlers (database, API-backed)
- File system watching with
ResourceWatcher - Multi-source resource servers
- Performance optimization
Start with basics if you’re building simple documentation or configuration servers. Move to advanced patterns when you need dynamic content or file system integration.
The Resource Analogy: Documentation for Agents
Continuing the website analogy from Chapter 4, resources are your “docs, FAQs, and knowledge base” for agents.
| Website Element | MCP Resource | Agent Use Case |
|---|---|---|
| Documentation pages | Text resources | Read policies, guides, references |
| FAQ/Help articles | Markdown/HTML resources | Learn how to use the service |
| Configuration files | JSON/YAML resources | Understand settings and options |
| Data exports | CSV/JSON resources | Access structured data |
| Images/diagrams | Image resources | View visual information |
| API specs | OpenAPI/JSON resources | Understand available operations |
Key insight: Resources are read-only reference material, not actions. They provide context that helps agents decide which tools to use and how to use them correctly.
Why Resources Matter for LLMs
LLMs driving MCP clients need context to make good decisions. Resources provide:
- Policies & Rules: “Can I refund orders over $1000?” → Read
docs://policies/refunds - Data for Reasoning: “What products are popular?” → Read
data://products/trending.json - Templates & Examples: “How do I format emails?” → Read
templates://email/welcome.html - Current State: “What’s in the config?” → Read
config://app/settings.json - Reference Material: “What are valid status codes?” → Read
docs://api/status-codes.md
Example workflow:
Agent task: "Process a refund for order #12345"
1. Read resource: docs://policies/refunds.md
→ Learn: "Refunds allowed within 30 days, max $500 without approval"
2. Call tool: get_order(order_id="12345")
→ Check: order date, amount
3. Decision: amount > $500 → escalate vs. amount < $500 → process
4. Call tool: create_refund(...) with correct parameters
Without the resource in step 1, the agent might call tools incorrectly or make wrong decisions.
Resource Anatomy: Checklist
Before diving into code, here’s what every resource needs:
| Component | Required? | Purpose | Example |
|---|---|---|---|
| URI | ✅ Required | Unique, stable identifier | docs://policies/refunds |
| Name | ✅ Required | Human-readable label | “Refund Policy” |
| Description | ⚠️ Recommended | Explains purpose & content | “30-day refund rules…” |
| MIME Type | ⚠️ Recommended | Content format | text/markdown |
| Priority | ⚠️ Recommended | Importance (0.0–1.0) | 0.9 (must-read policy) |
| Modified At | ⚠️ Recommended | Last update timestamp | 2025-01-15T10:30:00Z |
| Content | ✅ Required | The actual data | Text, Image, or JSON |
| List Method | ✅ Required | Discovery (enumerate) | Returns all resources |
| Read Method | ✅ Required | Fetch content by URI | Returns resource content |
| Notify | ⚠️ Optional | Update subscriptions | When content changes |
Priority guidance (0.0–1.0):
- 0.9–1.0: Must-read (policies, SLAs, breaking changes)
- 0.7–0.8: Important (guidelines, best practices)
- 0.5–0.6: Normal documentation
- 0.3–0.4: Supplementary (examples, FAQs)
- 0.1–0.2: Low-signal (archives, deprecated)
UI hint: Clients should order by priority DESC, modified_at DESC to surface critical, recent content first.
Quick decision tree:
- Static content? → Use
StaticResource(next section) - Dynamic content? → Implement
ResourceHandlertrait (Advanced section) - File system? → Use
ResourceWatcher(Advanced section)
Resource Anatomy: Step-by-Step
Every resource follows this anatomy:
- URI + Description → Unique identifier and purpose
- Content Types → Text, Image, or Resource content
- Resource Metadata → Name, MIME type, description
- List Implementation → Enumerate available resources
- Read Implementation → Return resource content
- Add to Server → Register and test
Let’s build a comprehensive documentation server following this pattern.
Step 1: URI + Description
#![allow(unused)] fn main() { /// URI: "docs://policies/refunds" /// Description: "Refund policy for customer orders. /// Defines time limits, amount thresholds, and approval requirements." }
URI Design Best Practices:
- Use scheme prefixes:
docs://,config://,data://,template:// - Hierarchical paths:
docs://policies/refunds,docs://policies/shipping - Stable identifiers: Don’t change URIs across versions
- Clear naming:
users/profile.jsonnotusr/p.json
Step 2: Content Types (Typed)
PMCP supports three content types:
#![allow(unused)] fn main() { use pmcp::types::Content; // 1. Text content (most common) let text_content = Content::Text { text: "# Refund Policy\n\nRefunds are allowed within 30 days...".to_string() }; // 2. Image content (base64-encoded) let image_content = Content::Image { data: base64_encoded_png, mime_type: "image/png".to_string(), }; // 3. Resource content (with metadata) let resource_content = Content::Resource { uri: "docs://policies/refunds".to_string(), mime_type: Some("text/markdown".to_string()), text: Some("# Refund Policy...".to_string()), }; }
Most resources use Content::Text with appropriate MIME types to indicate format.
Step 3: Resource Metadata
Define metadata for each resource. Note: ResourceInfo from the protocol doesn’t natively support priority or modified_at, so we use an annotations pattern:
#![allow(unused)] fn main() { use pmcp::types::ResourceInfo; use serde::{Serialize, Deserialize}; /// Extended resource metadata with priority and recency tracking /// (stored separately, combined in list responses) #[derive(Debug, Clone, Serialize, Deserialize)] struct ResourceAnnotations { /// Priority: 0.0 (low) to 1.0 (critical) priority: f64, /// Last modified timestamp (ISO 8601) modified_at: String, } /// Internal storage combining ResourceInfo with annotations struct AnnotatedResource { info: ResourceInfo, annotations: ResourceAnnotations, } /// Metadata for refund policy resource fn refund_policy_resource() -> AnnotatedResource { AnnotatedResource { info: ResourceInfo { /// Stable URI - don't change this across versions uri: "docs://policies/refunds".to_string(), /// Human-readable name name: "Refund Policy".to_string(), /// Description embedding priority and update info for agents description: Some( "[PRIORITY: HIGH] Customer refund policy. \ Covers time limits (30 days), amount thresholds ($500), \ and approval requirements. Updated on 2025-01-15." .to_string() ), /// MIME type - MUST match Content type in read() mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 0.9, // Must-read policy modified_at: "2025-01-15T10:30:00Z".to_string(), }, } } }
Why metadata matters:
- uri: Agents use this to request the resource (stable identifier)
- name: Shown in discovery lists for human/agent understanding
- description: Helps agents decide if resource is relevant
- Embed priority hints:
[PRIORITY: HIGH]or[CRITICAL] - Include “Updated on …” for user-facing context
- Embed priority hints:
- mime_type: Tells agents how to parse content (must match read() response)
- annotations.priority (0.0–1.0): Server-side importance ranking for sorting
- annotations.modified_at (ISO 8601): Last update timestamp for recency sorting
JSON output (what clients see):
{
"uri": "docs://policies/refunds",
"name": "Refund Policy",
"description": "[PRIORITY: HIGH] Customer refund policy. Covers time limits (30 days), amount thresholds ($500), and approval requirements. Updated on 2025-01-15.",
"mimeType": "text/markdown",
"annotations": {
"priority": 0.9,
"modifiedAt": "2025-01-15T10:30:00Z"
}
}
Note: Annotations are optional extensions. Clients can ignore them or use them for sorting/filtering.
Step 4: List Implementation
Implement resource listing (discovery) with priority and recency sorting:
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::{ResourceHandler, RequestHandlerExtra, Result}; use pmcp::types::{ListResourcesResult, ReadResourceResult, ResourceInfo}; struct DocumentationResources { // In-memory storage of annotated resources resources: Vec<AnnotatedResource>, } impl DocumentationResources { fn new() -> Self { Self { resources: vec![ AnnotatedResource { info: ResourceInfo { uri: "docs://policies/refunds".to_string(), name: "Refund Policy".to_string(), description: Some( "[PRIORITY: HIGH] Customer refund rules. \ Updated on 2025-01-15.".to_string() ), mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 0.9, modified_at: "2025-01-15T10:30:00Z".to_string(), }, }, AnnotatedResource { info: ResourceInfo { uri: "docs://policies/shipping".to_string(), name: "Shipping Policy".to_string(), description: Some( "[PRIORITY: NORMAL] Shipping timeframes and costs. \ Updated on 2025-01-10.".to_string() ), mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 0.5, modified_at: "2025-01-10T14:00:00Z".to_string(), }, }, AnnotatedResource { info: ResourceInfo { uri: "config://app/settings.json".to_string(), name: "App Settings".to_string(), description: Some( "[PRIORITY: HIGH] Application configuration. \ Updated on 2025-01-20.".to_string() ), mime_type: Some("application/json".to_string()), }, annotations: ResourceAnnotations { priority: 0.8, modified_at: "2025-01-20T09:15:00Z".to_string(), }, }, ], } } } #[async_trait] impl ResourceHandler for DocumentationResources { async fn list( &self, _cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // Sort by priority DESC, then modified_at DESC (most recent first) let mut sorted_resources = self.resources.clone(); sorted_resources.sort_by(|a, b| { // Primary sort: priority descending let priority_cmp = b.annotations.priority .partial_cmp(&a.annotations.priority) .unwrap_or(std::cmp::Ordering::Equal); if priority_cmp != std::cmp::Ordering::Equal { return priority_cmp; } // Secondary sort: modified_at descending (string comparison works for ISO 8601) b.annotations.modified_at.cmp(&a.annotations.modified_at) }); // Extract ResourceInfo for protocol response // (Annotations are embedded in description, can also be returned separately) let resources: Vec<ResourceInfo> = sorted_resources .iter() .map(|annotated| annotated.info.clone()) .collect(); Ok(ListResourcesResult { resources, next_cursor: None, // No pagination for small lists }) } async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Implementation in Step 5 todo!("Implement in next step") } } }
Pagination Support:
#![allow(unused)] fn main() { async fn list( &self, cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { const PAGE_SIZE: usize = 10; // Parse cursor to page number let page: usize = cursor .as_deref() .and_then(|c| c.parse().ok()) .unwrap_or(0); let start = page * PAGE_SIZE; let end = (start + PAGE_SIZE).min(self.resources.len()); let page_resources = self.resources[start..end].to_vec(); // Set next_cursor if more pages exist let next_cursor = if end < self.resources.len() { Some((page + 1).to_string()) } else { None }; Ok(ListResourcesResult { resources: page_resources, next_cursor, }) } }
Step 5: Read Implementation
Implement resource reading (fetching content). Critical: The content type in read() must match the mime_type advertised in list().
#![allow(unused)] fn main() { use pmcp::types::Content; use pmcp::{Error, ErrorCode}; #[async_trait] impl ResourceHandler for DocumentationResources { async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Match URI and return appropriate content // IMPORTANT: Content type must match mime_type from list() let content = match uri { "docs://policies/refunds" => { // mime_type in list() = "text/markdown" // So return text content (client will parse as markdown) Content::Text { text: r#"# Refund Policy # Timeframe - Refunds allowed within 30 days of purchase - Items must be in original condition # Amount Limits - Under $500: Auto-approved - Over $500: Requires manager approval # Process 1. Customer requests refund via support ticket 2. Verify purchase date and amount 3. Process or escalate based on amount "#.to_string() } }, "docs://policies/shipping" => { // mime_type in list() = "text/markdown" Content::Text { text: r#"# Shipping Policy # Domestic Shipping - Standard: 5-7 business days ($5.99) - Express: 2-3 business days ($12.99) - Overnight: Next business day ($24.99) # International - Contact support for rates and timeframes "#.to_string() } }, "config://app/settings.json" => { // mime_type in list() = "application/json" // Return JSON as text - client will parse based on MIME type Content::Text { text: r#"{ "theme": "dark", "language": "en", "features": { "refunds": true, "shipping_calculator": true, "live_chat": false }, "limits": { "max_refund_auto_approve": 500, "refund_window_days": 30 } }"#.to_string() } }, _ => { // Resource not found - return clear error return Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!( "Resource '{}' not found. Available resources: \ docs://policies/refunds, docs://policies/shipping, \ config://app/settings.json", uri ) )); } }; Ok(ReadResourceResult { contents: vec![content], }) } async fn list( &self, cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // ... (from Step 4) Ok(ListResourcesResult { resources: self.resources.clone(), next_cursor: None, }) } } }
MIME Type Consistency - Why It Matters:
The mime_type field in list() tells clients how to parse the content from read():
#![allow(unused)] fn main() { // In list(): mime_type: Some("application/json".to_string()) // In read(): Content::Text { text: r#"{"key": "value"}"#.to_string() // JSON string } // ✅ Client sees mime_type and parses text as JSON // ❌ If mime_type was "text/plain", client wouldn't parse JSON structure }
Common mistakes:
- ❌ Advertise
"application/json"but return plain text - ❌ Advertise
"text/markdown"but return HTML - ❌ Change mime_type without updating content format
- ✅ Keep advertised MIME type and actual content type aligned
Error Handling Best Practices:
- Return
ErrorCode::METHOD_NOT_FOUNDfor missing resources - Include helpful message listing available resources
- Consider suggesting similar URIs if applicable
Step 6: Add to Server
use pmcp::Server; use pmcp::types::capabilities::ServerCapabilities; #[tokio::main] async fn main() -> pmcp::Result<()> { let server = Server::builder() .name("documentation-server") .version("1.0.0") .capabilities(ServerCapabilities::resources_only()) .resources(DocumentationResources::new()) .build()?; // Test with: mcp-tester test stdio --list-resources // mcp-tester test stdio --read-resource "docs://policies/refunds" server.run_stdio().await }
Static Resources: The Simple Path
For fixed content that doesn’t change, use StaticResource and ResourceCollection:
#![allow(unused)] fn main() { use pmcp::{StaticResource, ResourceCollection}; // Create individual static resources let readme = StaticResource::new_text( "docs://readme", "# Welcome\n\nThis is the project README." ) .with_name("README") .with_description("Project overview and getting started guide") .with_mime_type("text/markdown"); let config = StaticResource::new_text( "config://app.json", r#"{"theme": "dark", "version": "1.0.0"}"# ) .with_name("App Config") .with_description("Application configuration") .with_mime_type("application/json"); // Images: provide binary data let logo_png = include_bytes!("../assets/logo.png"); let logo = StaticResource::new_image( "image://logo", logo_png, "image/png" ) .with_name("Company Logo") .with_description("Official company logo"); // Collect into a resource handler let resources = ResourceCollection::new() .add_resource(readme) .add_resource(config) .add_resource(logo); // Add to server let server = Server::builder() .resources(resources) .build()?; }
When to use StaticResource:
- ✅ Fixed documentation (README, guides, policies)
- ✅ Configuration files that rarely change
- ✅ Templates (email, reports)
- ✅ Images and assets
- ❌ Database-backed content (use custom ResourceHandler)
- ❌ File system content (use ResourceWatcher)
- ❌ API-backed content (use custom ResourceHandler)
URI Templates: Parameterized Resources
URI templates (RFC 6570) allow parameterized resource URIs like users://{userId} or files://{path*}.
Basic Template Usage
#![allow(unused)] fn main() { use pmcp::shared::UriTemplate; // Simple variable let template = UriTemplate::new("users://{userId}")?; // Expand to concrete URI let uri = template.expand(&[("userId", "alice")])?; // Result: "users://alice" // Extract variables from URI let vars = template.extract_variables("users://bob")?; // vars.get("userId") == Some("bob") }
Template Operators
#![allow(unused)] fn main() { // Simple variable UriTemplate::new("users://{userId}")? // Matches: users://123, users://alice // Path segments (explode) UriTemplate::new("files://{path*}")? // Matches: files://docs/readme.md, files://src/main.rs // Query parameters UriTemplate::new("search{?query,limit}")? // Matches: search?query=rust&limit=10 }
Security note: Always validate extracted variables before using them in database queries or file paths to prevent injection attacks.
For advanced template patterns with database lookups and dynamic enumeration, see the Advanced Topics section below.
Subscription & Notifications
Clients can subscribe to resources and receive notifications when they change.
Client-Side: Subscribing
#![allow(unused)] fn main() { use pmcp::Client; async fn subscribe_to_config(client: &mut Client) -> pmcp::Result<()> { // Subscribe to a specific resource client.subscribe_resource("config://app.json".to_string()).await?; // Client now receives ResourceUpdated notifications // when config://app.json changes // Later: unsubscribe client.unsubscribe_resource("config://app.json".to_string()).await?; Ok(()) } }
Server-Side: Sending Notifications
#![allow(unused)] fn main() { // When a resource changes, notify subscribed clients server.send_notification(ServerNotification::ResourceUpdated { uri: "config://app.json".to_string(), }).await?; // When resource list changes (add/remove resources) server.send_notification(ServerNotification::ResourceListChanged).await?; }
Use cases:
- Configuration changes (app settings, feature flags)
- Data updates (inventory, pricing)
- Document modifications (policies, guides)
Note: Subscription management is automatic—PMCP tracks subscriptions and routes notifications to the correct clients.
Advanced Topics
The following sections cover advanced resource patterns. Start with basics above; come here when you need dynamic content, file watching, or database integration.
Dynamic Resource Handlers
For resources that change or come from external sources, implement ResourceHandler:
Example 1: Database-Backed Resources
#![allow(unused)] fn main() { use sqlx::PgPool; use std::sync::Arc; struct DatabaseResources { pool: Arc<PgPool>, } #[async_trait] impl ResourceHandler for DatabaseResources { async fn list( &self, cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // Query database for available resources let products = sqlx::query!( "SELECT id, name, description FROM products WHERE active = true" ) .fetch_all(&*self.pool) .await .map_err(|e| Error::internal(format!("Database error: {}", e)))?; let resources = products.iter().map(|p| ResourceInfo { uri: format!("products://{}", p.id), name: p.name.clone(), description: p.description.clone(), mime_type: Some("application/json".to_string()), }).collect(); Ok(ListResourcesResult { resources, next_cursor: None, }) } async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Extract product ID from URI let product_id = uri .strip_prefix("products://") .ok_or_else(|| Error::validation("Invalid product URI"))?; // Fetch from database let product = sqlx::query!( "SELECT * FROM products WHERE id = $1", product_id ) .fetch_optional(&*self.pool) .await .map_err(|e| Error::internal(format!("Database error: {}", e)))? .ok_or_else(|| Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("Product '{}' not found", product_id) ))?; // Return as JSON let json = serde_json::json!({ "id": product.id, "name": product.name, "description": product.description, "price": product.price, "stock": product.stock, }); Ok(ReadResourceResult { contents: vec![Content::Text { text: serde_json::to_string_pretty(&json)?, }], }) } } }
Example 2: API-Backed Resources
#![allow(unused)] fn main() { use reqwest::Client; struct ApiResources { client: Client, base_url: String, } #[async_trait] impl ResourceHandler for ApiResources { async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Parse URI: "api://users/{id}" let path = uri .strip_prefix("api://") .ok_or_else(|| Error::validation("Invalid API URI"))?; // Fetch from external API let url = format!("{}/{}", self.base_url, path); let response = self.client .get(&url) .send() .await .map_err(|e| Error::internal(format!("API request failed: {}", e)))?; if !response.status().is_success() { return Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("API returned status {}", response.status()) )); } let body = response.text().await .map_err(|e| Error::internal(format!("Failed to read response: {}", e)))?; Ok(ReadResourceResult { contents: vec![Content::Text { text: body }], }) } async fn list( &self, _cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // Could query API for available endpoints Ok(ListResourcesResult { resources: vec![ ResourceInfo { uri: "api://users/{id}".to_string(), name: "User API".to_string(), description: Some("Fetch user data by ID".to_string()), mime_type: Some("application/json".to_string()), }, ], next_cursor: None, }) } } }
Advanced URI Template Patterns
For complex scenarios with ResourceHandler implementations:
Template Matching in Custom Handlers
#![allow(unused)] fn main() { use pmcp::shared::UriTemplate; use std::collections::HashMap; struct TemplateResources { user_data: HashMap<String, String>, // userId -> JSON } #[async_trait] impl ResourceHandler for TemplateResources { async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Define template let template = UriTemplate::new("users://{userId}")?; // Try to match and extract variables if let Ok(vars) = template.extract_variables(uri) { let user_id = vars.get("userId") .ok_or_else(|| Error::validation("Missing userId"))?; // Look up user data let data = self.user_data.get(user_id) .ok_or_else(|| Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("User '{}' not found", user_id) ))?; return Ok(ReadResourceResult { contents: vec![Content::Text { text: data.clone(), }], }); } Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, "Unknown resource" )) } async fn list( &self, _cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // List template pattern Ok(ListResourcesResult { resources: vec![ResourceInfo { uri: "users://{userId}".to_string(), name: "User Template".to_string(), description: Some("User data by ID".to_string()), mime_type: Some("application/json".to_string()), }], next_cursor: None, }) } } }
Template Operators
#![allow(unused)] fn main() { // Simple variable UriTemplate::new("users://{userId}")? // Matches: users://123, users://alice // Path segments (explode) UriTemplate::new("files://{path*}")? // Matches: files://docs/readme.md, files://src/main.rs // Query parameters UriTemplate::new("search{?query,limit}")? // Matches: search?query=rust&limit=10 // Fragment UriTemplate::new("docs://readme{#section}")? // Matches: docs://readme#installation }
Template expansion:
#![allow(unused)] fn main() { let template = UriTemplate::new("users://{userId}/posts/{postId}")?; let uri = template.expand(&[ ("userId", "alice"), ("postId", "42") ])?; // Result: "users://alice/posts/42" }
Security note: Always validate extracted variables before using them in database queries or file paths to prevent injection attacks.
File Watching with ResourceWatcher
PMCP includes built-in file system watching with ResourceWatcher (example 18):
#![allow(unused)] fn main() { use pmcp::server::resource_watcher::{ResourceWatcher, ResourceWatcherBuilder}; use std::path::PathBuf; use std::time::Duration; struct FileSystemResources { base_dir: PathBuf, } #[async_trait] impl ResourceHandler for FileSystemResources { async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Convert URI to file path let path = uri .strip_prefix("file://") .ok_or_else(|| Error::validation("Invalid file:// URI"))?; let full_path = self.base_dir.join(path); // Read file content let content = tokio::fs::read_to_string(&full_path) .await .map_err(|e| Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("Failed to read file: {}", e) ))?; Ok(ReadResourceResult { contents: vec![Content::Text { text: content }], }) } async fn list( &self, _cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // Scan directory for files let mut resources = Vec::new(); let mut entries = tokio::fs::read_dir(&self.base_dir) .await .map_err(|e| Error::internal(format!("Failed to read directory: {}", e)))?; while let Some(entry) = entries.next_entry().await .map_err(|e| Error::internal(format!("Failed to read entry: {}", e)))? { if entry.file_type().await?.is_file() { if let Some(name) = entry.file_name().to_str() { resources.push(ResourceInfo { uri: format!("file://{}", name), name: name.to_string(), description: Some(format!("File: {}", name)), mime_type: guess_mime_type(name), }); } } } Ok(ListResourcesResult { resources, next_cursor: None, }) } } fn guess_mime_type(filename: &str) -> Option<String> { match filename.rsplit('.').next()? { "md" => Some("text/markdown".to_string()), "json" => Some("application/json".to_string()), "txt" => Some("text/plain".to_string()), "html" => Some("text/html".to_string()), _ => None, } } }
Configuring ResourceWatcher
For production file watching (requires resource-watcher feature):
#![allow(unused)] fn main() { use pmcp::server::resource_watcher::ResourceWatcherBuilder; use tokio::sync::mpsc; async fn setup_watcher( base_dir: PathBuf, notification_tx: mpsc::Sender<pmcp::types::ServerNotification>, ) -> pmcp::Result<ResourceWatcher> { ResourceWatcherBuilder::new() // Directory to watch .base_dir(&base_dir) // Debounce rapid changes (default: 500ms) .debounce(Duration::from_millis(500)) // Include patterns (glob syntax) .pattern("**/*.md") .pattern("**/*.json") .pattern("**/*.txt") // Ignore patterns .ignore("**/.*") // Hidden files .ignore("**/node_modules/**") // Dependencies .ignore("**/target/**") // Build output .ignore("**/*.tmp") // Temp files // Resource limit (prevents memory issues) .max_resources(10_000) .build(notification_tx)? } }
Features:
- ✅ Native file system events (inotify, FSEvents, ReadDirectoryChangesW)
- ✅ Debouncing (batch rapid changes)
- ✅ Glob pattern matching (
**/*.md) - ✅ Ignore patterns (
.git,node_modules) - ✅ Automatic
ResourceUpdatednotifications - ✅ Resource limits (default: 10K files)
See example 18 (examples/18_resource_watcher.rs) for complete implementation.
Complete Multi-Source Resource Server
Combining static, database, and file system resources:
use pmcp::{Server, ResourceCollection, StaticResource}; use std::sync::Arc; // Static documentation fn static_docs() -> ResourceCollection { ResourceCollection::new() .add_resource( StaticResource::new_text( "docs://readme", "# Welcome to MCP Server\n\nDocumentation here..." ) .with_name("README") .with_mime_type("text/markdown") ) .add_resource( StaticResource::new_text( "docs://api-reference", "# API Reference\n\nEndpoints..." ) .with_name("API Reference") .with_mime_type("text/markdown") ) } // Combined resource handler struct CombinedResources { static_docs: ResourceCollection, db_resources: DatabaseResources, file_resources: FileSystemResources, } #[async_trait] impl ResourceHandler for CombinedResources { async fn list( &self, cursor: Option<String>, extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { // Combine resources from all sources let mut all_resources = Vec::new(); // Add static docs let static_list = self.static_docs.list(None, extra.clone()).await?; all_resources.extend(static_list.resources); // Add database resources let db_list = self.db_resources.list(None, extra.clone()).await?; all_resources.extend(db_list.resources); // Add file resources let file_list = self.file_resources.list(None, extra).await?; all_resources.extend(file_list.resources); Ok(ListResourcesResult { resources: all_resources, next_cursor: None, }) } async fn read( &self, uri: &str, extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // Route to appropriate handler based on URI prefix if uri.starts_with("docs://") { self.static_docs.read(uri, extra).await } else if uri.starts_with("products://") { self.db_resources.read(uri, extra).await } else if uri.starts_with("file://") { self.file_resources.read(uri, extra).await } else { Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!("Unknown resource scheme in URI: {}", uri) )) } } } #[tokio::main] async fn main() -> pmcp::Result<()> { let server = Server::builder() .name("multi-source-server") .version("1.0.0") .resources(CombinedResources { static_docs: static_docs(), db_resources: DatabaseResources::new(db_pool).await?, file_resources: FileSystemResources::new("./data".into()), }) .build()?; server.run_stdio().await }
Additional Advanced Patterns
For even more advanced resource patterns, see later chapters:
Dynamic Resource Registration (TypeScript SDK):
- Runtime resource add/remove/update
- Enable/disable functionality
- For dynamic patterns in Rust, see Chapter 14: Advanced Patterns
Variable Completion (TypeScript SDK):
- Autocomplete callbacks for template variables
- Not available in Rust SDK currently
Resource Limits & Performance:
- Limit resource counts and sizes to prevent DoS
- For production tuning, see Chapter 14: Performance & Optimization
Resource + Tool Integration:
- Resource provides policy → Tool validates against policy
- For integration patterns, see Chapter 9: Integration Patterns
Best Practices for Resources
Do’s and Don’ts
| ✅ Do | ❌ Don’t |
|---|---|
| Use stable, hierarchical URIs with clear names | Use resources for actions (that’s a tool) |
Populate priority and modified_at accurately | Expose internal filesystem paths or external URLs directly |
| Keep resources small, focused, and well-described | Ship stale docs without modified_at |
| Use consistent MIME types between list() and read() | Return giant monolithic documents (>1000 lines) |
| Design content for LLM comprehension (structured Markdown) | Include secrets or credentials in resource content |
| Link related resources with “See Also” references | Use non-stable URIs that change across versions |
| Test resources with mcp-tester and integration tests | Assume agents will infer missing metadata |
1. URI Design: Stable and Hierarchical
#![allow(unused)] fn main() { // ✅ Good URI patterns "docs://policies/refunds" // Clear hierarchy "config://app/database.json" // Organized by category "data://products/trending.csv" // Descriptive path "template://email/welcome.html" // Type prefix // ❌ Bad URI patterns "resource1" // No structure "http://example.com/data" // External URL (use API tool instead) "C:\\Users\\data.json" // OS-specific path "temp_123" // Non-stable ID }
Principles:
- Stable: Never change URIs across versions
- Hierarchical: Use paths for organization
- Descriptive: Clear names (
refundsnotr1) - Scheme prefixes:
docs://,config://,data:// - No secrets: Don’t include API keys or tokens in URIs
Versioning Strategy:
Prefer stable URIs with evolving content over versioned URIs:
#![allow(unused)] fn main() { // ✅ Preferred: Stable URI, update content + modified_at AnnotatedResource { info: ResourceInfo { uri: "docs://ordering/policies".to_string(), // Stable URI name: "Ordering Policies".to_string(), description: Some( "[PRIORITY: HIGH] Updated on 2025-01-20 with new fraud rules.".to_string() ), mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 0.9, modified_at: "2025-01-20T10:00:00Z".to_string(), // Shows recency }, } // ⚠️ Only for breaking changes: Create new versioned URI AnnotatedResource { info: ResourceInfo { uri: "docs://ordering/policies/v2".to_string(), // New URI for breaking change name: "Ordering Policies (v2)".to_string(), description: Some( "[PRIORITY: CRITICAL] New 2025 policy framework. Replaces v1. \ Updated on 2025-01-20.".to_string() ), mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 1.0, // Highest priority for new policy modified_at: "2025-01-20T10:00:00Z".to_string(), }, } // Keep v1 available with deprecated flag (in description) AnnotatedResource { info: ResourceInfo { uri: "docs://ordering/policies/v1".to_string(), name: "Ordering Policies (v1 - Deprecated)".to_string(), description: Some( "[DEPRECATED] Use v2. Kept for historical reference. \ Last updated 2024-12-01.".to_string() ), mime_type: Some("text/markdown".to_string()), }, annotations: ResourceAnnotations { priority: 0.1, // Low priority (archived) modified_at: "2024-12-01T15:00:00Z".to_string(), }, } }
When to version URIs:
- ✅ Breaking changes: Structure or meaning fundamentally changed
- ✅ Regulatory compliance: Must preserve exact historical versions
- ✅ Migration periods: Run v1 and v2 simultaneously during transition
When NOT to version URIs:
- ❌ Minor updates: Clarifications, typos, additional examples
- ❌ Content refresh: Updated dates, new data, policy expansions
- ❌ Format changes: Markdown → HTML (use MIME type instead)
Best practice: Use modified_at and priority to signal importance. Agents see priority: 1.0, modified_at: today and know it’s critical and current.
2. MIME Types: Be Specific
#![allow(unused)] fn main() { // ✅ Specific MIME types "text/markdown" // For .md files "application/json" // For JSON data "text/csv" // For CSV data "text/html" // For HTML "image/png" // For PNG images "application/yaml" // For YAML configs // ⚠️ Generic fallback "text/plain" // When format is truly unknown }
Why it matters: Agents use MIME types to parse content correctly. JSON with text/plain won’t be parsed as JSON.
3. Descriptions: Context for Agents
#![allow(unused)] fn main() { // ❌ Too vague description: "Policy document" // ✅ Specific and actionable description: "Refund policy: 30-day window, $500 auto-approval limit. \ Use this to determine if refund requests require manager approval." }
Include:
- What: What information does this contain?
- When: When should agents read this?
- How: How should agents use this information?
4. Pagination: Handle Large Lists
#![allow(unused)] fn main() { const MAX_RESOURCES_PER_PAGE: usize = 100; async fn list( &self, cursor: Option<String>, _extra: RequestHandlerExtra, ) -> Result<ListResourcesResult> { let offset: usize = cursor .as_deref() .and_then(|c| c.parse().ok()) .unwrap_or(0); let resources = self.get_resources(offset, MAX_RESOURCES_PER_PAGE).await?; let next_cursor = if resources.len() == MAX_RESOURCES_PER_PAGE { Some((offset + MAX_RESOURCES_PER_PAGE).to_string()) } else { None }; Ok(ListResourcesResult { resources, next_cursor, }) } }
When to paginate:
- ✅ More than 100 resources
- ✅ Resources are expensive to fetch
- ❌ Small, static lists (<50 resources)
5. Error Messages: Guide the Agent
#![allow(unused)] fn main() { // ❌ Vague error Err(Error::protocol(ErrorCode::METHOD_NOT_FOUND, "Not found")) // ✅ Actionable error Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, format!( "Resource 'docs://policies/{}' not found. \ Available policies: refunds, shipping, returns. \ Example URI: docs://policies/refunds", unknown_policy ) )) }
Include:
- What was requested
- Why it failed
- Available alternatives
- Example of correct URI
6. Security: Validate Everything
#![allow(unused)] fn main() { async fn read( &self, uri: &str, _extra: RequestHandlerExtra, ) -> Result<ReadResourceResult> { // ❌ Path traversal vulnerability let path = uri.strip_prefix("file://").unwrap(); let content = std::fs::read_to_string(path)?; // DANGEROUS! // ✅ Safe path validation let path = uri .strip_prefix("file://") .ok_or_else(|| Error::validation("Invalid URI scheme"))?; // Validate path is within base directory let full_path = self.base_dir.join(path); if !full_path.starts_with(&self.base_dir) { return Err(Error::validation("Path traversal not allowed")); } // Validate path exists and is a file if !full_path.is_file() { return Err(Error::protocol( ErrorCode::METHOD_NOT_FOUND, "File not found or is a directory" )); } let content = tokio::fs::read_to_string(&full_path).await?; // ... safe to use } }
Security checklist:
- ✅ Validate URI schemes
- ✅ Prevent path traversal (../)
- ✅ Sanitize template variables
- ✅ Limit file sizes (prevent DoS)
- ✅ Restrict file types
- ✅ Never expose system paths in errors
7. LLM-Friendly Content Design (XU)
Design resource content for maximum agent comprehension and traversal:
Concise, Structured Markdown
# Refund Policy
## Quick Summary
- 30-day window from purchase date
- $500 auto-approval limit
- Manager approval required for higher amounts
## Eligibility
Items must be:
- In original packaging
- Unused/unopened
- With valid receipt
## Process
1. Customer submits refund request
2. Verify purchase date < 30 days
3. Check amount: < $500 → Auto-approve | > $500 → Escalate
4. Process refund within 3-5 business days
## See Also
- [Shipping Policy](docs://policies/shipping) - Return shipping costs
- [Exchange Policy](docs://policies/exchanges) - Alternative to refunds
- [Fraud Prevention](docs://policies/fraud) - Suspicious request handling
Why this works:
- ✅ Clear H1/H2 structure: LLMs parse hierarchy easily
- ✅ Bullet lists: Faster to scan than paragraphs
- ✅ Tables for enumerations: Status codes, pricing tiers, etc.
- ✅ Examples inline: Show don’t tell (amounts, dates, URIs)
- ✅ Stable headings: Consistent anchors for deep linking
- ✅ “See Also” links: Related resources by URI for context traversal
Stable Anchors for Deep Linking
Use consistent heading structure so clients can deep-link:
# Ordering API Reference
## Authentication
[... authentication details ...]
## Endpoints
### POST /orders
[... create order details ...]
### GET /orders/{id}
[... get order details ...]
## Error Codes
[... error reference ...]
Client can reference:
docs://api-reference#authentication- Direct link to auth sectiondocs://api-reference#post-orders- Direct link to specific endpoint
Consistency wins: Keep heading formats predictable across all resources.
Link Between Related Resources
Help agents traverse context by linking related URIs:
#![allow(unused)] fn main() { // Refund policy references related policies Content::Text { text: r#"# Refund Policy # See Also - [Shipping Policy](docs://policies/shipping) - Return shipping costs - [Exchange Policy](docs://policies/exchanges) - Alternative to refunds - [Customer Support](docs://support/contact) - Escalation paths - [SLA Terms](docs://legal/sla) - Refund processing timeframes # Process 1. Review [Eligibility Rules](docs://policies/refunds#eligibility) 2. Check [Amount Limits](docs://policies/refunds#amount-limits) 3. Follow [Approval Workflow](docs://workflows/refund-approval) "#.to_string() } }
Benefits:
- Agents can follow links to gather comprehensive context
- Reduces need for “ask user for more info” loops
- Creates knowledge graph of related policies/procedures
Small, Focused Resources
#![allow(unused)] fn main() { // ❌ Bad: One giant "policies.md" (10,000 lines) ResourceInfo { uri: "docs://policies".to_string(), name: "All Company Policies".to_string(), // Too large, hard to rank, slow to parse } // ✅ Good: Multiple focused resources vec![ ResourceInfo { uri: "docs://policies/refunds".to_string(), name: "Refund Policy".to_string(), // 50-200 lines, focused, fast to read }, ResourceInfo { uri: "docs://policies/shipping".to_string(), name: "Shipping Policy".to_string(), }, ResourceInfo { uri: "docs://policies/exchanges".to_string(), name: "Exchange Policy".to_string(), }, ] }
Why small resources win:
- ✅ Priority ranking works: Can mark refunds as 0.9, FAQ as 0.3
- ✅ Faster reads: Agents consume 200 lines faster than 10K lines
- ✅ Better caching: Clients can cache individual policies
- ✅ Clear responsibility: One topic per resource
- ✅ Easier maintenance: Update shipping without touching refunds
Size guidelines:
- 50-500 lines: Sweet spot for most documentation
- < 50 lines: Fine for quick reference (API keys, status codes)
- > 1000 lines: Consider splitting into sub-resources
Content Format Recommendations
For policies and procedures:
# [Policy Name]
## Quick Summary (3-5 bullet points)
- Key point 1
- Key point 2
## Detailed Rules
[Sections with H2/H3 hierarchy]
## Examples
[Concrete scenarios]
## See Also
[Related resource links]
For reference material (API docs, schemas):
# [API/Schema Name]
## Overview (1-2 sentences)
## Structure
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| ... | ... | ... | ... |
## Examples
```json
{...}
See Also
[Related APIs/schemas]
**For FAQ/troubleshooting:**
```markdown
# [Topic] FAQ
## Question 1?
Answer with example.
See [Policy](uri) for details.
## Question 2?
Answer with example.
See [Workflow](uri) for details.
Agent-friendly elements:
- Start with summary/overview
- Use tables for structured data
- Provide examples inline
- Link to authoritative resources
- Keep consistent formatting
Testing Resources
Unit Tests: Resource Handlers
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_list_resources() { let handler = DocumentationResources::new(); let result = handler.list(None, RequestHandlerExtra::default()).await; assert!(result.is_ok()); let list = result.unwrap(); assert!(!list.resources.is_empty()); assert_eq!(list.resources[0].uri, "docs://policies/refunds"); } #[tokio::test] async fn test_read_resource() { let handler = DocumentationResources::new(); let result = handler.read( "docs://policies/refunds", RequestHandlerExtra::default() ).await; assert!(result.is_ok()); let content = result.unwrap(); assert_eq!(content.contents.len(), 1); } #[tokio::test] async fn test_read_missing_resource() { let handler = DocumentationResources::new(); let result = handler.read( "docs://nonexistent", RequestHandlerExtra::default() ).await; assert!(result.is_err()); match result.unwrap_err() { Error::Protocol { code, .. } => { assert_eq!(code.as_i32(), ErrorCode::METHOD_NOT_FOUND.as_i32()); }, _ => panic!("Expected Protocol error"), } } } }
Integration Tests: Full Client-Server Flow
#![allow(unused)] fn main() { #[tokio::test] async fn test_resource_discovery_flow() { // Start server let server = Server::builder() .resources(DocumentationResources::new()) .build() .unwrap(); let transport = StdioTransport::new(); tokio::spawn(async move { server.run_stdio().await }); // Create client let mut client = Client::new(transport); client.initialize(ClientCapabilities::default()).await.unwrap(); // List resources let list = client.list_resources(None).await.unwrap(); assert!(!list.resources.is_empty()); // Read a resource let content = client.read_resource(list.resources[0].uri.clone()).await.unwrap(); assert!(!content.contents.is_empty()); } }
Testing with mcp-tester
# List all resources
mcp-tester test stdio --list-resources
# Read specific resource
mcp-tester test stdio --read-resource "docs://policies/refunds"
# Run scenario-based tests
mcp-tester scenario scenarios/resources_test.yaml --url stdio
Scenario file (scenarios/resources_test.yaml):
name: Resource Testing
steps:
- name: List resources
operation:
type: list_resources
assertions:
- type: success
- type: exists
path: resources
- type: count
path: resources
min: 1
- name: Read first resource
operation:
type: read_resource
uri: "${resources[0].uri}"
assertions:
- type: success
- type: exists
path: contents
- name: Test error handling
operation:
type: read_resource
uri: "docs://nonexistent"
assertions:
- type: error
code: -32601 # METHOD_NOT_FOUND
Summary
Resources are the documentation and reference material for agents. PMCP provides:
Core Features:
- ✅ Static resources (StaticResource, ResourceCollection)
- ✅ Dynamic resource handlers (ResourceHandler trait)
- ✅ URI templates (RFC 6570)
- ✅ File system watching (ResourceWatcher)
- ✅ Subscription & notifications
- ✅ Type-safe implementations
Best Practices:
- ✅ Stable URIs (never change)
- ✅ Specific MIME types
- ✅ Helpful descriptions
- ✅ Pagination for large lists
- ✅ Actionable error messages
- ✅ Security validation
Key Takeaways:
- Use static resources for fixed content (docs, configs, templates)
- Use dynamic handlers for database/API-backed content
- Use URI templates for parameterized resources
- Use ResourceWatcher for file system monitoring
- Provide clear metadata (name, description, MIME type)
- Validate all URIs to prevent security issues
Next chapters:
- Chapter 7: Prompts & Templates
- Chapter 8: Error Handling & Recovery
- Chapter 9: Integration Patterns
Resources + Tools + Prompts = complete MCP server. You now understand how to provide the context agents need to make informed decisions.
Chapter 7: Prompts — User-Triggered Workflows
Prompts are pre-defined workflows that users explicitly trigger from their MCP client. While tools let LLMs perform actions and resources provide reference data, prompts are user-controlled workflows that orchestrate tools and resources to accomplish complex tasks.
Think of prompts as your MCP server’s “quick actions”—common workflows that users can invoke with minimal input.
Quick Start: Your First Prompt (15 lines)
Let’s create a simple code review prompt:
use pmcp::{Server, SyncPrompt, types::{GetPromptResult, PromptMessage, Role, MessageContent}}; use std::collections::HashMap; #[tokio::main] async fn main() -> pmcp::Result<()> { let code_review = SyncPrompt::new("code-review", |args| { let code = args.get("code").and_then(|v| v.as_str()) .ok_or_else(|| pmcp::Error::validation("'code' required"))?; Ok(GetPromptResult { messages: vec![ PromptMessage { role: Role::System, content: MessageContent::Text { text: "You are an expert code reviewer. Provide constructive feedback.".to_string(), }, }, PromptMessage { role: Role::User, content: MessageContent::Text { text: format!("Please review this code:\n\n```\n{}\n```", code), }, }, ], description: Some("Code review prompt".to_string()), }) }) .with_description("Generate a code review prompt") .with_argument("code", "Code to review", true); Server::builder().prompt("code-review", code_review).build()?.run_stdio().await }
Test it:
# Start server
cargo run
# In another terminal:
mcp-tester test stdio --list-prompts
# Shows: code-review
mcp-tester test stdio --get-prompt "code-review" '{"code": "fn main() {}"}'
# Returns structured prompt messages
You’ve created, registered, and tested an MCP prompt! Now let’s understand how it works.
The Prompt Analogy: Website for Agents
Continuing the website analogy from Chapter 4, prompts are your “homepage CTAs” (calls-to-action) for agents.
| Website Element | MCP Prompt | Purpose |
|---|---|---|
| “Get Started” button | Simple prompt | Quick access to common workflow |
| Multi-step wizard | Workflow prompt | Guided multi-tool orchestration |
| Template forms | Prompt with arguments | Pre-filled workflows with user input |
| Help tooltips | Prompt descriptions | Explain what the prompt does |
| Form validation | Argument validation | Ensure user provides correct inputs |
Key insight: Prompts are user-triggered, not auto-executed by the LLM. They appear in the client UI for users to select explicitly.
Why Prompts Matter for LLMs
LLMs driving MCP clients benefit from prompts in several ways:
- User Intent Clarity: User selects “Generate weekly report” → LLM knows exact workflow
- Tool Orchestration: Prompt defines sequence (fetch data → calculate → format → save)
- Context Pre-loading: Prompt includes system instructions and resource references
- Argument Guidance: User provides structured inputs (date range, format, recipients)
- Consistent Results: Same prompt + same inputs = predictable workflow execution
Example workflow:
User action: Selects "Generate weekly report" prompt in Claude Desktop
↓
Client calls: prompts/get with arguments {start_date, end_date, format}
↓
Server returns: Structured messages with:
- System instructions (how to generate report)
- Resource references (templates, previous reports)
- Tool orchestration (which tools to call in what order)
↓
LLM executes: Follows instructions, calls tools, produces report
Without prompts, users would need to manually describe the entire workflow every time.
Prompt Anatomy: Step-by-Step
Every prompt follows this anatomy:
- Name + Description → What the prompt does
- Arguments → User inputs (required vs optional)
- Messages → Structured conversation (System, User, Assistant)
- Message Content Types → Text, Image, or Resource references
- Add to Server → Register and test
Let’s build a comprehensive blog post generator following this pattern.
Step 1: Name + Description
#![allow(unused)] fn main() { /// Prompt name: "blog-post" /// Description: "Generate a complete blog post on any topic. /// Includes title, introduction, main sections, and conclusion. /// Supports different writing styles and lengths." }
Naming best practices:
- Use kebab-case:
blog-post,weekly-report,code-review - Descriptive and action-oriented:
generate-blog-postnotblog - User-facing: Users see these names in UI dropdowns
Step 2: Arguments (with Defaults)
Define required and optional arguments:
#![allow(unused)] fn main() { use std::collections::HashMap; /// Arguments for blog post prompt /// /// The function receives args as HashMap<String, String> fn get_arguments(args: &HashMap<String, String>) -> pmcp::Result<(String, String, String)> { // Required argument let topic = args.get("topic") .ok_or_else(|| pmcp::Error::validation( "Missing required argument 'topic'. \ Example: {topic: 'Rust async programming'}" ))?; // Optional arguments with defaults let style = args.get("style") .map(|s| s.as_str()) .unwrap_or("professional"); let length = args.get("length") .map(|s| s.as_str()) .unwrap_or("medium"); // Validate style if !matches!(style, "professional" | "casual" | "technical") { return Err(pmcp::Error::validation(format!( "Invalid style '{}'. Must be: professional, casual, or technical", style ))); } // Validate length if !matches!(length, "short" | "medium" | "long") { return Err(pmcp::Error::validation(format!( "Invalid length '{}'. Must be: short (500w), medium (1000w), or long (2000w)", length ))); } Ok((topic.to_string(), style.to_string(), length.to_string())) } }
Argument patterns:
- ✅ Required: Must provide (e.g., topic, customer_id)
- ✅ Optional with defaults: Fallback if not provided (e.g., style, length)
- ✅ Validation: Check values before use
- ✅ Clear errors: Tell user exactly what’s wrong
Step 3: Messages (Structured Conversation)
Create a structured conversation with different roles:
#![allow(unused)] fn main() { use pmcp::types::{PromptMessage, Role, MessageContent}; fn build_messages(topic: &str, style: &str, length: &str) -> Vec<PromptMessage> { vec![ // System message: Instructions to the LLM PromptMessage { role: Role::System, content: MessageContent::Text { text: format!( "You are a professional blog post writer.\n\ \n\ TASK: Write a {} {} blog post about: {}\n\ \n\ WORKFLOW:\n\ 1. Create an engaging title\n\ 2. Write a compelling introduction\n\ 3. Develop 3-5 main sections with examples\n\ 4. Conclude with key takeaways\n\ \n\ STYLE GUIDE:\n\ - Professional: Formal tone, industry terminology\n\ - Casual: Conversational, relatable examples\n\ - Technical: Deep dives, code examples, references\n\ \n\ LENGTH TARGETS:\n\ - Short: ~500 words (quick overview)\n\ - Medium: ~1000 words (balanced coverage)\n\ - Long: ~2000 words (comprehensive guide)\n\ \n\ FORMAT: Use Markdown with proper headings (# ## ###)", length, style, topic ), }, }, // Assistant message: Provide context or resources PromptMessage { role: Role::Assistant, content: MessageContent::Resource { uri: format!("resource://blog/style-guide/{}", style), text: None, mime_type: Some("text/markdown".to_string()), }, }, // User message: The actual request PromptMessage { role: Role::User, content: MessageContent::Text { text: format!( "Please write a {} {} blog post about: {}", length, style, topic ), }, }, ] } }
Message roles explained:
- System: Instructions for LLM behavior (tone, format, workflow)
- Assistant: Context, resources, or examples
- User: The user’s actual request (with argument placeholders)
Why this structure works:
- Clear expectations: System message defines workflow steps
- Resource integration: Assistant provides style guides
- User intent: User message is concise and clear
Step 4: Message Content Types
PMCP supports three content types for messages:
Text Content (Most Common)
#![allow(unused)] fn main() { MessageContent::Text { text: "Your text here".to_string(), } }
Use for: Instructions, user requests, explanations
Image Content
#![allow(unused)] fn main() { MessageContent::Image { data: base64_encoded_image, // Vec<u8> base64-encoded mime_type: "image/png".to_string(), } }
Use for: Visual references, diagrams, screenshots, design mockups
Example:
#![allow(unused)] fn main() { let logo_bytes = include_bytes!("../assets/logo.png"); let logo_base64 = base64::encode(logo_bytes); PromptMessage { role: Role::Assistant, content: MessageContent::Image { data: logo_base64, mime_type: "image/png".to_string(), }, } }
Resource References
#![allow(unused)] fn main() { MessageContent::Resource { uri: "resource://app/documentation".to_string(), text: None, // Optional inline preview mime_type: Some("text/markdown".to_string()), } }
Use for: Documentation, configuration files, templates, policies
Why resource references are powerful:
- ❌ Bad: Embed 5000 lines of API docs in prompt text
- ✅ Good: Reference
resource://api/documentation— LLM fetches only if needed - Benefit: Smaller prompts, on-demand context loading
Step 5: Complete Prompt Implementation
Putting it all together with SyncPrompt:
#![allow(unused)] fn main() { use pmcp::{SyncPrompt, types::{GetPromptResult, PromptMessage, Role, MessageContent}}; use std::collections::HashMap; fn create_blog_post_prompt() -> SyncPrompt< impl Fn(HashMap<String, String>) -> pmcp::Result<GetPromptResult> + Send + Sync > { SyncPrompt::new("blog-post", |args| { // Step 1: Parse and validate arguments let topic = args.get("topic") .ok_or_else(|| pmcp::Error::validation("'topic' required"))?; let style = args.get("style").map(|s| s.as_str()).unwrap_or("professional"); let length = args.get("length").map(|s| s.as_str()).unwrap_or("medium"); // Validate values if !matches!(style, "professional" | "casual" | "technical") { return Err(pmcp::Error::validation(format!( "Invalid style '{}'. Use: professional, casual, or technical", style ))); } // Step 2: Build messages let messages = vec![ // System: Workflow instructions PromptMessage { role: Role::System, content: MessageContent::Text { text: format!( "You are a {} blog post writer. Write a {} post about: {}\n\ \n\ STRUCTURE:\n\ 1. Title (# heading)\n\ 2. Introduction (hook + overview)\n\ 3. Main sections (## headings)\n\ 4. Conclusion (key takeaways)\n\ \n\ LENGTH: {} (~{} words)", style, style, topic, length, match length { "short" => "500", "long" => "2000", _ => "1000", } ), }, }, // Assistant: Style guide resource PromptMessage { role: Role::Assistant, content: MessageContent::Resource { uri: format!("resource://blog/style-guide/{}", style), text: None, mime_type: Some("text/markdown".to_string()), }, }, // User: The request PromptMessage { role: Role::User, content: MessageContent::Text { text: format!("Write a {} {} blog post about: {}", length, style, topic), }, }, ]; Ok(GetPromptResult { messages, description: Some(format!("Generate {} blog post about {}", style, topic)), }) }) .with_description("Generate a complete blog post on any topic") .with_argument("topic", "The topic to write about", true) .with_argument("style", "Writing style: professional, casual, technical", false) .with_argument("length", "Post length: short, medium, long", false) } }
Key components:
- Closure captures arguments:
|args| { ... } - Validation: Check required fields and values
- Message construction: System → Assistant → User
- Metadata: Description helps users understand the prompt
- Argument definitions: Shown in discovery (
prompts/list)
Step 6: Add to Server
use pmcp::Server; #[tokio::main] async fn main() -> pmcp::Result<()> { let blog_prompt = create_blog_post_prompt(); let server = Server::builder() .name("content-server") .version("1.0.0") .prompt("blog-post", blog_prompt) // Add the tools this prompt might use: // .tool("search_resources", /* ... */) // .tool("generate_outline", /* ... */) .build()?; // Test with: mcp-tester test stdio --get-prompt "blog-post" '{"topic":"Rust"}' server.run_stdio().await }
Simple Text Prompts: Common Patterns
For most use cases, simple text-based prompts with SyncPrompt are sufficient.
Pattern 1: Code Review Prompt
#![allow(unused)] fn main() { use pmcp::{SyncPrompt, types::{GetPromptResult, PromptMessage, Role, MessageContent}}; use std::collections::HashMap; fn create_code_review_prompt() -> SyncPrompt< impl Fn(HashMap<String, String>) -> pmcp::Result<GetPromptResult> + Send + Sync > { SyncPrompt::new("code-review", |args| { let code = args.get("code") .ok_or_else(|| pmcp::Error::validation("'code' required"))?; let language = args.get("language") .map(|s| s.as_str()) .unwrap_or("unknown"); let focus = args.get("focus") .map(|s| s.as_str()) .unwrap_or("general"); Ok(GetPromptResult { messages: vec![ PromptMessage { role: Role::System, content: MessageContent::Text { text: format!( "You are an expert {} code reviewer. Focus on {} aspects.\n\ Provide constructive feedback with specific suggestions.", language, focus ), }, }, PromptMessage { role: Role::User, content: MessageContent::Text { text: format!("Review this {} code:\n\n```{}\n{}\n```", language, language, code), }, }, ], description: Some(format!("Code review for {} focusing on {}", language, focus)), }) }) .with_description("Generate a code review prompt") .with_argument("code", "Code to review", true) .with_argument("language", "Programming language", false) .with_argument("focus", "Focus area: performance, security, style", false) } }
Pattern 2: Documentation Generator
#![allow(unused)] fn main() { fn create_docs_prompt() -> SyncPrompt< impl Fn(HashMap<String, String>) -> pmcp::Result<GetPromptResult> + Send + Sync > { SyncPrompt::new("generate-docs", |args| { let code = args.get("code") .ok_or_else(|| pmcp::Error::validation("'code' required"))?; let format = args.get("format") .map(|s| s.as_str()) .unwrap_or("markdown"); if !matches!(format, "markdown" | "html" | "plaintext") { return Err(pmcp::Error::validation(format!( "Invalid format '{}'. Use: markdown, html, or plaintext", format ))); } Ok(GetPromptResult { messages: vec![ PromptMessage { role: Role::System, content: MessageContent::Text { text: format!( "Generate comprehensive documentation in {} format.\n\ \n\ Include:\n\ - Function/class descriptions\n\ - Parameter documentation\n\ - Return value descriptions\n\ - Usage examples\n\ - Edge cases and error handling", format ), }, }, PromptMessage { role: Role::User, content: MessageContent::Text { text: format!("Document this code:\n\n```\n{}\n```", code), }, }, ], description: Some("Generate code documentation".to_string()), }) }) .with_description("Generate documentation for code") .with_argument("code", "Code to document", true) .with_argument("format", "Output format: markdown, html, plaintext", false) } }
Pattern 3: Task Creation Prompt
#![allow(unused)] fn main() { fn create_task_prompt() -> SyncPrompt< impl Fn(HashMap<String, String>) -> pmcp::Result<GetPromptResult> + Send + Sync > { SyncPrompt::new("create-task", |args| { let title = args.get("title") .ok_or_else(|| pmcp::Error::validation("'title' required"))?; let project = args.get("project") .map(|s| s.as_str()) .unwrap_or("default"); let priority = args.get("priority") .map(|s| s.as_str()) .unwrap_or("normal"); Ok(GetPromptResult { messages: vec![ PromptMessage { role: Role::System, content: MessageContent::Text { text: format!( "Create a task in project '{}' with priority '{}'.\n\ \n\ Task format:\n\ - Title: Brief, actionable\n\ - Description: Clear context and requirements\n\ - Acceptance criteria: Measurable completion conditions\n\ - Labels: Relevant tags for categorization", project, priority ), }, }, // Reference project documentation PromptMessage { role: Role::Assistant, content: MessageContent::Resource { uri: format!("resource://projects/{}/guidelines", project), text: None, mime_type: Some("text/markdown".to_string()), }, }, PromptMessage { role: Role::User, content: MessageContent::Text { text: format!("Create a task: {}", title), }, }, ], description: Some(format!("Create task in {}", project)), }) }) .with_description("Create a new task in a project") .with_argument("title", "Task title", true) .with_argument("project", "Project name", false) .with_argument("priority", "Priority: low, normal, high", false) } }
Best Practices for Simple Prompts
1. Argument Validation: Fail Fast
#![allow(unused)] fn main() { // ❌ Bad: Silent defaults for invalid values let priority = args.get("priority") .map(|s| s.as_str()) .unwrap_or("normal"); // Silently accepts "urgnet" typo // ✅ Good: Validate and provide clear error let priority = args.get("priority") .map(|s| s.as_str()) .unwrap_or("normal"); if !matches!(priority, "low" | "normal" | "high") { return Err(pmcp::Error::validation(format!( "Invalid priority '{}'. Must be: low, normal, or high", priority ))); } }
2. System Messages: Be Specific
#![allow(unused)] fn main() { // ❌ Too vague "You are a helpful assistant." // ✅ Specific role and instructions "You are a senior software engineer specializing in code review.\n\ Focus on: security vulnerabilities, performance issues, and maintainability.\n\ Provide actionable feedback with specific file/line references.\n\ Use a constructive, educational tone." }
3. Resource References: Keep Prompts Lightweight
#![allow(unused)] fn main() { // ❌ Bad: Embed large policy doc in prompt PromptMessage { role: Role::Assistant, content: MessageContent::Text { text: five_thousand_line_policy_document, // Huge prompt! }, } // ✅ Good: Reference resource (LLM fetches if needed) PromptMessage { role: Role::Assistant, content: MessageContent::Resource { uri: "resource://policies/refund-policy".to_string(), text: None, mime_type: Some("text/markdown".to_string()), }, } }
4. Argument Descriptions: Guide Users
#![allow(unused)] fn main() { // ❌ Vague descriptions .with_argument("style", "The style", false) // ✅ Clear descriptions with examples .with_argument( "style", "Writing style (professional, casual, technical). Default: professional", false ) }
5. Optional Arguments: Document Defaults
#![allow(unused)] fn main() { fn create_prompt() -> SyncPrompt< impl Fn(HashMap<String, String>) -> pmcp::Result<GetPromptResult> + Send + Sync > { SyncPrompt::new("example", |args| { // Document defaults in code let format = args.get("format") .map(|s| s.as_str()) .unwrap_or("markdown"); // Default: markdown let verbosity = args.get("verbosity") .map(|s| s.as_str()) .unwrap_or("normal"); // Default: normal // ... use format and verbosity Ok(GetPromptResult { messages: vec![], description: None }) }) // Document defaults in argument descriptions .with_argument("format", "Output format (markdown, html). Default: markdown", false) .with_argument("verbosity", "Detail level (brief, normal, verbose). Default: normal", false) } }
AsyncPrompt vs SyncPrompt
Choose based on your handler’s needs:
SyncPrompt (Recommended for Most Cases)
For simple, CPU-bound prompt generation:
#![allow(unused)] fn main() { use pmcp::SyncPrompt; let prompt = SyncPrompt::new("simple", |args| { // Synchronous logic only let topic = args.get("topic").unwrap_or(&"default".to_string()); Ok(GetPromptResult { messages: vec![ PromptMessage { role: Role::System, content: MessageContent::Text { text: format!("Talk about {}", topic), }, }, ], description: None, }) }); }
SimplePrompt (Async)
For prompts that need async operations (database queries, API calls):
#![allow(unused)] fn main() { use pmcp::SimplePrompt; use std::pin::Pin; use std::future::Future; let prompt = SimplePrompt::new("async-example", Box::new( |args: HashMap<String, String>, _extra: pmcp::RequestHandlerExtra| { Box::pin(async move { // Can await async operations let data = fetch_from_database(&args["id"]).await?; let template = generate_messages(&data).await?; Ok(GetPromptResult { messages: template, description: Some("Generated from database".to_string()), }) }) as Pin<Box<dyn Future<Output = pmcp::Result<GetPromptResult>> + Send>> } )); }
When to use which:
- ✅ SyncPrompt: 95% of cases (simple message construction)
- ✅ SimplePrompt: Database lookups, API calls, file I/O
Listing Prompts
Users discover prompts via prompts/list:
{
"method": "prompts/list"
}
Response:
{
"prompts": [
{
"name": "code-review",
"description": "Generate a code review prompt",
"arguments": [
{"name": "code", "description": "Code to review", "required": true},
{"name": "language", "description": "Programming language", "required": false},
{"name": "focus", "description": "Focus area", "required": false}
]
},
{
"name": "blog-post",
"description": "Generate a complete blog post",
"arguments": [
{"name": "topic", "description": "Topic to write about", "required": true},
{"name": "style", "description": "Writing style", "required": false},
{"name": "length", "description": "Post length", "required": false}
]
}
]
}
When to Use Prompts
Use prompts when:
✅ Users need quick access to common workflows
- “Generate weekly report”
- “Create pull request description”
- “Review code focusing on security”
✅ Multiple tools must be orchestrated in a specific order
- Data analysis pipelines
- Content generation workflows
- Multi-step validation processes
✅ You want to guide LLM behavior for specific tasks
- “Write in executive summary style”
- “Focus on security vulnerabilities”
- “Generate tests for this function”
Don’t use prompts when:
❌ It’s just a single tool call
- Use tools directly instead
❌ The workflow is user-specific and can’t be templated
- Let the LLM figure it out from available tools
❌ The task changes based on dynamic runtime conditions
- Use tools with conditional logic instead
Advanced: Workflow-Based Prompts
For complex multi-tool orchestration with data flow between steps, PMCP provides a powerful workflow system. This advanced section demonstrates building sophisticated prompts that compose multiple tools.
When to use workflows:
- ✅ Multi-step processes with data dependencies
- ✅ Complex tool orchestration (step 2 uses output from step 1)
- ✅ Validated workflows with compile-time checks
- ✅ Reusable tool compositions
When NOT to use workflows:
- ❌ Simple single-message prompts (use
SyncPrompt) - ❌ One-off custom requests
- ❌ Highly dynamic workflows that can’t be templated
Workflow Anatomy: Quadratic Formula Solver
Let’s build a workflow that solves quadratic equations (ax² + bx + c = 0) step by step.
From examples/50_workflow_minimal.rs:
#![allow(unused)] fn main() { use pmcp::server::workflow::{ dsl::{constant, field, from_step, prompt_arg}, InternalPromptMessage, SequentialWorkflow, ToolHandle, WorkflowStep, }; use serde_json::json; fn create_quadratic_solver_workflow() -> SequentialWorkflow { SequentialWorkflow::new( "quadratic_solver", "Solve quadratic equations using the quadratic formula" ) // Define required prompt arguments .argument("a", "Coefficient a (x² term)", true) .argument("b", "Coefficient b (x term)", true) .argument("c", "Coefficient c (constant term)", true) // Add instruction messages .instruction(InternalPromptMessage::system( "Solve the quadratic equation ax² + bx + c = 0" )) // Step 1: Calculate discriminant (b² - 4ac) .step( WorkflowStep::new("calc_discriminant", ToolHandle::new("calculator")) .arg("operation", constant(json!("discriminant"))) .arg("a", prompt_arg("a")) .arg("b", prompt_arg("b")) .arg("c", prompt_arg("c")) .bind("discriminant") // ← Bind output as "discriminant" ) // Step 2: Calculate first root .step( WorkflowStep::new("calc_root1", ToolHandle::new("calculator")) .arg("operation", constant(json!("quadratic_root"))) .arg("a", prompt_arg("a")) .arg("b", prompt_arg("b")) .arg("discriminant_value", field("discriminant", "value")) // ← Reference binding .arg("sign", constant(json!("+"))) .bind("root1") // ← Bind output as "root1" ) // Step 3: Calculate second root .step( WorkflowStep::new("calc_root2", ToolHandle::new("calculator")) .arg("operation", constant(json!("quadratic_root"))) .arg("a", prompt_arg("a")) .arg("b", prompt_arg("b")) .arg("discriminant_value", field("discriminant", "value")) .arg("sign", constant(json!("-"))) .bind("root2") ) // Step 4: Format the solution .step( WorkflowStep::new("format_solution", ToolHandle::new("formatter")) .arg("discriminant_result", from_step("discriminant")) // ← Entire output .arg("root1_result", from_step("root1")) .arg("root2_result", from_step("root2")) .arg("format_template", constant(json!("Solution: x = {root1} or x = {root2}"))) .bind("formatted_solution") ) } }
Key concepts:
-
SequentialWorkflow: Defines a multi-step workflow
-
WorkflowStep: Individual steps that call tools
-
Bindings:
.bind("name")creates named outputs -
DSL helpers:
prompt_arg("a")- Reference workflow argumentfrom_step("discriminant")- Use entire output from previous stepfield("discriminant", "value")- Extract specific field from outputconstant(json!("value"))- Provide constant value
-
Data flow: Step 2 uses output from Step 1 via bindings
Workflow DSL: The Four Mapping Helpers
From examples/52_workflow_dsl_cookbook.rs:
#![allow(unused)] fn main() { WorkflowStep::new("step_name", ToolHandle::new("tool")) // 1. prompt_arg("arg_name") - Get value from workflow arguments .arg("input", prompt_arg("user_input")) // 2. constant(json!(...)) - Provide a constant value .arg("mode", constant(json!("auto"))) .arg("count", constant(json!(42))) // 3. from_step("binding") - Get entire output from previous step .arg("data", from_step("result1")) // 4. field("binding", "field") - Get specific field from output .arg("style", field("result1", "recommended_style")) .bind("result2") // ← Create binding for this step's output }
Important distinction:
- Step name (first arg): Identifies the step internally
- Binding name (via
.bind()): How other steps reference the output - ✅ Use binding names in
from_step()andfield() - ❌ Don’t use step names to reference outputs
Chaining Steps with Bindings
From examples/52_workflow_dsl_cookbook.rs:
#![allow(unused)] fn main() { SequentialWorkflow::new("content-pipeline", "Multi-step content creation") .argument("topic", "Topic to write about", true) .step( // Step 1: Create draft WorkflowStep::new("create_draft", ToolHandle::new("writer")) .arg("topic", prompt_arg("topic")) .arg("format", constant(json!("markdown"))) .bind("draft") // ← Bind as "draft" ) .step( // Step 2: Review draft (uses output from step 1) WorkflowStep::new("review_draft", ToolHandle::new("reviewer")) .arg("content", from_step("draft")) // ← Reference "draft" binding .arg("criteria", constant(json!(["grammar", "clarity"]))) .bind("review") // ← Bind as "review" ) .step( // Step 3: Revise (uses outputs from steps 1 & 2) WorkflowStep::new("revise_draft", ToolHandle::new("editor")) .arg("original", from_step("draft")) // ← Reference "draft" .arg("feedback", field("review", "suggestions")) // ← Extract field from "review" .bind("final") // ← Bind as "final" ) }
Pattern: Each step binds its output, allowing later steps to reference it.
Validation and Error Messages
Workflows are validated at build time. From examples/51_workflow_error_messages.rs:
Common errors:
- Unknown binding - Referencing a binding that doesn’t exist:
#![allow(unused)] fn main() { .step( WorkflowStep::new("create", ToolHandle::new("creator")) .bind("content") // ← Binds as "content" ) .step( WorkflowStep::new("review", ToolHandle::new("reviewer")) .arg("text", from_step("draft")) // ❌ ERROR: "draft" doesn't exist ) // Error: Unknown binding 'draft'. Available bindings: content // Fix: Change to from_step("content") }
- Undefined prompt argument - Using an undeclared argument:
#![allow(unused)] fn main() { SequentialWorkflow::new("workflow", "...") .argument("topic", "The topic", true) // Missing: .argument("style", ...) .step( WorkflowStep::new("create", ToolHandle::new("creator")) .arg("topic", prompt_arg("topic")) .arg("style", prompt_arg("writing_style")) // ❌ ERROR: not declared ) // Error: Undefined prompt argument 'writing_style' // Fix: Add .argument("writing_style", "Writing style", false) }
- Step without binding cannot be referenced:
#![allow(unused)] fn main() { .step( WorkflowStep::new("create", ToolHandle::new("creator")) .arg("topic", prompt_arg("topic")) // ❌ Missing: .bind("content") ) .step( WorkflowStep::new("review", ToolHandle::new("reviewer")) .arg("text", from_step("create")) // ❌ ERROR: "create" has no binding ) // Error: Step 'create' has no binding. Add .bind("name") to reference it. // Fix: Add .bind("content") to first step }
Best practice: Call .validate() early to catch errors:
#![allow(unused)] fn main() { let workflow = create_my_workflow(); match workflow.validate() { Ok(()) => println!("✅ Workflow is valid"), Err(e) => { eprintln!("❌ Validation failed: {}", e); // Error messages are actionable - they tell you exactly what's wrong } } }
Understanding MCP Client Autonomy
Critical insight: MCP clients (LLMs like Claude) are autonomous agents that make their own decisions. When you return a prompt with instructions, the LLM is free to:
- ✅ Follow your instructions exactly
- ❌ Ignore your instructions entirely
- 🔀 Modify the workflow to suit its understanding
- 🌐 Call tools on other MCP servers instead of yours
- 🤔 Decide your workflow isn’t appropriate and do something else
This is not a bug—it’s the design of MCP. Clients have agency.
Example: Instruction-Only Prompt (Low Compliance)
#![allow(unused)] fn main() { // Traditional approach: Just return instructions PromptMessage { role: Role::System, content: MessageContent::Text { text: "Follow these steps: 1. Call list_pages to get all pages 2. Find the best matching page for the project name 3. Call add_journal_task with the formatted task" } } }
What actually happens:
- LLM might call different tools
- LLM might skip steps it thinks are unnecessary
- LLM might use tools from other MCP servers
- LLM might reorder steps based on its reasoning
- Compliance probability: ~60-70% (LLM decides independently)
Server-Side Execution: Improving Workflow Compliance
PMCP’s hybrid execution model dramatically improves the probability that clients complete your workflow as designed by:
- Executing deterministic steps server-side (can’t be skipped)
- Providing complete context (tool results + resources)
- Offering clear guidance for remaining steps
- Reducing client decision space (fewer choices = higher compliance)
From examples/54_hybrid_workflow_execution.rs:
The Hybrid Execution Model
When a workflow prompt is invoked via prompts/get, the server:
- Executes tools server-side for steps with resolved parameters
- Fetches and embeds resources to provide context
- Returns conversation trace showing what was done
- Hands off to client with guidance for remaining steps
Result: Server has already completed deterministic steps. Client receives:
- ✅ Actual tool results (not instructions to call tools)
- ✅ Resource content (documentation, schemas, examples)
- ✅ Clear guidance for what remains
- ✅ Reduced decision space (fewer ways to go wrong)
Compliance improvement: ~85-95% (server did the work, client just continues)
Hybrid Execution Example: Logseq Task Creation
From examples/54_hybrid_workflow_execution.rs:
#![allow(unused)] fn main() { use pmcp::server::workflow::{ SequentialWorkflow, WorkflowStep, ToolHandle, DataSource, }; fn create_task_workflow() -> SequentialWorkflow { SequentialWorkflow::new( "add_project_task", "add a task to a Logseq project with intelligent page matching" ) .argument("project", "Project name (can be fuzzy match)", true) .argument("task", "Task description", true) // Step 1: Server executes (deterministic - no parameters needed) .step( WorkflowStep::new("list_pages", ToolHandle::new("list_pages")) .with_guidance("I'll first get all available page names from Logseq") .bind("pages") ) // Step 2: Client continues (needs LLM reasoning for fuzzy matching) .step( WorkflowStep::new("add_task", ToolHandle::new("add_journal_task")) .with_guidance( "I'll now:\n\ 1. Find the page name from the list above that best matches '{project}'\n\ 2. Format the task as: [[matched-page-name]] {task}\n\ 3. Call add_journal_task with the formatted task" ) .with_resource("docs://logseq/task-format") .expect("Valid resource URI") // No .arg() mappings - server can't resolve params (needs fuzzy match) .bind("result") ) } }
Server execution flow:
User invokes: prompts/get with {project: "MCP Tester", task: "Fix bug"}
↓
Server: Creates user intent message
Server: Creates assistant plan message
↓
Server: Executes Step 1 (list_pages)
→ Guidance: "I'll first get all available page names"
→ Calls list_pages tool
→ Result: {"page_names": ["mcp-tester", "MCP Rust SDK", "Test Page"]}
→ Stores in binding "pages"
↓
Server: Attempts Step 2 (add_task)
→ Guidance: "Find the page name... that matches 'MCP Tester'"
→ Fetches resource: docs://logseq/task-format
→ Embeds content: "Task Format Guide: Use [[page-name]]..."
→ Checks params: Missing (needs fuzzy match - can't resolve deterministically)
→ STOPS (graceful handoff)
↓
Server: Returns conversation trace to client
Conversation trace returned to client:
Message 1 (User):
"I want to add a task to a Logseq project with intelligent page matching.
Parameters:
- project: "MCP Tester"
- task: "Fix bug"
Message 2 (Assistant):
"Here's my plan:
1. list_pages - List all available pages
2. add_journal_task - Add a task to a journal"
Message 3 (Assistant): [Guidance for step 1]
"I'll first get all available page names from Logseq"
Message 4 (Assistant): [Tool call announcement]
"Calling tool 'list_pages' with parameters: {}"
Message 5 (User): [Tool result - ACTUAL DATA]
"Tool result:
{"page_names": ["mcp-tester", "MCP Rust SDK", "Test Page"]}"
Message 6 (Assistant): [Guidance for step 2 - with argument substitution]
"I'll now:
1. Find the page name from the list above that best matches 'MCP Tester'
2. Format the task as: [[matched-page-name]] Fix bug
3. Call add_journal_task with the formatted task"
Message 7 (User): [Resource content - DOCUMENTATION]
"Resource content from docs://logseq/task-format:
Task Format Guide:
- Use [[page-name]] for links
- Add TASK prefix for action items
- Use TODAY for current date"
[Server stops - hands off to client with complete context]
Client LLM receives:
- ✅ Page list (actual data, not instruction to fetch it)
- ✅ Clear 3-step guidance (what to do next)
- ✅ Task format documentation (how to format)
- ✅ User’s original intent (project + task)
Probability client completes correctly: ~90%
The client:
- Can’t skip step 1 (server already did it)
- Has exact data to work with (page list)
- Has clear instructions (3 steps)
- Has documentation (format guide)
- Has fewer decisions to make (just fuzzy match + format + call)
Workflow Methods for Hybrid Execution
.with_guidance(text) - Assistant message explaining what this step should do
#![allow(unused)] fn main() { .step( WorkflowStep::new("match", ToolHandle::new("add_task")) .with_guidance( "Find the page matching '{project}' in the list above. \ If no exact match, use fuzzy matching for the closest name." ) .bind("result") ) }
Features:
- Rendered as assistant message in conversation trace
- Supports
{arg_name}substitution (replaced with actual argument values) - Shown even if server successfully executes the step
- Critical for graceful handoff when server can’t resolve parameters
.with_resource(uri) - Fetches resource and embeds content as user message
#![allow(unused)] fn main() { .step( WorkflowStep::new("add_task", ToolHandle::new("add_journal_task")) .with_guidance("Format the task according to the guide") .with_resource("docs://logseq/task-format") .expect("Valid resource URI") .with_resource("docs://logseq/examples") .expect("Valid resource URI") .arg("task", DataSource::prompt_arg("task")) ) }
Features:
- Server fetches resource during workflow execution
- Content embedded as user message before step execution
- Multiple resources supported (call
.with_resource()multiple times) - Provides context for client LLM decision-making
- Reduces hallucination (client has actual docs, not assumptions)
When Server Executes vs Hands Off
Server executes step completely if:
- ✅ All required tool parameters can be resolved from:
- Prompt arguments (via
prompt_arg("name")) - Previous step bindings (via
from_step("binding")orfield("binding", "field")) - Constants (via
constant(json!(...)))
- Prompt arguments (via
- ✅ Tool schema’s required fields are satisfied
- ✅ No errors during tool execution
Server stops gracefully (hands off to client) if:
- ❌ Tool requires parameters not available deterministically
- ❌ LLM reasoning needed (fuzzy matching, context interpretation, decisions)
- ❌ Parameters can’t be resolved from available sources
On graceful handoff, server includes:
- All guidance messages (what to do next)
- All resource content (documentation, schemas, examples)
- All previous tool results (via bindings in conversation trace)
- Clear state of what was completed vs what remains
Why This Improves Compliance
Traditional prompt-only approach:
Prompt: "1. Call list_pages, 2. Match project, 3. Call add_task"
↓
Client decides: Should I follow this? Let me think...
- Maybe I should search first?
- Maybe the user wants something else?
- What if I use a different tool?
- Should I call another server?
↓
Compliance: ~60-70% (high variance)
Hybrid execution approach:
Prompt execution returns:
- Step 1 DONE (here's the actual page list)
- Step 2 guidance (match from THIS list)
- Resource content (here's the format docs)
↓
Client sees: Half the work is done, I just need to:
1. Match "MCP Tester" to one of: ["mcp-tester", "MCP Rust SDK", "Test Page"]
2. Format using the provided guide
3. Call add_journal_task
↓
Compliance: ~85-95% (low variance)
Key improvements:
- ✅ Reduced decision space: Client has fewer choices
- ✅ Concrete data: Actual tool results, not instructions
- ✅ Clear next steps: Guidance is specific to current state
- ✅ Documentation provided: No need to guess formatting
- ✅ Partial completion: Can’t skip server-executed steps
- ✅ Lower cognitive load: Less for LLM to figure out
Argument Substitution in Guidance
Guidance supports {arg_name} placeholders that are replaced with actual argument values:
#![allow(unused)] fn main() { .step( WorkflowStep::new("process", ToolHandle::new("processor")) .with_guidance( "Process the user's request for '{topic}' in '{style}' style. \ Use the examples from the resource to match the tone." ) .with_resource("docs://style-guides/{style}") .expect("Valid URI") ) }
At runtime with {topic: "Rust async", style: "casual"}:
Guidance rendered as:
"Process the user's request for 'Rust async' in 'casual' style.
Use the examples from the resource to match the tone."
Resource URI becomes:
"docs://style-guides/casual"
Benefits:
- Guidance is specific to user’s input
- Client sees exact values it should work with
- Reduces ambiguity (not “the topic” but “Rust async”)
Registering Workflows as Prompts
Use .prompt_workflow() to register and validate workflows. When invoked via prompts/get, the workflow executes server-side and returns a conversation trace:
use pmcp::Server; #[tokio::main] async fn main() -> pmcp::Result<()> { let workflow = create_task_workflow(); let server = Server::builder() .name("logseq-server") .version("1.0.0") // Register tools that the workflow uses .tool("list_pages", list_pages_tool) .tool("add_journal_task", add_task_tool) // Register resources for .with_resource() to fetch .resources(LogseqDocsHandler) // Register workflow as prompt (validates automatically) .prompt_workflow(workflow)? .build()?; server.run_stdio().await }
What happens when user invokes the prompt:
-
Registration time (
.prompt_workflow()):- Validates workflow (bindings, arguments, tool references exist)
- Registers as prompt (discoverable via
prompts/list) - Returns error if validation fails
-
Invocation time (
prompts/get):- User calls with arguments:
{project: "MCP Tester", task: "Fix bug"} - Server executes workflow steps with resolved parameters
- Server calls tools, fetches resources, builds conversation trace
- Server stops when parameters can’t be resolved (graceful handoff)
- Server returns conversation trace (not just instructions)
- User calls with arguments:
-
Client receives:
- User intent message (what user wants)
- Assistant plan message (workflow steps)
- Tool execution results (actual data from server-side calls)
- Resource content (embedded documentation)
- Guidance messages (what to do next)
- Complete context to continue or review
Key insight: The workflow is executed, not just described. Client receives results, not instructions.
Integration with Typed Tools
From examples/53_typed_tools_workflow_integration.rs:
Workflows integrate seamlessly with typed tools:
use pmcp::Server; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; // Typed tool input #[derive(Debug, Deserialize, Serialize, JsonSchema)] struct AnalyzeCodeInput { code: String, language: String, depth: u8, } async fn analyze_code(input: AnalyzeCodeInput, _extra: RequestHandlerExtra) -> Result<Value> { // Implementation Ok(json!({ "analysis": "...", "issues_found": 3 })) } // Workflow that uses the typed tool fn create_code_review_workflow() -> SequentialWorkflow { SequentialWorkflow::new("code_review", "Review code comprehensively") .argument("code", "Source code", true) .argument("language", "Programming language", false) .step( WorkflowStep::new("analyze", ToolHandle::new("analyze_code")) .arg("code", prompt_arg("code")) .arg("language", prompt_arg("language")) .arg("depth", constant(json!(2))) .bind("analysis") ) // ... more steps } #[tokio::main] async fn main() -> pmcp::Result<()> { Server::builder() .name("code-server") .version("1.0.0") // Register typed tool (automatic schema generation) .tool_typed("analyze_code", analyze_code) // Register workflow that references the tool .prompt_workflow(create_code_review_workflow())? .build()? .run_stdio() .await }
Benefits:
- ✅ Type-safe tool inputs (compile-time checked)
- ✅ Automatic JSON schema generation
- ✅ Workflow validates tool references exist
- ✅ Single source of truth for tool definitions
Workflow Best Practices
- Use descriptive binding names:
#![allow(unused)] fn main() { // ❌ Bad: Unclear .bind("r1") .bind("out") // ✅ Good: Clear purpose .bind("analysis_result") .bind("formatted_output") }
- Declare all arguments before using:
#![allow(unused)] fn main() { SequentialWorkflow::new("workflow", "...") // ✅ Declare all arguments first .argument("topic", "Topic", true) .argument("style", "Style", false) .argument("length", "Length", false) // Then use them in steps .step(...) }
- Add
.bind()only when output is needed:
#![allow(unused)] fn main() { .step( WorkflowStep::new("log", ToolHandle::new("logger")) .arg("message", from_step("result")) // No .bind() - logging is a side-effect, output not needed ) }
- Use
field()to extract specific data:
#![allow(unused)] fn main() { // ❌ Bad: Pass entire large object .arg("data", from_step("analysis")) // Entire analysis result // ✅ Good: Extract only what's needed .arg("summary", field("analysis", "summary")) .arg("score", field("analysis", "confidence_score")) }
- Validate workflows early:
#![allow(unused)] fn main() { let workflow = create_my_workflow(); workflow.validate()?; // ← Catch errors before registration }
- Use guidance for steps requiring LLM reasoning:
#![allow(unused)] fn main() { // ✅ Good: Clear guidance for non-deterministic steps .step( WorkflowStep::new("match", ToolHandle::new("add_task")) .with_guidance( "Find the best matching page from the list above. \ Consider: exact matches > fuzzy matches > semantic similarity." ) // No .arg() mappings - server will hand off to client ) // ❌ Bad: No guidance for complex reasoning step .step( WorkflowStep::new("match", ToolHandle::new("add_task")) // Client has to guess what to do ) }
- Embed resources for context-heavy steps:
#![allow(unused)] fn main() { // ✅ Good: Provide documentation for formatting/styling .step( WorkflowStep::new("format", ToolHandle::new("formatter")) .with_guidance("Format according to the style guide") .with_resource("docs://formatting/style-guide") .expect("Valid URI") .with_resource("docs://formatting/examples") .expect("Valid URI") ) // ❌ Bad: Expect LLM to know complex formatting rules .step( WorkflowStep::new("format", ToolHandle::new("formatter")) .with_guidance("Format the output properly") // No resources - LLM will hallucinate formatting rules ) }
- Design for hybrid execution - maximize server-side work:
#![allow(unused)] fn main() { // ✅ Good: Server does deterministic work, client does reasoning .step( WorkflowStep::new("fetch_data", ToolHandle::new("database_query")) .arg("query", constant(json!("SELECT * FROM pages"))) .bind("all_pages") // ← Server executes this ) .step( WorkflowStep::new("select_page", ToolHandle::new("update_page")) .with_guidance("Choose the most relevant page from the list") // ← Client does reasoning with server-provided data ) // ❌ Bad: Client has to do all the work .step( WorkflowStep::new("do_everything", ToolHandle::new("complex_tool")) .with_guidance( "1. Query the database for pages\n\ 2. Filter by relevance\n\ 3. Select the best match\n\ 4. Update the page" ) // Server does nothing - just instructions ) }
When to Use Workflows vs Simple Prompts
| Feature | Simple Prompt (SyncPrompt) | Workflow (SequentialWorkflow) |
|---|---|---|
| Use case | Single-message prompts | Multi-step tool orchestration |
| Execution | Returns instructions only | Executes tools server-side |
| Complexity | Simple | Moderate to complex |
| Tool composition | LLM decides | Pre-defined sequence |
| Data flow | None | Explicit bindings |
| Validation | Argument checks | Full workflow validation |
| Compliance | ~60-70% (LLM decides) | ~85-95% (server guides) |
| Resource embedding | Manual references | Automatic fetch & embed |
| Examples | Code review, blog post generation | Logseq task creation, data pipelines |
Decision guide:
- ✅ Use simple prompts for: One-shot requests, LLM-driven tool selection, no tool execution needed
- ✅ Use workflows for: Multi-step processes, high compliance requirements, data dependencies, hybrid execution
Testing Prompts
Unit Testing Simple Prompts
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_code_review_prompt() { let prompt = create_code_review_prompt(); let mut args = HashMap::new(); args.insert("code".to_string(), "fn test() {}".to_string()); args.insert("language".to_string(), "rust".to_string()); let result = prompt.handle(args, RequestHandlerExtra::default()).await; assert!(result.is_ok()); let prompt_result = result.unwrap(); assert_eq!(prompt_result.messages.len(), 2); assert!(matches!(prompt_result.messages[0].role, Role::System)); assert!(matches!(prompt_result.messages[1].role, Role::User)); } #[tokio::test] async fn test_missing_required_argument() { let prompt = create_code_review_prompt(); let args = HashMap::new(); // Missing "code" let result = prompt.handle(args, RequestHandlerExtra::default()).await; assert!(result.is_err()); } } }
Testing Workflows
#![allow(unused)] fn main() { #[test] fn test_workflow_validation() { let workflow = create_quadratic_solver_workflow(); // Workflow should validate successfully assert!(workflow.validate().is_ok()); // Check arguments assert_eq!(workflow.arguments().len(), 3); assert!(workflow.arguments().contains_key(&"a".into())); // Check steps assert_eq!(workflow.steps().len(), 4); // Check bindings let bindings = workflow.output_bindings(); assert!(bindings.contains(&"discriminant".into())); assert!(bindings.contains(&"root1".into())); } }
Integration Testing with mcp-tester
# List all prompts
mcp-tester test stdio --list-prompts
# Get specific prompt
mcp-tester test stdio --get-prompt "code-review" '{"code": "fn main() {}", "language": "rust"}'
# Get workflow prompt
mcp-tester test stdio --get-prompt "quadratic_solver" '{"a": 1, "b": -3, "c": 2}'
Summary
Prompts are user-triggered workflows that orchestrate tools and resources. PMCP provides two approaches:
Simple Prompts (SyncPrompt):
- ✅ Quick message templates with arguments
- ✅ Minimal boilerplate
- ✅ Perfect for single-message prompts
- ✅ Returns instructions for LLM to follow (~60-70% compliance)
- ✅ User provides inputs, LLM decides tool usage and execution order
Workflow Prompts (SequentialWorkflow):
- ✅ Multi-step tool orchestration with server-side execution
- ✅ Executes deterministic steps during
prompts/get - ✅ Returns conversation trace (tool results + resources + guidance)
- ✅ Hybrid execution: server does work, client continues with context
- ✅ Explicit data flow with bindings
- ✅ Compile-time validation
- ✅ High compliance (~85-95% - server guides client)
- ✅ Automatic resource fetching and embedding
Understanding MCP Client Autonomy:
- MCP clients (LLMs) are autonomous agents - they can follow, ignore, or modify your instructions
- They can call tools on other MCP servers instead of yours
- Traditional instruction-only prompts have ~60-70% compliance
- Hybrid execution with server-side tool execution + resources + guidance improves compliance to ~85-95%
- Server does deterministic work, reducing client decision space and increasing predictability
Key takeaways:
- Start with
SyncPromptfor simple instruction-only prompts - Use workflows when you need high compliance and multi-step orchestration
- Design workflows for hybrid execution: server executes what it can, client continues with guidance
- Use
.with_guidance()for steps requiring LLM reasoning - Use
.with_resource()to embed documentation and reduce hallucination - Validate arguments thoroughly and workflows early
- Test with
mcp-testerand unit tests - Remember: Higher server-side execution = higher client compliance
Next chapters:
- Chapter 8: Error Handling & Recovery
- Chapter 9: Integration Patterns
Prompts + Tools + Resources = complete MCP server. You now understand how to provide user-triggered workflows that make your server easy and efficient to use.
Error Handling
Error handling is one of Rust’s superpowers, and PMCP leverages this strength to provide robust, predictable error management for your MCP applications. This chapter introduces error handling concepts (even if you’re new to Rust) and shows you how to build resilient MCP applications.
Why Rust’s Error Handling is Different (and Better)
If you’re coming from languages like JavaScript, Python, or Java, Rust’s approach to errors might feel different at first—but once you understand it, you’ll appreciate its power.
No Surprises: Errors You Can See
In many languages, errors are invisible in function signatures:
// JavaScript - can this throw? Who knows!
function processData(input) {
return JSON.parse(input); // Might throw, might not
}
In Rust, errors are explicit and visible:
#![allow(unused)] fn main() { // Rust - the Result<T, E> tells you this can fail fn process_data(input: &str) -> Result<Value, Error> { serde_json::from_str(input) // Returns Result - you must handle it } }
The Result<T, E> type means:
Ok(T)- Success with value of typeTErr(E)- Failure with error of typeE
Pattern Matching: Elegant Error Handling
Rust’s match statement makes error handling explicit and exhaustive:
#![allow(unused)] fn main() { use tracing::{info, error}; match client.call_tool("calculator", args).await { Ok(result) => { info!("Success! Result: {}", result.content); } Err(err) => { error!("Failed: {}", err); // Handle the error appropriately } } }
The compiler forces you to handle both cases—no forgotten error checks!
The ? Operator: Concise Error Propagation
For quick error propagation, Rust provides the ? operator:
#![allow(unused)] fn main() { async fn fetch_and_process() -> Result<Value, Error> { let result = client.call_tool("fetch_data", args).await?; // Propagates errors let processed = process_result(&result)?; // Continues if Ok Ok(processed) } }
The ? operator automatically:
- Returns the error if the operation failed
- Unwraps the success value if it succeeded
- Converts between compatible error types
This is much cleaner than nested error checking in other languages!
PMCP Error Types
PMCP provides a comprehensive error system aligned with the MCP protocol and JSON-RPC 2.0 specification.
Core Error Categories
#![allow(unused)] fn main() { use pmcp::error::{Error, ErrorCode, TransportError}; // Protocol errors (JSON-RPC 2.0 standard codes) let parse_error = Error::parse("Invalid JSON structure"); let invalid_request = Error::protocol( ErrorCode::INVALID_REQUEST, "Request missing required field 'method'".to_string() ); let method_not_found = Error::method_not_found("tools/unknown"); let invalid_params = Error::invalid_params("Count must be positive"); let internal_error = Error::internal("Database connection failed"); // Validation errors (business logic, not protocol-level) let validation_error = Error::validation("Email format is invalid"); // Transport errors (network, connection issues) let timeout = Error::timeout(30_000); // 30 second timeout let transport_error = Error::Transport(TransportError::Request("connection timeout".into())); // Resource errors let not_found = Error::not_found("User with ID 123 not found"); // Rate limiting (predefined error code) let rate_limit = Error::protocol( ErrorCode::RATE_LIMITED, "Rate limit exceeded: retry after 60s".to_string() ); // Custom protocol errors let custom_error = Error::protocol( ErrorCode::other(-32099), // Application-defined codes "Custom application error".to_string() ); }
Error Code Reference
PMCP follows JSON-RPC 2.0 error codes with MCP-specific extensions:
| Code | Constant | When to Use |
|---|---|---|
| -32700 | PARSE_ERROR | Invalid JSON received |
| -32600 | INVALID_REQUEST | Request structure is wrong |
| -32601 | METHOD_NOT_FOUND | Unknown method/tool name |
| -32602 | INVALID_PARAMS | Parameter validation failed |
| -32603 | INTERNAL_ERROR | Server-side failure |
MCP-Specific Error Codes:
| Code | Constant | When to Use |
|---|---|---|
| -32001 | REQUEST_TIMEOUT | Request exceeded timeout |
| -32002 | UNSUPPORTED_CAPABILITY | Feature not supported |
| -32003 | AUTHENTICATION_REQUIRED | Auth needed |
| -32004 | PERMISSION_DENIED | User lacks permission |
| -32005 | RATE_LIMITED | Rate limit exceeded |
| -32006 | CIRCUIT_BREAKER_OPEN | Circuit breaker tripped |
Application-Defined Codes:
- -32000 to -32099: Use
ErrorCode::other(code)for custom application errors
Creating Meaningful Errors
Good error messages help users understand and fix problems:
#![allow(unused)] fn main() { // ❌ Bad: Vague error Err(Error::validation("Invalid input")) // ✅ Good: Specific, actionable error Err(Error::validation( "Parameter 'email' must be a valid email address. Got: 'not-an-email'" )) // ✅ Better: Include context and suggestions Err(Error::invalid_params(format!( "Parameter 'count' must be between 1 and 100. Got: {}. \ Reduce the count or use pagination.", count ))) }
Practical Error Handling Patterns
Let’s explore real-world error handling patterns you’ll use in PMCP applications.
Pattern 1: Graceful Degradation with Fallbacks
When a primary operation fails, try a simpler fallback:
#![allow(unused)] fn main() { use tracing::warn; // Try advanced feature, fall back to basic version let result = match client.call_tool("advanced_search", args).await { Ok(result) => result, Err(e) => { warn!("Advanced search failed: {}. Trying basic search...", e); // Fallback to basic search client.call_tool("basic_search", args).await? } }; }
Pattern 2: Retry with Exponential Backoff
For transient failures (network issues, temporary unavailability), retry with increasing delays:
#![allow(unused)] fn main() { use pmcp::error::{Error, TransportError}; use tokio::time::{sleep, Duration}; use futures::future::BoxFuture; use tracing::warn; async fn retry_with_backoff<F, T>( mut operation: F, max_retries: u32, initial_delay: Duration, ) -> Result<T, Error> where F: FnMut() -> BoxFuture<'static, Result<T, Error>>, { let mut delay = initial_delay; for attempt in 0..=max_retries { match operation().await { Ok(result) => return Ok(result), Err(e) => { // Check if error is retryable by matching on variants let is_retryable = matches!( e, Error::Timeout(_) | Error::RateLimited | Error::Transport(TransportError::ConnectionClosed) | Error::Transport(TransportError::Io(_)) | Error::Transport(TransportError::Request(_)) ); if !is_retryable || attempt == max_retries { return Err(e); } warn!("Attempt {} failed: {}. Retrying in {:?}...", attempt + 1, e, delay); sleep(delay).await; delay *= 2; // Exponential backoff } } } Err(Error::internal("All retry attempts failed")) } // Usage let result = retry_with_backoff( || Box::pin(client.call_tool("unstable_api", args)), 3, // max_retries Duration::from_millis(500), // initial_delay ).await?; }
Why exponential backoff?
- Prevents overwhelming a struggling server
- Gives transient issues time to resolve
- Reduces network congestion
Important: Only retry idempotent operations (reads, GETs, safe queries). For non-idempotent operations (writes, POSTs, state changes), retrying may cause duplicate actions. Consider using:
- Request IDs to detect duplicates
- Conditional operations (e.g., “only if version matches”)
- Separate retry logic for reads vs. writes
Pattern 3: Circuit Breaker
Stop trying operations that consistently fail to prevent resource waste:
#![allow(unused)] fn main() { use std::sync::atomic::{AtomicU32, Ordering}; use tokio::sync::Mutex; use std::future::Future; use tracing::error; struct CircuitBreaker { failures: AtomicU32, failure_threshold: u32, state: Mutex<CircuitState>, } enum CircuitState { Closed, // Normal operation Open, // Failing - reject requests HalfOpen, // Testing if service recovered } impl CircuitBreaker { async fn call<F, T>(&self, operation: F) -> Result<T, Error> where F: Future<Output = Result<T, Error>>, { // Check circuit state let state = self.state.lock().await; if matches!(*state, CircuitState::Open) { // Use typed error internally; convert to protocol error at API boundary return Err(Error::CircuitBreakerOpen); } drop(state); // Execute operation match operation.await { Ok(result) => { // Success - reset failures self.failures.store(0, Ordering::SeqCst); Ok(result) } Err(e) => { // Increment failures let failures = self.failures.fetch_add(1, Ordering::SeqCst); if failures >= self.failure_threshold { let mut state = self.state.lock().await; *state = CircuitState::Open; error!("Circuit breaker opened after {} failures", failures); } Err(e) } } } } }
Note: This example uses Error::CircuitBreakerOpen internally. When constructing JSON-RPC responses at API boundaries, you can also use Error::protocol(ErrorCode::CIRCUIT_BREAKER_OPEN, "...") to include additional metadata.
When to use circuit breakers:
- Calling external services that might be down
- Database operations that might fail
- Rate-limited APIs
- Any operation that could cascade failures
Pattern 4: Timeout Protection
Prevent operations from hanging indefinitely:
#![allow(unused)] fn main() { use tokio::time::{timeout, Duration}; use pmcp::error::Error; use tracing::{info, error}; async fn call_with_timeout(client: &Client, args: Value) -> Result<CallToolResult, Error> { // Set a timeout for any async operation match timeout( Duration::from_secs(30), client.call_tool("slow_operation", args) ).await { Ok(Ok(result)) => { info!("Success: {:?}", result); Ok(result) } Ok(Err(e)) => { error!("Operation failed: {}", e); Err(e) } Err(_) => { // Convert elapsed timeout to PMCP error Err(Error::timeout(30_000)) // 30,000 milliseconds } } } }
Pattern 5: Batch Error Aggregation
When processing multiple operations, collect both successes and failures:
#![allow(unused)] fn main() { use tracing::{info, error}; let operations = vec![ ("task1", task1_args), ("task2", task2_args), ("task3", task3_args), ]; let mut successes = Vec::new(); let mut failures = Vec::new(); for (name, args) in operations { match client.call_tool("processor", args).await { Ok(result) => successes.push((name, result)), Err(err) => failures.push((name, err)), } } // Report results info!("Completed: {}/{}", successes.len(), successes.len() + failures.len()); if !failures.is_empty() { error!("Failed operations:"); for (name, err) in &failures { error!(" - {}: {}", name, err); } } // Continue with successful results for (name, result) in successes { process_result(name, result).await?; } }
Input Validation and Error Messages
Proper validation prevents errors and provides clear feedback when they occur.
Validation Best Practices
#![allow(unused)] fn main() { use async_trait::async_trait; use serde_json::{json, Value}; use pmcp::{ToolHandler, RequestHandlerExtra}; use pmcp::error::Error; #[async_trait] impl ToolHandler for ValidatorTool { async fn handle(&self, arguments: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> { // 1. Check required fields exist let input = arguments .get("input") .ok_or_else(|| Error::invalid_params( "Missing required parameter 'input'" ))? .as_str() .ok_or_else(|| Error::invalid_params( "Parameter 'input' must be a string" ))?; // 2. Validate input constraints if input.len() < 5 { return Err(Error::validation( format!("Input must be at least 5 characters. Got: {} chars", input.len()) )); } if !input.chars().all(|c| c.is_alphanumeric()) { return Err(Error::validation( "Input must contain only alphanumeric characters" )); } // 3. Business logic validation if is_blacklisted(input) { return Err(Error::validation( format!("Input '{}' is not allowed by policy", input) )); } // All validations passed Ok(json!({ "status": "validated", "input": input })) } } }
Progressive Validation
Validate in order from cheapest to most expensive:
#![allow(unused)] fn main() { use pmcp::error::Error; async fn validate_and_process(data: &str) -> Result<ProcessedData, Error> { // 1. Fast: Check syntax (no I/O) if !is_valid_syntax(data) { return Err(Error::validation("Invalid syntax")); } // 2. Medium: Check against local rules (minimal I/O) if !passes_local_checks(data) { return Err(Error::validation("Failed local validation")); } // 3. Slow: Check against external service (network I/O) if !check_with_service(data).await? { return Err(Error::validation("Failed external validation")); } // 4. Process (expensive operation) process_data(data).await } }
Error Recovery Strategies
Different errors require different recovery strategies.
Decision Tree for Error Handling
Is the error retryable?
├─ Yes (timeout, network, temporary)
│ ├─ Retry with exponential backoff
│ └─ If retries exhausted → Try fallback
│
└─ No (validation, permission, not found)
├─ Can we use cached/default data?
│ ├─ Yes → Use fallback data
│ └─ No → Propagate error to user
│
└─ Log error details for debugging
Example: Comprehensive Error Strategy
#![allow(unused)] fn main() { use pmcp::error::{Error, ErrorCode}; use tokio::time::Duration; use tracing::warn; async fn fetch_user_data(user_id: &str) -> Result<UserData, Error> { // Try primary source with retries let primary_result = retry_with_backoff( || Box::pin(api_client.get_user(user_id)), 3, // max_retries Duration::from_secs(1), // initial_delay ).await; match primary_result { Ok(data) => Ok(data), Err(e) => { warn!("Primary API failed: {}", e); // Check error type using pattern matching match e { // Network/transport errors - try cache Error::Transport(_) | Error::Timeout(_) => { warn!("Network error, checking cache..."); cache.get_user(user_id).ok_or_else(|| { Error::internal("Primary API down and no cached data") }) } // Not found - use proper error type Error::Protocol { code, .. } if code == ErrorCode::METHOD_NOT_FOUND => { Err(Error::not_found(format!("User {} not found", user_id))) } // Rate limited - propagate with suggestion Error::RateLimited => { Err(Error::protocol( ErrorCode::RATE_LIMITED, "API rate limit exceeded. Please retry later.".to_string() )) } // Other errors - propagate _ => Err(e), } } } } }
Running the Example
The 12_error_handling.rs example demonstrates all these patterns:
cargo run --example 12_error_handling
This example shows:
- Different Error Types - Parse, validation, internal, rate limiting
- Input Validation - Length checks, character validation
- Retry Logic - Exponential backoff for transient failures
- Timeout Handling - Preventing hung operations
- Recovery Strategies - Fallback and circuit breaker patterns
- Batch Operations - Error aggregation and success rate tracking
Error Handling Checklist
When implementing error handling in your PMCP application:
-
Use appropriate error types - Choose the right
ErrorCodefor the situation - Provide clear messages - Include context, got vs. expected, suggestions
- Validate inputs early - Fail fast with meaningful feedback
- Handle transient failures - Implement retries with backoff
- Set timeouts - Prevent operations from hanging
- Log errors properly - Include context for debugging
- Test error paths - Don’t just test the happy path
- Document error behavior - Tell users what errors they might see
Library vs Application Error Handling
Libraries (creating reusable MCP tools/servers):
- Use PMCP’s typed
Errorenum for all public APIs - Avoid
Error::Other(anyhow::Error)in library interfaces - Provide specific error types that callers can match on
- Use
thiserrorfor custom error types if needed
Applications (building MCP clients/servers):
- Can use
anyhow::Result<T>for internal error context - Convert to PMCP errors at API boundaries
Error::Other(anyhow::Error)is acceptable for application-level errors
// Library - typed errors pub async fn fetch_resource(uri: &str) -> Result<Resource, Error> { // Use specific PMCP error types if !is_valid_uri(uri) { return Err(Error::invalid_params("Invalid URI format")); } // ... } // Application - anyhow for context #[tokio::main] async fn main() -> anyhow::Result<()> { let resource = fetch_resource("file:///data.json") .await .context("Failed to fetch configuration")?; Ok(()) }
Error Boundary Mapping
When building MCP applications, errors flow through different layers. Use the right error type at each layer:
Internal Layer (typed variants):
#![allow(unused)] fn main() { // Use typed error variants internally for pattern matching if is_rate_limited() { return Err(Error::RateLimited); } if circuit_open { return Err(Error::CircuitBreakerOpen); } if elapsed > timeout { return Err(Error::timeout(timeout_ms)); } }
API Boundary (protocol errors):
#![allow(unused)] fn main() { // Convert to protocol errors at JSON-RPC boundaries match internal_operation().await { Ok(result) => result, Err(Error::RateLimited) => { // Add metadata when constructing JSON-RPC responses return Err(Error::protocol( ErrorCode::RATE_LIMITED, json!({ "message": "Rate limit exceeded", "retry_after": 60, "limit": 100 }).to_string() )); } Err(e) => return Err(e), } }
Use Error::error_code() to extract the error code when needed:
#![allow(unused)] fn main() { if let Some(code) = error.error_code() { match code { ErrorCode::RATE_LIMITED => { /* handle rate limit */ } ErrorCode::TIMEOUT => { /* handle timeout */ } _ => { /* handle others */ } } } }
Security Considerations
⚠️ Never leak sensitive information in error messages:
#![allow(unused)] fn main() { // ❌ Bad: Exposes sensitive data Err(Error::validation(format!("Invalid API key: {}", api_key))) // ✅ Good: Generic message Err(Error::protocol( ErrorCode::AUTHENTICATION_REQUIRED, "Invalid authentication credentials".to_string() )) // ❌ Bad: Exposes internal paths Err(Error::internal(format!("Failed to read /etc/secrets/db.conf: {}", e))) // ✅ Good: Sanitized message Err(Error::internal("Failed to read configuration file".to_string())) // ❌ Bad: Reveals user existence (timing attack) if !user_exists(username) { return Err(Error::not_found("User not found")); } if !password_valid(username, password) { return Err(Error::validation("Invalid password")); } // ✅ Good: Constant-time response if !authenticate(username, password) { // Same error for both cases return Err(Error::protocol( ErrorCode::AUTHENTICATION_REQUIRED, "Invalid username or password".to_string() )); } }
Security checklist:
- No secrets, tokens, or API keys in error messages
- No internal file paths or system information
- No database query details or schema information
- No user enumeration (same error for “not found” vs “wrong password”)
- No stack traces in production error responses
- Sanitize all user input before including in errors
Best Practices Summary
✅ Use pattern matching - Match error variants instead of parsing strings
✅ Use tracing - Prefer warn!/error! over println!/eprintln!
✅ Use specific errors - Error::not_found for missing resources, not Error::validation
✅ Add imports - Make code snippets self-contained and copy-pasteable
✅ Avoid double-logging - Log at boundaries, not at every layer
✅ Use constants - ErrorCode::METHOD_NOT_FOUND not magic numbers
✅ Map at boundaries - Use typed errors internally, protocol errors at API boundaries
✅ Protect secrets - Never expose sensitive data in error messages
✅ Retry wisely - Only retry idempotent operations
Key Takeaways
- Rust makes errors visible -
Result<T, E>shows what can fail - Pattern matching is powerful - Handle all cases exhaustively
- The
?operator is your friend - Concise error propagation - PMCP provides rich error types - Aligned with MCP and JSON-RPC standards
- Different errors need different strategies - Retry, fallback, fail fast
- Clear error messages help users - Be specific and actionable
- Test your error handling - Errors are part of your API
- Match on variants, not strings - Type-safe error classification
With Rust’s error handling and PMCP’s comprehensive error types, you can build MCP applications that are robust, predictable, and provide excellent user experience even when things go wrong.
Further Reading
- Rust Book: Error Handling
- PMCP Error API Documentation
- JSON-RPC 2.0 Specification
- Example:
examples/12_error_handling.rs
Authentication and Security
Authentication in MCP is about trust and accountability. When an MCP server exposes tools that access user data, modify resources, or perform privileged operations, it must act on behalf of an authenticated user and enforce their permissions. This chapter shows you how to build secure MCP servers using industry-standard OAuth 2.0 and OpenID Connect (OIDC).
The Philosophy: Don’t Reinvent the Wheel
MCP servers should behave like web servers - they validate access tokens sent with requests and enforce authorization policies. They should not implement custom authentication flows.
Why This Matters
Security is hard. Every custom authentication system introduces risk:
- Password storage vulnerabilities
- Token generation weaknesses
- Session management bugs
- Timing attack vulnerabilities
- Missing rate limiting
- Inadequate audit logging
Your organization already solved this. Most organizations have:
- Single Sign-On (SSO) systems
- OAuth 2.0 / OIDC providers (Auth0, Okta, Keycloak, Azure AD)
- Established user directories (LDAP, Active Directory)
- Proven authorization policies
- Security auditing and compliance
MCP should integrate, not duplicate. Instead of building a new authentication system, MCP servers should:
- Receive access tokens from MCP clients
- Validate tokens against your existing OAuth provider
- Extract user identity from validated tokens
- Enforce permissions based on scopes and roles
- Act on behalf of the user with their privileges
This approach provides:
- ✅ Centralized user management
- ✅ Consistent security policies across all applications
- ✅ Audit trails showing which user performed which action
- ✅ SSO integration (login once, access all MCP servers)
- ✅ Industry-standard security reviewed by experts
How MCP Authentication Works
MCP uses the same pattern as securing REST APIs:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ │ │ │ │ │
│ MCP Client │ │ MCP Server │ │ OAuth │
│ │ │ │ │ Provider │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. User authenticates │ │
├──────────────────────────────────────────────────────────────>│
│ (OAuth flow happens externally) │
│ │ │
│<─────────────────────────────────────────────────────────────┤
│ 2. Receive access token │ │
│ │ │
│ 3. Call tool with token │ │
├──────────────────────────────>│ │
│ Authorization: Bearer <token>│ │
│ │ │
│ │ 4. Validate token │
│ ├──────────────────────────────>│
│ │ │
│ │<──────────────────────────────┤
│ │ 5. Token valid + user info │
│ │ │
│ 6. Result (as authenticated │ │
│<─────────────────────────────┤│ │
│ user) │ │
Key principles:
- OAuth flow is external - The client handles authorization code flow, token exchange, and refresh
- MCP server validates tokens - Server checks tokens against OAuth provider for each request
- User context is propagated - Tools know which user is calling them
- Permissions are enforced - Scopes and roles control what each user can do
OAuth 2.0 and OIDC Primer
Before diving into code, let’s understand the key concepts.
OAuth 2.0: Delegated Authorization
OAuth 2.0 lets users grant applications limited access to their resources without sharing passwords.
Key terms:
- Resource Owner: The user who owns the data
- Client: The application (MCP client) requesting access
- Authorization Server: Issues tokens after authenticating the user (e.g., Auth0, Okta)
- Resource Server: Protects user resources, validates tokens (your MCP server)
- Access Token: Short-lived token proving authorization (typically JWT)
- Refresh Token: Long-lived token used to get new access tokens
- Scope: Permission level (e.g., “read:data”, “write:data”, “admin”)
OpenID Connect (OIDC): Identity Layer
OIDC extends OAuth 2.0 to provide user identity information.
Additional concepts:
- ID Token: JWT containing user identity claims (name, email, etc.)
- UserInfo Endpoint: Returns additional user profile information
- Discovery:
.well-known/openid-configurationendpoint for provider metadata
Authorization Code Flow (Recommended)
This is the most secure flow for MCP clients:
-
Client redirects user to authorization endpoint
https://auth.example.com/authorize? response_type=code& client_id=mcp-client-123& redirect_uri=http://localhost:3000/callback& scope=openid profile read:tools write:tools& state=random-state-value -
User authenticates and grants permission
-
Authorization server redirects back with code
http://localhost:3000/callback?code=auth_code_xyz&state=random-state-value -
Client exchanges code for tokens (backend, not browser)
POST https://auth.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=auth_code_xyz& client_id=mcp-client-123& client_secret=client_secret_abc& redirect_uri=http://localhost:3000/callback -
Receive tokens
{ "access_token": "eyJhbGc...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "refresh_xyz...", "scope": "openid profile read:tools write:tools" } -
Client uses access token in MCP requests
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "read_data", "arguments": {"key": "user-123"} }, "_meta": { "authorization": { "type": "bearer", "token": "eyJhbGc..." } } }
Important: Steps 1-5 happen outside the MCP server. The MCP server only sees step 6 (requests with tokens).
Best practice for public clients: Use Authorization Code + PKCE (Proof Key for Code Exchange) instead of implicit flow. PKCE prevents authorization code interception attacks even without client secrets.
Building a Secure MCP Server
Let’s build an MCP server that validates OAuth tokens and enforces permissions.
Step 1: Configure OAuth Provider Integration
PMCP provides built-in OAuth provider integration:
#![allow(unused)] fn main() { use pmcp::server::auth::{InMemoryOAuthProvider, OAuthClient, OAuthProvider, GrantType, ResponseType}; use std::sync::Arc; use std::collections::HashMap; // Create OAuth provider (points to your real OAuth server) let oauth_provider = Arc::new( InMemoryOAuthProvider::new("https://auth.example.com") ); // Register your MCP client application let client = OAuthClient { client_id: "mcp-client-123".to_string(), client_secret: Some("your-client-secret".to_string()), client_name: "My MCP Client".to_string(), redirect_uris: vec!["http://localhost:3000/callback".to_string()], grant_types: vec![ GrantType::AuthorizationCode, GrantType::RefreshToken, ], response_types: vec![ResponseType::Code], scopes: vec![ "openid".to_string(), "profile".to_string(), "read:tools".to_string(), "write:tools".to_string(), "admin".to_string(), ], metadata: HashMap::new(), }; let registered_client = oauth_provider.register_client(client).await?; }
Note: InMemoryOAuthProvider is for development. In production, integrate with your real OAuth provider:
- Auth0: Use Auth0 Management API
- Okta: Use Okta API
- Keycloak: Use Keycloak Admin API
- Azure AD: Use Microsoft Graph API
- Custom: Implement
OAuthProvidertrait
Step 2: Create Authentication Middleware
Middleware validates tokens and extracts user context:
#![allow(unused)] fn main() { use pmcp::server::auth::middleware::{AuthMiddleware, BearerTokenMiddleware, ScopeMiddleware}; use std::sync::Arc; // Scope middleware - enforces required scopes // Create fresh instances for each middleware (BearerTokenMiddleware doesn't implement Clone) let read_middleware = Arc::new(ScopeMiddleware::any( Box::new(BearerTokenMiddleware::new(oauth_provider.clone())), vec!["read:tools".to_string()], )); let write_middleware = Arc::new(ScopeMiddleware::all( Box::new(BearerTokenMiddleware::new(oauth_provider.clone())), vec!["write:tools".to_string()], )); let admin_middleware = Arc::new(ScopeMiddleware::all( Box::new(BearerTokenMiddleware::new(oauth_provider.clone())), vec!["admin".to_string()], )); }
Scope enforcement:
ScopeMiddleware::any()- Requires at least one of the specified scopesScopeMiddleware::all()- Requires all of the specified scopes
Step 3: Protect Tools with Authentication
Tools check authentication via middleware:
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::{ToolHandler, RequestHandlerExtra}; use pmcp::error::{Error, ErrorCode}; use serde_json::{json, Value}; use tracing::info; use chrono::Utc; /// Public tool - no authentication required struct GetServerTimeTool; #[async_trait] impl ToolHandler for GetServerTimeTool { async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> { Ok(json!({ "time": Utc::now().to_rfc3339(), "timezone": "UTC" })) } } /// Protected tool - requires authentication and 'read:tools' scope struct ReadUserDataTool { auth_middleware: Arc<dyn AuthMiddleware>, } #[async_trait] impl ToolHandler for ReadUserDataTool { async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> { // Authenticate the request let auth_context = self.auth_middleware .authenticate(extra.auth_info.as_ref()) .await?; info!("User {} accessing read_user_data", auth_context.subject); // Extract parameters let user_id = args.get("user_id") .and_then(|v| v.as_str()) .ok_or_else(|| Error::invalid_params("Missing 'user_id'"))?; // Authorization check: users can only read their own data if auth_context.subject != user_id && !auth_context.has_scope("admin") { return Err(Error::protocol( ErrorCode::PERMISSION_DENIED, format!("User {} cannot access data for user {}", auth_context.subject, user_id) )); } // Fetch data (as the authenticated user) let data = fetch_user_data(user_id).await?; Ok(json!({ "user_id": user_id, "data": data, "accessed_by": auth_context.subject, "scopes": auth_context.scopes })) } } }
Security features demonstrated:
- Authentication - Validates token and extracts user identity
- Authorization - Checks if user has permission for this specific action
- Audit logging - Records who accessed what
- Scope validation - Ensures required permissions are present
Note: Authentication happens once at the request boundary. The RequestHandlerExtra.auth_context is populated by middleware and passed to tools, avoiding re-authentication for each tool call and reducing logging noise.
Step 4: Build and Run the Server
#![allow(unused)] fn main() { use pmcp::server::Server; use pmcp::types::capabilities::ServerCapabilities; let server = Server::builder() .name("secure-mcp-server") .version("1.0.0") .capabilities(ServerCapabilities { tools: Some(Default::default()), ..Default::default() }) // Public tool - no auth .tool("get_server_time", GetServerTimeTool) // Protected tools - require auth + scopes .tool("read_user_data", ReadUserDataTool { auth_middleware: read_middleware, }) .tool("write_user_data", WriteUserDataTool { auth_middleware: write_middleware, }) .tool("admin_operation", AdminOperationTool { auth_middleware: admin_middleware, }) .build()?; // Run server server.run_stdio().await?; }
OIDC Discovery
OIDC providers expose a discovery endpoint that provides all the metadata your client needs:
#![allow(unused)] fn main() { use pmcp::client::auth::OidcDiscoveryClient; use std::time::Duration; use tracing::info; // Discover provider configuration let discovery_client = OidcDiscoveryClient::with_settings( 5, // max retries Duration::from_secs(1), // retry delay ); let metadata = discovery_client .discover("https://auth.example.com") .await?; info!("Authorization endpoint: {}", metadata.authorization_endpoint); info!("Token endpoint: {}", metadata.token_endpoint); info!("Supported scopes: {:?}", metadata.scopes_supported); }
What you get from discovery:
issuer- Provider’s identifierauthorization_endpoint- Where to redirect users for logintoken_endpoint- Where to exchange codes for tokensjwks_uri- Public keys for validating JWT signaturesuserinfo_endpoint- Where to fetch user profile datascopes_supported- Available permission scopesgrant_types_supported- Supported OAuth flowstoken_endpoint_auth_methods_supported- Client authentication methods
This eliminates hardcoded URLs and ensures compatibility with provider changes.
Token Validation Best Practices
Proper token validation is critical for security.
Validate JWT Tokens
If using JWT access tokens, validate:
#![allow(unused)] fn main() { use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm}; use std::collections::HashSet; use pmcp::error::Error; async fn validate_jwt_token(token: &str, jwks_uri: &str) -> Result<TokenClaims, Error> { // 1. Fetch JWKS (public keys) from provider (cache with TTL based on Cache-Control) let jwks = fetch_jwks_cached(jwks_uri).await?; // 2. Decode header to get key ID let header = jsonwebtoken::decode_header(token)?; let kid = header.kid.ok_or_else(|| Error::validation("Missing kid in JWT header"))?; // 3. Enforce expected algorithm (reject "none" and unexpected algs) if header.alg != Algorithm::RS256 { return Err(Error::validation(format!("Unsupported algorithm: {:?}", header.alg))); } // 4. Find matching key (refresh JWKS on miss for key rotation) let key = match jwks.find_key(&kid) { Some(k) => k, None => { // Refresh JWKS in case of key rotation let fresh_jwks = fetch_jwks(jwks_uri).await?; fresh_jwks.find_key(&kid) .ok_or_else(|| Error::validation(format!("Unknown signing key: {}", kid)))? } }; // 5. Validate signature and claims let mut validation = Validation::new(Algorithm::RS256); validation.validate_exp = true; // Check expiration validation.validate_nbf = true; // Check "not before" validation.leeway = 60; // Allow 60s clock skew tolerance validation.set_audience(&["mcp-server"]); // Validate audience validation.set_issuer(&["https://auth.example.com"]); // Validate issuer let token_data = decode::<TokenClaims>( token, &DecodingKey::from_rsa_components(&key.n, &key.e)?, &validation, )?; Ok(token_data.claims) } }
Critical validations:
- ✅ Algorithm - Enforce expected algorithm (RS256), reject “none” or unexpected algs
- ✅ Signature - Verify token was issued by trusted provider
- ✅ Expiration (
exp) - Reject expired tokens (with clock skew tolerance) - ✅ Not before (
nbf) - Reject tokens used too early (with clock skew tolerance) - ✅ Issuer (
iss) - Verify token is from expected provider - ✅ Audience (
aud) - Verify token is intended for this server - ✅ Scope - Check required permissions are present
- ✅ Key rotation - Refresh JWKS on key ID miss, cache keys respecting Cache-Control
JWT vs Opaque Tokens:
- JWT tokens - Validate locally using provider’s JWKs (public keys). No provider call needed per request after JWKS fetch.
- Opaque tokens - Use introspection endpoint, requires provider call per validation (use caching).
Token Introspection
For opaque (non-JWT) tokens, use introspection:
#![allow(unused)] fn main() { use pmcp::error::{Error, ErrorCode}; async fn introspect_token(token: &str, introspection_endpoint: &str) -> Result<IntrospectionResponse, Error> { let client = reqwest::Client::new(); let response = client .post(introspection_endpoint) .basic_auth("client-id", Some("client-secret")) .form(&[("token", token)]) .send() .await? .json::<IntrospectionResponse>() .await?; if !response.active { return Err(Error::protocol( ErrorCode::AUTHENTICATION_REQUIRED, "Token is not active".to_string() )); } Ok(response) } }
Cache Validation Results
Token validation can be expensive (network calls, crypto operations). Cache validated tokens:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::time::{Duration, Instant}; use tokio::sync::RwLock; struct TokenCache { cache: RwLock<HashMap<String, (TokenClaims, Instant)>>, ttl: Duration, jwks_uri: String, } impl TokenCache { async fn get_or_validate(&self, token: &str) -> Result<TokenClaims, Error> { // Check cache { let cache = self.cache.read().await; if let Some((claims, cached_at)) = cache.get(token) { if cached_at.elapsed() < self.ttl { return Ok(claims.clone()); } } } // Validate and cache let claims = validate_jwt_token(token, &self.jwks_uri).await?; let mut cache = self.cache.write().await; cache.insert(token.to_string(), (claims.clone(), Instant::now())); Ok(claims) } } }
Cache considerations:
- ✅ Use short TTL (e.g., 5 minutes) to limit exposure of revoked tokens
- ✅ Clear cache on server restart
- ✅ Consider using Redis for distributed caching
- ❌ Don’t cache expired tokens
- ❌ Don’t cache tokens with critical operations
Authorization Patterns
Authentication (who you are) is different from authorization (what you can do).
Pattern 1: Scope-Based Authorization
Scopes define coarse-grained permissions:
#![allow(unused)] fn main() { use serde_json::json; async fn handle_request(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> { let auth_ctx = self.auth_middleware .authenticate(extra.auth_info.as_ref()) .await?; // Check scopes using has_scope() method if !auth_ctx.has_scope("write:data") { return Err(Error::protocol( ErrorCode::PERMISSION_DENIED, "Missing required scope: write:data".to_string() )); } // Proceed with operation Ok(json!({"status": "authorized"})) } }
Pattern 2: Role-Based Access Control (RBAC)
Roles group permissions:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] enum Role { User, Manager, Admin, } impl Role { fn from_claims(claims: &TokenClaims) -> Vec<Role> { claims.roles .iter() .filter_map(|r| match r.as_str() { "user" => Some(Role::User), "manager" => Some(Role::Manager), "admin" => Some(Role::Admin), _ => None, }) .collect() } fn can_delete_users(&self) -> bool { matches!(self, Role::Admin) } fn can_approve_requests(&self) -> bool { matches!(self, Role::Manager | Role::Admin) } } // In handler let roles = Role::from_claims(&auth_ctx.claims); if !roles.iter().any(|r| r.can_approve_requests()) { return Err(Error::protocol( ErrorCode::PERMISSION_DENIED, "Requires Manager or Admin role".to_string() )); } }
Pattern 3: Attribute-Based Access Control (ABAC)
Fine-grained, context-aware permissions:
#![allow(unused)] fn main() { async fn can_access_resource( user_id: &str, resource_id: &str, operation: &str, context: &RequestContext, ) -> Result<bool, Error> { // Check resource ownership let resource = fetch_resource(resource_id).await?; if resource.owner_id == user_id { return Ok(true); // Owners can do anything } // Check sharing permissions if resource.is_shared_with(user_id) { let permissions = resource.get_user_permissions(user_id); if permissions.contains(&operation.to_string()) { return Ok(true); } } // Check organization membership if context.organization_id == resource.organization_id { let org_role = get_org_role(user_id, &context.organization_id).await?; if org_role.can_perform(operation) { return Ok(true); } } Ok(false) } }
Pattern 4: Least Privilege Principle
Always grant minimum necessary permissions:
#![allow(unused)] fn main() { // ❌ Bad: Overly permissive if auth_ctx.has_scope("admin") { // Admin can do anything perform_operation(&args).await?; } // ✅ Good: Specific permission checks match operation { "read" => { require_scope(&auth_ctx, "read:data")?; read_data(&args).await? } "write" => { require_scope(&auth_ctx, "write:data")?; write_data(&args).await? } "delete" => { require_scope(&auth_ctx, "delete:data")?; require_ownership(&auth_ctx, &resource)?; delete_data(&args).await? } _ => return Err(Error::invalid_params("Unknown operation")), } }
Security Best Practices
1. Use HTTPS in Production
Always use TLS/HTTPS for:
- OAuth authorization endpoints
- Token endpoints
- MCP server endpoints
- Any endpoint transmitting tokens
#![allow(unused)] fn main() { // ❌ NEVER in production let oauth_provider = InMemoryOAuthProvider::new("http://auth.example.com"); // ✅ Always use HTTPS let oauth_provider = InMemoryOAuthProvider::new("https://auth.example.com"); }
2. Validate Redirect URIs
Prevent authorization code interception:
#![allow(unused)] fn main() { fn validate_redirect_uri(client: &OAuthClient, redirect_uri: &str) -> Result<(), Error> { if !client.redirect_uris.contains(&redirect_uri.to_string()) { return Err(Error::protocol( ErrorCode::INVALID_REQUEST, "Invalid redirect_uri".to_string() )); } // Must be HTTPS in production if !redirect_uri.starts_with("https://") && !is_localhost(redirect_uri) { return Err(Error::validation("redirect_uri must use HTTPS")); } Ok(()) } }
3. Use Short-Lived Access Tokens
#![allow(unused)] fn main() { // ✅ Good: Short-lived access tokens let token = TokenResponse { access_token: generate_token(), expires_in: Some(900), // 15 minutes refresh_token: Some(generate_refresh_token()), // ... }; // ❌ Bad: Long-lived access tokens let token = TokenResponse { access_token: generate_token(), expires_in: Some(86400 * 30), // 30 days - too long! // ... }; }
4. Implement Rate Limiting
Prevent brute force and DoS attacks:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use pmcp::error::{Error, ErrorCode}; struct RateLimiter { requests: RwLock<HashMap<String, Vec<Instant>>>, max_requests: usize, window: Duration, } impl RateLimiter { async fn check(&self, user_id: &str, client_id: Option<&str>) -> Result<(), Error> { let mut requests = self.requests.write().await; // Key by user_id + client_id for better granularity let key = match client_id { Some(cid) => format!("{}:{}", user_id, cid), None => user_id.to_string(), }; let user_requests = requests.entry(key).or_default(); // Remove old requests outside window (sliding window) user_requests.retain(|&time| time.elapsed() < self.window); // Check limit if user_requests.len() >= self.max_requests { return Err(Error::protocol( ErrorCode::RATE_LIMITED, format!("Rate limit exceeded: {} requests per {:?}", self.max_requests, self.window) )); } user_requests.push(Instant::now()); Ok(()) } } }
Rate limiting strategies:
- Sliding window (shown above) - Fair, tracks exact request times
- Token bucket - Allows bursts, good for API quotas
- IP-based - Additional layer at edge/reverse proxy for DoS protection
- Multi-key - Combine user_id + client_id + IP for comprehensive control
5. Audit Logging
Log all authentication and authorization events:
#![allow(unused)] fn main() { use tracing::{info, warn, error}; async fn authenticate_request(&self, auth_info: &AuthInfo) -> Result<AuthContext, Error> { match self.validate_token(&auth_info.token).await { Ok(claims) => { info!( user = %claims.sub, scopes = ?claims.scope, client = %claims.aud, "Authentication successful" ); Ok(AuthContext::from_claims(claims)) } Err(e) => { warn!( error = %e, token_prefix = %&auth_info.token[..10], "Authentication failed" ); Err(e) } } } async fn authorize_action(&self, user: &str, action: &str, resource: &str) -> Result<(), Error> { if !self.has_permission(user, action, resource).await? { error!( user = %user, action = %action, resource = %resource, "Authorization denied" ); return Err(Error::protocol( ErrorCode::PERMISSION_DENIED, "Insufficient permissions".to_string() )); } info!( user = %user, action = %action, resource = %resource, "Authorization granted" ); Ok(()) } }
6. Secure Token Storage (Client-Side)
For MCP clients:
#![allow(unused)] fn main() { // ✅ Good: Use OS keychain/credential manager use keyring::Entry; let entry = Entry::new("mcp-client", "access_token")?; entry.set_password(&access_token)?; // Later... let token = entry.get_password()?; // ❌ Bad: Store in plaintext files std::fs::write("token.txt", access_token)?; // NEVER DO THIS }
7. Handle Token Refresh
Implement automatic token refresh:
#![allow(unused)] fn main() { use tokio::sync::RwLock; use std::time::Instant; use std::time::Duration; use std::sync::Arc; use pmcp::server::auth::OAuthProvider; use pmcp::error::Error; struct TokenManager { access_token: RwLock<String>, refresh_token: String, expires_at: RwLock<Instant>, oauth_provider: Arc<dyn OAuthProvider>, // Use provider, not client struct } impl TokenManager { async fn get_valid_token(&self) -> Result<String, Error> { // Check if token is about to expire (refresh 5 minutes early) let expires_at = *self.expires_at.read().await; if Instant::now() + Duration::from_secs(300) >= expires_at { self.refresh().await?; } Ok(self.access_token.read().await.clone()) } async fn refresh(&self) -> Result<(), Error> { // Use OAuthProvider's refresh_token method let new_tokens = self.oauth_provider .refresh_token(&self.refresh_token) .await?; *self.access_token.write().await = new_tokens.access_token; *self.expires_at.write().await = Instant::now() + Duration::from_secs(new_tokens.expires_in.unwrap_or(3600) as u64); Ok(()) } } }
Running the Examples
OAuth Server Example
Demonstrates bearer token validation and scope-based authorization:
cargo run --example 16_oauth_server
What it shows:
- Public tools (no auth required)
- Protected tools (auth required)
- Scope-based authorization (read, write, admin)
- Token validation with middleware
- Audit logging
OIDC Discovery Example
Demonstrates OIDC provider integration:
cargo run --example 20_oidc_discovery
What it shows:
- OIDC discovery from provider metadata
- Authorization code exchange
- Token refresh flows
- Retry logic for network errors
- Transport isolation for security
Integration Checklist
When integrating authentication into your MCP server:
- Choose OAuth provider - Auth0, Okta, Keycloak, Azure AD, etc.
- Register MCP server - Create OAuth client in provider
- Configure scopes - Define permission levels (read, write, admin, etc.)
- Implement token validation - JWT validation or introspection
-
Add middleware - Use
BearerTokenMiddlewareandScopeMiddleware - Protect tools - Add auth checks to tool handlers
- Enforce authorization - Check scopes, roles, or attributes
- Enable HTTPS - Use TLS for all endpoints
- Implement rate limiting - Prevent abuse
- Add audit logging - Track who did what
- Document scopes - Tell users what permissions they need
- Test authorization - Verify permission enforcement works
- Handle token expiration - Implement refresh logic (client-side)
Multi-Tenant Considerations
For multi-tenant MCP servers, enforce tenant boundaries:
#![allow(unused)] fn main() { use pmcp::error::{Error, ErrorCode}; async fn validate_tenant_access(auth_ctx: &AuthContext, resource_id: &str) -> Result<(), Error> { // Extract tenant ID from token claims let token_tenant = auth_ctx.claims.get("tenant_id") .and_then(|v| v.as_str()) .ok_or_else(|| Error::validation("Missing tenant_id claim"))?; // Fetch resource and check tenant ownership let resource = fetch_resource(resource_id).await?; if resource.tenant_id != token_tenant { return Err(Error::protocol( ErrorCode::PERMISSION_DENIED, format!("Resource {} does not belong to tenant {}", resource_id, token_tenant) )); } Ok(()) } }
Multi-tenant best practices:
- Include
tenant_idin access token claims - Validate tenant context for ALL resource access
- Use database row-level security when possible
- Audit cross-tenant access attempts
- Consider separate OAuth clients per tenant
Common Pitfalls to Avoid
❌ Don’t implement custom auth flows - Use established OAuth providers
❌ Don’t store tokens in plaintext - Use secure storage (keychain, vault)
❌ Don’t skip token validation - Always validate signature, expiration, audience, algorithm
❌ Don’t use long-lived access tokens - Keep them short (15-60 minutes)
❌ Don’t accept HTTP in production - Always use HTTPS
❌ Don’t leak tokens in logs - Redact tokens in log messages
❌ Don’t bypass authorization - Always check permissions, even for “trusted” users
❌ Don’t trust client-provided identity - Always validate server-side
❌ Don’t use implicit flow - Use Authorization Code + PKCE instead
❌ Don’t ignore tenant boundaries - Always validate tenant context in multi-tenant apps
Key Takeaways
- Reuse existing OAuth infrastructure - Don’t reinvent authentication
- MCP servers = web servers - Same token validation patterns apply
- OAuth flows are external - MCP server only validates tokens
- Act on behalf of users - Use access tokens to enforce user permissions
- Validate everything - Signature, expiration, audience, scopes
- Log security events - Track authentication and authorization
- Use HTTPS always - Protect tokens in transit
- Keep tokens short-lived - Use refresh tokens for long sessions
- Enforce least privilege - Grant minimum necessary permissions
- Test security - Verify authorization works correctly
Authentication done right makes MCP servers secure, auditable, and integrated with your organization’s existing identity infrastructure. By following OAuth 2.0 and OIDC standards, you get enterprise-grade security without reinventing the wheel.
Further Reading
- OAuth 2.0 RFC 6749
- OpenID Connect Core 1.0
- OIDC Discovery
- JWT Best Practices
- PMCP Auth API Documentation
- Example:
examples/16_oauth_server.rs - Example:
examples/20_oidc_discovery.rs
Chapter 10: Transport Layers — Write Once, Run Anywhere
This chapter introduces the transport layer architecture of MCP—the foundation that enables you to write your server logic once and deploy it across diverse environments without modification. Just as a web application can be deployed behind different web servers (nginx, Apache, Cloudflare), your MCP server can run over different transport mechanisms (stdio, HTTP, WebSocket) without changing a single line of business logic.
The Power of Transport Abstraction
One of PMCP’s most powerful features is its clean separation between what your server does (business logic) and how it communicates (transport layer).
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::{Server, ToolHandler, RequestHandlerExtra, ServerCapabilities}; use pmcp::server::streamable_http_server::StreamableHttpServer; use serde_json::{json, Value}; use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use tokio::sync::Mutex; // Your business logic - transport-agnostic struct CalculatorTool; #[async_trait] impl ToolHandler for CalculatorTool { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> { // This code is identical regardless of transport let a = args["a"].as_f64().unwrap(); let b = args["b"].as_f64().unwrap(); Ok(json!({ "result": a + b })) } } // Build your server once fn build_server() -> pmcp::Result<Server> { Server::builder() .name("calculator-server") .version("1.0.0") .tool("calculate", CalculatorTool) .capabilities(ServerCapabilities::tools_only()) .build() } // Deploy anywhere - just swap how you run it async fn deploy_stdio() -> pmcp::Result<()> { let server = build_server()?; server.run_stdio().await // stdio transport } async fn deploy_http() -> pmcp::Result<()> { let server = build_server()?; let server = Arc::new(Mutex::new(server)); let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 8080); let http_server = StreamableHttpServer::new(addr, server); http_server.start().await?; Ok(()) } async fn deploy_websocket() -> pmcp::Result<()> { // Optional: WebSocket server transport (feature: `websocket`) // Typical flow: bind → accept one connection → run with custom transport // use pmcp::server::transport::websocket::WebSocketServerTransport; // let mut ws = WebSocketServerTransport::default_server(); // ws.bind().await?; // ws.accept().await?; // build_server()?.run(ws).await?; Ok(()) } }
This is write once, run anywhere in practice. Your CalculatorTool works identically whether it’s:
- Running locally via stdio for Claude Desktop
- Deployed as an HTTP API for web-based AI agents
- Hosted as a WebSocket service for real-time applications
- Bundled in WASM for browser-based demos
Why Transport Abstraction Matters
Client Diversity
Different MCP clients prefer different transport mechanisms:
| Client | Preferred Transport | Why? |
|---|---|---|
| Claude Desktop | stdio | Simple process spawning, secure local execution |
| Cursor IDE | HTTP/WebSocket | Network-accessible, works with remote servers |
| Web-based AI agents | HTTP/WebSocket | Browser compatibility, CORS support |
| ChatGPT | HTTP | RESTful integration, scalable backend |
| GitHub Copilot | stdio/HTTP | IDE integration flexibility |
| Custom enterprise tools | Any | Depends on infrastructure |
Without transport abstraction, you’d need to maintain separate server implementations for each client type. With PMCP, you write your logic once and support all clients.
Deployment Flexibility
Your deployment environment often dictates transport choice:
%%{init: { 'theme': 'neutral' }}%%
flowchart TB
subgraph "Same Server Logic"
logic["Your MCP Server<br/>(Tools, Resources, Prompts)"]
end
subgraph "Different Deployments"
local["Local Desktop<br/>stdio transport<br/>(Claude Desktop)"]
cloud["Cloud HTTP<br/>HTTP transport<br/>(AWS Lambda)"]
realtime["Real-time Service<br/>WebSocket transport<br/>(Live updates)"]
browser["Browser WASM<br/>HTTP/WebSocket<br/>(Demos, docs)"]
end
logic --> local
logic --> cloud
logic --> realtime
logic --> browser
ASCII fallback:
Your MCP Server Logic
(Tools, Resources, Prompts)
|
+----------------+----------------+----------------+
| | | |
stdio Streamable HTTP WebSocket (client) WASM
| | | |
Claude Cloud Platforms Connect to WS Browser
Desktop (Lambda, Fly.io, servers Demos
Workers, K8s, VPS)
Available Transport Options
PMCP provides several built-in transport implementations, each optimized for specific use cases:
1. Stdio Transport (Process-based)
- Best for: Local desktop applications (Claude Desktop, VS Code extensions)
- Mechanism: Standard input/output streams
- Pros: Simple, secure, process isolation, no network overhead
- Cons: Local-only, one client per process
2. Streamable HTTP (Flexible HTTP-based transport)
- Best for: Cloud deployments, web apps, Cursor IDE, serverless functions, containerized services
- Mechanism: HTTP POST for requests, optional SSE for server notifications
- Modes:
- Stateless (JSON only): No sessions, pure request/response, perfect for serverless (AWS Lambda, Cloudflare Workers, etc.)
- Stateful (JSON + optional SSE): Sessions tracked, SSE for notifications, ideal for long-lived servers
- Pros: Extremely flexible, works through firewalls, choose your tradeoffs (stateless vs stateful, JSON vs SSE)
- Cons: More configuration options than stdio
- Note: PMCP’s built-in HTTP server using Axum (feature:
streamable-http)
3. WebSocket Transport (Client + optional Server)
- Best for: Connecting to existing WebSocket MCP servers
- Mechanism: Persistent WebSocket connection with JSON messages
- Pros: True bi-directional communication, low latency, efficient
- Cons: Firewall issues, connection management complexity
- Note: Client transport is available via
WebSocketTransport(feature:websocket). A server transport is also provided aspmcp::server::transport::websocket::WebSocketServerTransport(feature:websocket) and demonstrated inexamples/27_websocket_server_enhanced.rs. For production servers, prefer Streamable HTTP.
4. WASM Transports (Browser-based)
- Best for: Browser demos, documentation, client-side tools
- Mechanism: Web APIs (fetch, WebSocket) compiled to WebAssembly
- Pros: No server required, instant demos, sandboxed security
- Cons: Limited capabilities, browser-only
- Note: On
wasm32targets,TransportrelaxesSend + Syncrequirements; useWasmHttpTransportandWasmWebSocketTransport
Feature Flags
Transport implementations are feature-gated. Enable them in your Cargo.toml:
[dependencies]
pmcp = { version = "1.7", features = ["streamable-http", "http", "websocket"] }
# For minimal stdio-only deployment:
pmcp = { version = "1.7", default-features = false }
# For WebAssembly targets:
pmcp = { version = "1.7", features = ["wasm"] }
Available features:
streamable-http: Streamable HTTP server (Axum-based) and client transporthttp: Base HTTP utilities (included withstreamable-http)websocket: WebSocket client transport (crate root) and server transport (pmcp::server::transport::websocket), requirestokiowasm: WASM-compatible transports for browser use
Understanding Streamable HTTP Modes
Streamable HTTP is PMCP’s most flexible transport, offering different operational modes for different deployment scenarios:
Mode 1: Stateless + JSON Response (Serverless-Optimized)
When to use:
- AWS Lambda, Cloudflare Workers, Google Cloud Functions, Azure Functions
- Serverless platforms with cold starts and per-request billing
- Auto-scaling environments where sessions can’t be maintained
- Simple request/response patterns without server notifications
How it works:
#![allow(unused)] fn main() { let config = StreamableHttpServerConfig { session_id_generator: None, // ❌ No sessions enable_json_response: true, // ✅ Simple JSON responses event_store: None, // ❌ No event history ..Default::default() }; }
- Client sends HTTP POST → Server responds with JSON → Connection closes
- No state maintained between requests
- No server-initiated notifications
- Minimal memory footprint per request
Deployment targets: AWS Lambda, Cloudflare Workers, Google Cloud Functions, Azure Functions, Vercel, Netlify Functions
Mode 2: Stateful + JSON Response (Session without SSE)
When to use:
- Long-lived servers (VPS, EC2, containers) where you want session tracking
- Need to correlate multiple requests from same client
- Don’t need server-initiated notifications (client can poll)
How it works:
#![allow(unused)] fn main() { let config = StreamableHttpServerConfig { session_id_generator: Some(Box::new(|| Uuid::new_v4().to_string())), // ✅ Sessions enable_json_response: true, // ✅ JSON responses event_store: Some(...), // ✅ Track session history ..Default::default() }; }
- Server generates
mcp-session-idon first request - Client includes session ID in subsequent requests
- Server maintains session state
- Still uses JSON responses (no SSE)
Deployment targets: Docker containers, Kubernetes, Fly.io, Railway, traditional VPS, AWS EC2, Google Compute Engine
Mode 3: Stateful + SSE (Full Real-Time)
When to use:
- Cursor IDE integration (Cursor expects SSE)
- Need server-initiated notifications (progress, logging, resource changes)
- Long-running operations with real-time updates
- WebSocket-like behavior over HTTP
How it works:
#![allow(unused)] fn main() { let config = StreamableHttpServerConfig { session_id_generator: Some(Box::new(|| Uuid::new_v4().to_string())), // ✅ Sessions enable_json_response: false, // ✅ Use SSE for responses event_store: Some(...), // ✅ Track events for resumption ..Default::default() }; }
- Client sends
Accept: text/event-streamheader - Server responds with SSE stream for notifications
- Supports resumption via
Last-Event-Idheader - Full bi-directional communication
Deployment targets: Docker, Kubernetes, Fly.io, Railway, VPS, AWS EC2, Google Compute Engine, DigitalOcean, Hetzner, any long-lived server
Mode Comparison
| Feature | Stateless + JSON | Stateful + JSON | Stateful + SSE |
|---|---|---|---|
| Sessions | ❌ None | ✅ Tracked | ✅ Tracked |
| Server Notifications | ❌ No | ⚠️ Poll only | ✅ Real-time |
| Memory per client | ~10KB | ~50KB | ~300KB |
| Cold start friendly | ✅ Yes | ⚠️ Loses sessions | ❌ No |
| Serverless | ✅ Perfect | ⚠️ Works but loses state | ❌ Not recommended |
| Cursor IDE | ⚠️ Limited | ⚠️ Limited | ✅ Full support |
| Best for | Lambda, Workers | Containers, VPS | Real-time apps |
The Transport Trait: Your Interface to Freedom
All transports implement a single, simple trait (pmcp::Transport, re-exported from pmcp::shared::transport):
#![allow(unused)] fn main() { use async_trait::async_trait; use std::fmt::Debug; #[async_trait] pub trait Transport: Send + Sync + Debug { /// Send a message over the transport async fn send(&mut self, message: TransportMessage) -> Result<()>; /// Receive a message from the transport async fn receive(&mut self) -> Result<TransportMessage>; /// Close the transport async fn close(&mut self) -> Result<()>; /// Check if still connected fn is_connected(&self) -> bool { true } /// Get transport type for debugging fn transport_type(&self) -> &'static str { "unknown" } } }
This trait is all you need to make your server work with any communication mechanism. The server core only knows about this interface—it doesn’t care if messages travel over stdin, HTTP, WebSocket, or carrier pigeon.
Note: For WASM targets, the trait relaxes Send + Sync requirements since Web APIs are single-threaded.
Transport Messages: The Universal Currency
All transports exchange the same message types (pmcp::shared::TransportMessage):
#![allow(unused)] fn main() { use pmcp::shared::TransportMessage; use pmcp::types::{RequestId, Request, JSONRPCResponse, Notification}; pub enum TransportMessage { /// Client request (initialize, tools/call, etc.) Request { id: RequestId, request: Request, }, /// Server response Response(JSONRPCResponse), /// Server notification (progress, logging, etc.) Notification(Notification), } }
Whether you’re using stdio or HTTP, your server sends and receives the same TransportMessage variants. The transport handles the serialization details:
- stdio: Line-delimited JSON on stdin/stdout
- Streamable HTTP: JSON in HTTP POST body, SSE for server messages
- WebSocket: JSON text frames (currently; binary frames not yet implemented)
Choosing the Right Transport
Use this decision tree to select your transport:
%%{init: { 'theme': 'neutral' }}%%
flowchart TD
start{{"What's your<br/>deployment target?"}}
start -->|Local desktop app| local_choice{{"Claude Desktop,<br/>VS Code, etc.?"}}
start -->|Web/cloud service| cloud_choice{{"Need real-time<br/>updates?"}}
start -->|Browser demo| browser[Use WASM HTTP or WebSocket]
local_choice -->|Yes| stdio[Use Stdio Transport]
local_choice -->|Custom desktop app| local_custom{{"Need notifications?"}}
local_custom -->|Yes| websocket[Use WebSocket]
local_custom -->|No| http[Use HTTP]
cloud_choice -->|Yes, with sessions| streamable[Use Streamable HTTP]
cloud_choice -->|Yes, full duplex| websocket
cloud_choice -->|No, stateless| serverless{{"AWS Lambda,<br/>serverless?"}}
serverless -->|Yes| http
serverless -->|No| streamable
ASCII fallback:
Deployment Target?
├─ Local Desktop → Claude Desktop/VS Code → Stdio
│ → Custom app with notifications → WebSocket
│ → Custom app without notifications → HTTP
│
├─ Web/Cloud → Need real-time? → Yes + sessions → Streamable HTTP
│ → Yes + full duplex → WebSocket
│ → No → Serverless? → Yes → HTTP
│ → No → Streamable HTTP
│
└─ Browser Demo → WASM HTTP or WebSocket
Quick Selection Guide
| Scenario | Recommended Transport | Mode | Why? |
|---|---|---|---|
| Claude Desktop | Stdio | N/A | Native support, simplest setup |
| Cursor IDE | Streamable HTTP | Stateful + SSE | Cursor expects SSE for notifications |
| AWS Lambda | Streamable HTTP | Stateless + JSON | No sessions, cold-start friendly |
| Cloudflare Workers | Streamable HTTP | Stateless + JSON | Serverless edge, no state |
| Google Cloud Functions | Streamable HTTP | Stateless + JSON | Serverless, auto-scaling |
| Docker/Kubernetes | Streamable HTTP | Stateful + JSON or SSE | Long-lived, sessions work |
| Fly.io / Railway | Streamable HTTP | Stateful + SSE | Persistent servers, real-time |
| Traditional VPS | Streamable HTTP | Stateful + SSE | Full control, long-lived |
| Browser demo | WASM + Streamable HTTP | Client-side | No backend required |
| Enterprise proxy | Streamable HTTP | Any mode | Firewall-friendly HTTPS |
| Connect to WS server | WebSocket (client) | N/A | Full-duplex communication |
| Multi-tenant SaaS | Streamable HTTP | Stateful + SSE | Session isolation, notifications |
Transport Architecture Deep Dive
Separation of Concerns
PMCP maintains strict layering:
┌─────────────────────────────────────┐
│ Your Application Logic │
│ (Tools, Resources, Prompts) │ ← Transport-agnostic
├─────────────────────────────────────┤
│ MCP Server Core │
│ (Protocol, Routing, State) │ ← Transport-agnostic
├─────────────────────────────────────┤
│ Transport Trait │
│ (send, receive, close) │ ← Universal interface
├─────────────────────────────────────┤
│ Transport Implementation │
│ (Stdio/HTTP/WebSocket/etc.) │ ← Environment-specific
└─────────────────────────────────────┘
Your code at the top two layers never changes. Only the bottom layer changes based on deployment.
Message Flow Example
Here’s how a tool call flows through different transports:
Stdio Transport:
Client (Claude Desktop)
↓ spawns process
Server Process
↓ reads stdin (line-delimited JSON)
Transport receives: {"jsonrpc":"2.0","method":"tools/call",...}
↓ deserializes to TransportMessage::Request
Server Core
↓ routes to tool handler
Your CalculatorTool.handle()
↓ returns result
Server Core
↓ creates TransportMessage::Response
Transport
↓ writes to stdout (JSON + newline)
Client receives result
Streamable HTTP Transport:
Client (Cursor IDE, Web browser)
↓ HTTP POST to /
Streamable HTTP Server (Axum)
↓ reads request body (JSON-RPC)
↓ checks Accept header, mcp-protocol-version
Transport receives: {"jsonrpc":"2.0","method":"tools/call",...}
↓ deserializes to TransportMessage::Request
Server Core
↓ routes to tool handler
Your CalculatorTool.handle() ← SAME CODE
↓ returns result
Server Core
↓ creates TransportMessage::Response
Transport
↓ HTTP 200 response with JSON body (or SSE if Accept: text/event-stream)
↓ includes mcp-session-id header if stateful
Client receives result
Notice: Your tool handler code is identical. The transport handles all the I/O details.
Supporting Multiple Transports Simultaneously
Many production deployments support multiple transports at once:
use pmcp::{Server, ServerCapabilities}; use pmcp::server::streamable_http_server::StreamableHttpServer; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; use tracing::info; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create shared server capabilities let capabilities = ServerCapabilities::tools_only(); // Spawn stdio server for Claude Desktop tokio::spawn(async { let stdio_server = Server::builder() .name("calculator-server") .version("1.0.0") .capabilities(capabilities.clone()) .tool("calculator", CalculatorTool) .build() .expect("Failed to build stdio server"); stdio_server.run_stdio().await.expect("Stdio server failed"); }); // Spawn HTTP server for web clients let http_server = Server::builder() .name("calculator-server") .version("1.0.0") .capabilities(capabilities) .tool("calculator", CalculatorTool) .build()?; let http_server = Arc::new(Mutex::new(http_server)); let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); let streamable = StreamableHttpServer::new(addr, http_server); info!("Starting HTTP server on {}", addr); streamable.start().await?; // All transports use the same CalculatorTool implementation! Ok(()) }
This pattern enables:
- Local development via stdio
- Production deployment via Streamable HTTP
- Testing via any transport
- Gradual migration from one transport to another
Transport Configuration
Each transport has specific configuration needs:
Stdio Transport - Server Side
#![allow(unused)] fn main() { use pmcp::{Server, ServerCapabilities}; // Minimal - just build and run let server = Server::builder() .name("my-server") .version("1.0.0") .tool("my_tool", MyTool) .capabilities(ServerCapabilities::tools_only()) .build()?; server.run_stdio().await?; // Reads stdin, writes stdout }
Streamable HTTP Server Configuration
#![allow(unused)] fn main() { use pmcp::{Server, ServerCapabilities}; use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; let server = Server::builder() .name("http-server") .version("1.0.0") .capabilities(ServerCapabilities::tools_only()) .build()?; let server = Arc::new(Mutex::new(server)); let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); // Stateful mode (default) - sessions tracked let http_server = StreamableHttpServer::new(addr, server.clone()); // Or configure for stateless mode (serverless-friendly) let config = StreamableHttpServerConfig { session_id_generator: None, // Stateless enable_json_response: false, // Use SSE event_store: None, on_session_initialized: None, on_session_closed: None, }; let http_server = StreamableHttpServer::with_config(addr, server, config); let (bound_addr, handle) = http_server.start().await?; }
Streamable HTTP Protocol Details:
The Streamable HTTP server enforces MCP-specific headers:
mcp-protocol-version: Protocol version header (e.g.,2024-11-05)mcp-session-id: Session identifier (stateful mode only)Accept: Must includeapplication/jsonortext/event-streamAccept: application/json→ Simple JSON responsesAccept: text/event-stream→ Server-Sent Events for notifications
Last-Event-Id: For SSE resumption after reconnection
Full working examples:
examples/22_streamable_http_server_stateful.rs- Session management, SSE notificationsexamples/23_streamable_http_server_stateless.rs- Serverless-friendly, no sessionsexamples/24_streamable_http_client.rs- Client connecting to both modes
WebSocket Transport - Client Side
#![allow(unused)] fn main() { use pmcp::{Client, ClientCapabilities, WebSocketTransport, WebSocketConfig}; use std::time::Duration; use url::Url; let config = WebSocketConfig { url: Url::parse("ws://localhost:3000/mcp")?, auto_reconnect: true, reconnect_delay: Duration::from_secs(1), max_reconnect_delay: Duration::from_secs(30), max_reconnect_attempts: Some(5), ping_interval: Some(Duration::from_secs(30)), request_timeout: Duration::from_secs(30), }; let transport = WebSocketTransport::new(config); transport.connect().await?; // Must connect before using let mut client = Client::new(transport); client.initialize(ClientCapabilities::minimal()).await?; }
Streamable HTTP Transport - Client Side
#![allow(unused)] fn main() { use pmcp::shared::streamable_http::{StreamableHttpTransport, StreamableHttpTransportConfig}; use url::Url; let config = StreamableHttpTransportConfig { url: Url::parse("http://localhost:8080")?, extra_headers: vec![], auth_provider: None, session_id: None, enable_json_response: true, on_resumption_token: None, }; let transport = StreamableHttpTransport::new(config); let mut client = Client::new(transport); }
Transport Characteristics Comparison
| Feature | Stdio | Streamable HTTP | WebSocket (client) |
|---|---|---|---|
| Setup Complexity | Trivial | Medium | Medium |
| Latency | Lowest | Low | Lowest |
| Server Notifications | Full | SSE streams | Full |
| State Management | Per-process | Optional sessions | Connection-based |
| Firewall Friendly | N/A | ✅ Yes | ⚠️ Sometimes |
| Scalability | 1:1 process | Horizontal/session-aware | Connection pools |
| Browser Support | ❌ No | ✅ Yes | ✅ Yes |
| Message Format | Line-delimited JSON | JSON + SSE | JSON text frames |
| Connection Persistence | Process lifetime | Session-scoped | Persistent |
| Server Implementation | Built-in | Built-in (Axum) | Client-only |
| Best for | Local desktop apps | Cursor IDE, web apps, cloud | Connecting to WS servers |
Real-World Deployment Patterns
Pattern 1: Local-First with Cloud Fallback (Client)
#![allow(unused)] fn main() { use pmcp::shared::streamable_http::{StreamableHttpTransport, StreamableHttpTransportConfig}; // Try connecting to local server first, fallback to cloud let transport = if is_local_server_available().await { // Local stdio server via another process (requires process spawning) create_stdio_client_transport() } else { // Cloud HTTP server let config = StreamableHttpTransportConfig { url: Url::parse("https://api.example.com/mcp")?, ..Default::default() }; StreamableHttpTransport::new(config) }; let mut client = Client::new(transport); }
Pattern 2: Multi-Transport Server
use pmcp::{Server, ServerCapabilities}; use pmcp::server::streamable_http_server::StreamableHttpServer; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; // Serve same MCP server logic on multiple transports simultaneously #[tokio::main] async fn main() -> pmcp::Result<()> { // Build server once let capabilities = ServerCapabilities::tools_only(); // Stdio server for local Claude Desktop let stdio_server = Server::builder() .name("multi-transport-server") .version("1.0.0") .capabilities(capabilities.clone()) .tool("my_tool", MyTool) .build()?; tokio::spawn(async move { stdio_server.run_stdio().await }); // HTTP server for remote clients let http_server = Server::builder() .name("multi-transport-server") .version("1.0.0") .capabilities(capabilities) .tool("my_tool", MyTool) .build()?; let http_server = Arc::new(Mutex::new(http_server)); let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); let streamable = StreamableHttpServer::new(addr, http_server); streamable.start().await?; Ok(()) }
Pattern 3: Multi-Platform Deployment (Same Code, Different Modes)
AWS Lambda (Stateless)
#![allow(unused)] fn main() { use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; // Serverless: No sessions, pure request/response let config = StreamableHttpServerConfig { session_id_generator: None, // Stateless enable_json_response: true, // JSON only event_store: None, // No event history on_session_initialized: None, on_session_closed: None, }; let server = build_my_server()?; // Same server logic let http_server = StreamableHttpServer::with_config(addr, Arc::new(Mutex::new(server)), config); }
Fly.io / Railway (Stateful + SSE)
#![allow(unused)] fn main() { // Long-lived servers: Sessions + real-time notifications let config = StreamableHttpServerConfig::default(); // Uses default (stateful + SSE) let server = build_my_server()?; // Same server logic let http_server = StreamableHttpServer::new(addr, Arc::new(Mutex::new(server))); }
Docker / Kubernetes (Stateful + JSON)
#![allow(unused)] fn main() { // Containers: Session tracking without SSE overhead let config = StreamableHttpServerConfig { session_id_generator: Some(Box::new(|| Uuid::new_v4().to_string())), enable_json_response: true, // JSON responses event_store: Some(Arc::new(InMemoryEventStore::default())), ..Default::default() }; let server = build_my_server()?; // Same server logic let http_server = StreamableHttpServer::with_config(addr, Arc::new(Mutex::new(server)), config); }
Key insight: Same build_my_server() function, different deployment configs!
Pattern 4: Hybrid Architecture
#![allow(unused)] fn main() { // Different transports for different trust levels match tool_trust_level(tool_name) { TrustLevel::Trusted => { // Call tool on local stdio server (more secure) local_stdio_client.call_tool(tool_name, args).await } TrustLevel::Untrusted => { // Call tool on sandboxed HTTP server (isolated) remote_http_client.call_tool(tool_name, args).await } } }
When to Create Custom Transports
Most developers will use the built-in transports, but you might create a custom transport for:
- Custom protocols: gRPC, QUIC, MQTT
- Hardware integration: Serial ports, USB, Bluetooth
- Message queues: RabbitMQ, Kafka, SQS
- IPC mechanisms: Named pipes, domain sockets
- Exotic environments: Embedded systems, IoT devices
Custom transports are covered in Chapter 23. For now, know that if you can implement async fn send() and async fn receive(), you can add any transport mechanism to PMCP.
Performance Considerations
Transport choice affects performance:
Latency Rankings (Typical)
- Stdio: ~0.1ms (local IPC, no network)
- WebSocket (client): ~1-5ms (persistent connection, JSON text frames)
- Streamable HTTP: ~2-10ms (HTTP POST + SSE)
Throughput Rankings (Messages/sec)
- Stdio: 5,000+ (line-buffered, local IPC)
- WebSocket (client): 3,000+ (persistent, JSON text)
- Streamable HTTP: 1,000+ (HTTP overhead, optional sessions)
Memory Rankings (Per Connection)
- Stdio: ~100KB (process isolation, stdio buffers)
- Streamable HTTP (stateless): ~50KB (no session state)
- WebSocket (client): ~200KB (frame buffers, reconnection logic)
- Streamable HTTP (stateful): ~300KB (session state + SSE event store)
Choose based on your bottleneck:
- Latency-critical → stdio or WebSocket client
- Scale-critical → Streamable HTTP (stateless mode)
- Balanced → Streamable HTTP (stateful mode)
Testing Across Transports
Write tests that work with any transport:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { // Generic test that works with any transport async fn test_calculator_with_transport<T: Transport>( transport: T ) -> Result<()> { let mut client = Client::new(transport); let result = client.call_tool("calculator", json!({ "operation": "add", "a": 5, "b": 3 })).await?; assert_eq!(result["result"], 8); Ok(()) } #[tokio::test] async fn test_stdio() { test_calculator_with_transport(StdioTransport::new()).await.unwrap(); } #[tokio::test] async fn test_http() { test_calculator_with_transport(HttpTransport::new()).await.unwrap(); } #[tokio::test] async fn test_websocket() { test_calculator_with_transport(WebSocketTransport::new()).await.unwrap(); } } }
This ensures your server works correctly regardless of transport.
Migration Between Transports
Changing transports requires minimal code changes—just how you run the server:
#![allow(unused)] fn main() { use pmcp::{Server, ServerCapabilities}; use pmcp::server::streamable_http_server::StreamableHttpServer; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; // Server logic is identical in both cases let server = Server::builder() .name("my-server") .version("1.0.0") .tool("my_tool", MyTool) .capabilities(ServerCapabilities::tools_only()) .build()?; // Before: stdio deployment server.run_stdio().await?; // After: HTTP deployment (SAME SERVER LOGIC) let server = Arc::new(Mutex::new(server)); let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); let http_server = StreamableHttpServer::new(addr, server); http_server.start().await?; // Your tool implementations don't change at all! }
This enables:
- Development → Production migration: Stdio locally, HTTP in production
- A/B testing: Run same server on multiple transports simultaneously
- Gradual rollout: Migrate users from stdio to HTTP incrementally
- Disaster recovery: Switch transports if one fails
What’s Next
This chapter introduced the transport layer philosophy and compared the available options. The following chapters dive deep into each transport implementation:
- Chapter 10.1 - WebSocket Transport: Client-side WebSocket connections, auto-reconnection, real-time communication
- Chapter 10.2 - HTTP Transport: Understanding the base HTTP protocol layer
- Chapter 10.3 - Streamable HTTP: Server-side implementation using Axum, SSE for notifications, session management, Cursor IDE compatibility
Each detailed chapter includes:
- Configuration options and best practices
- Production deployment examples (including AWS Lambda, Docker, Kubernetes)
- Performance tuning guides
- Security considerations (CORS, authentication, rate limiting)
- Troubleshooting common issues
- Complete working examples
Key Takeaways
✅ Transport abstraction is PMCP’s superpower — write once, deploy anywhere
✅ Your server logic never changes — only the transport configuration changes
✅ Streamable HTTP has three modes — Stateless (serverless), Stateful + JSON (containers), Stateful + SSE (real-time)
✅ Deploy to any platform — AWS Lambda, Cloudflare Workers, Google Cloud Functions, Fly.io, Railway, Docker, Kubernetes, VPS, or traditional servers
✅ Choose mode based on deployment — Serverless = stateless, Containers = stateful, Cursor IDE = SSE
✅ Support multiple transports simultaneously — stdio for local + Streamable HTTP for cloud
✅ Test across all transports — ensures compatibility everywhere
✅ Migration is configuration — Same code, different StreamableHttpServerConfig
The transport layer is the foundation that makes MCP truly universal. By abstracting away communication details, PMCP lets you focus on building powerful tools, resources, and prompts—confident that they’ll work everywhere your users need them.
In the next chapter, we’ll explore WebSocket transport in depth, covering client-side WebSocket connections for connecting to existing MCP servers with auto-reconnection, ping/pong heartbeats, and error recovery strategies.
Chapter 10.1: WebSocket Transport
WebSocket provides a persistent, low-latency channel that’s ideal for interactive tools and near–real-time experiences. PMCP supports WebSocket on the client side out of the box and also includes an optional server transport. For most server deployments, Streamable HTTP is recommended; use WebSocket when full‑duplex, long‑lived connections are required or when integrating with existing WS infrastructure.
Capabilities at a Glance
- Client:
WebSocketTransport,WebSocketConfig(feature:websocket) - Server (optional):
pmcp::server::transport::websocket::{WebSocketServerTransport, WebSocketServerConfig}(feature:websocket) - Persistent connection with JSON text frames; ping/pong keepalive
Client: Connect to a WebSocket Server
use pmcp::{Client, ClientCapabilities, WebSocketTransport, WebSocketConfig}; use std::time::Duration; use url::Url; #[tokio::main] async fn main() -> pmcp::Result<()> { // Connect to an existing MCP server that speaks WebSocket let cfg = WebSocketConfig { url: Url::parse("ws://localhost:3000/mcp")?, auto_reconnect: true, reconnect_delay: Duration::from_secs(1), max_reconnect_delay: Duration::from_secs(30), max_reconnect_attempts: Some(5), ping_interval: Some(Duration::from_secs(30)), request_timeout: Duration::from_secs(30), }; let transport = WebSocketTransport::new(cfg); transport.connect().await?; let mut client = Client::new(transport); let _info = client.initialize(ClientCapabilities::minimal()).await?; // Use the client normally (list tools, call tools, etc.) Ok(()) }
See examples/13_websocket_transport.rs for a complete walkthrough.
Server (Optional): Accept WebSocket Connections
PMCP includes a WebSocket server transport for custom scenarios. It yields a Transport you can pass to server.run(...) after accepting a connection. For most production servers, use Streamable HTTP.
use pmcp::{Server, ServerCapabilities, ToolHandler, RequestHandlerExtra}; use async_trait::async_trait; use serde_json::{json, Value}; use pmcp::server::transport::websocket::{WebSocketServerTransport, WebSocketServerConfig}; struct Echo; #[async_trait] impl ToolHandler for Echo { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> { Ok(json!({ "echo": args })) } } #[tokio::main] async fn main() -> pmcp::Result<()> { let server = Server::builder() .name("ws-server") .version("1.0.0") .capabilities(ServerCapabilities::tools_only()) .tool("echo", Echo) .build()?; let mut ws = WebSocketServerTransport::new(WebSocketServerConfig::default()); ws.bind().await?; // Start listening (default 127.0.0.1:9001) ws.accept().await?; // Accept one connection // Run server over this transport (handles requests from that connection) server.run(ws).await }
See examples/27_websocket_server_enhanced.rs for a multi‑client demo and additional capabilities.
Feature Flags
[dependencies]
pmcp = { version = "1.7", features = ["websocket"] }
When to Use WebSocket
- Full-duplex, interactive sessions with low latency
- Custom desktop/native apps that prefer persistent connections
- Integration with existing WS gateways or load balancers
Prefer Streamable HTTP for most cloud/server deployments (SSE notifications, session management, and firewall friendliness).
Chapter 10.2: HTTP Transport (Client)
PMCP’s HTTP transport is a client-side implementation that sends JSON-RPC requests over HTTP and can optionally subscribe to Server-Sent Events (SSE) for notifications. For server deployments, see Streamable HTTP in Chapter 10.3.
When to Use HTTP Transport
- Simple request/response interactions against an HTTP MCP endpoint
- Optional SSE notifications via a separate endpoint
- Firewall-friendly and proxy-compatible
If you control the server, prefer Streamable HTTP (single endpoint with JSON and SSE, plus session support).
Features and Types
HttpTransport,HttpConfig(feature:http)- Optional connection pooling and configurable timeouts
- Optional
sse_endpointfor notifications
Basic Client Example
use pmcp::{Client, ClientCapabilities, HttpTransport, HttpConfig}; use url::Url; #[tokio::main] async fn main() -> pmcp::Result<()> { // Create HTTP transport let transport = HttpTransport::with_url(Url::parse("http://localhost:8080")?)?; // Optionally connect to SSE notifications if your server exposes one // transport.connect_sse().await?; // Build client and initialize let mut client = Client::new(transport); let _info = client.initialize(ClientCapabilities::minimal()).await?; // Use the client as usual (list tools, call tools, etc.) Ok(()) }
Configuration
#![allow(unused)] fn main() { use pmcp::HttpConfig; use url::Url; use std::time::Duration; let cfg = HttpConfig { base_url: Url::parse("https://api.example.com/mcp")?, sse_endpoint: Some("/events".into()), // or None if not using SSE timeout: Duration::from_secs(30), headers: vec![("Authorization".into(), "Bearer <token>".into())], enable_pooling: true, max_idle_per_host: 10, }; }
Feature Flags
[dependencies]
pmcp = { version = "1.7", features = ["http"] }
Notes
- This client can target generic HTTP-style MCP servers.
- For PMCP’s recommended server implementation, see Streamable HTTP (Axum-based) in Chapter 10.3.
Chapter 10.3: Streamable HTTP (Server + Client)
Streamable HTTP is PMCP’s preferred transport for servers. It combines JSON requests with Server‑Sent Events (SSE) for notifications, supports both stateless and stateful operation, and uses a single endpoint with content negotiation via the Accept header.
Why Streamable HTTP?
- Single endpoint for requests and notifications
- Works well through proxies and enterprise firewalls
- Optional sessions for stateful, multi-request workflows
- Built with Axum, provided by the SDK
Server (Axum-based)
Types: pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig} (feature: streamable-http).
use pmcp::{Server, ServerCapabilities, ToolHandler, RequestHandlerExtra}; use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; use async_trait::async_trait; use serde_json::{json, Value}; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; struct Add; #[async_trait] impl ToolHandler for Add { async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> { let a = args["a"].as_f64().unwrap_or(0.0); let b = args["b"].as_f64().unwrap_or(0.0); Ok(json!({"result": a + b})) } } #[tokio::main] async fn main() -> pmcp::Result<()> { let server = Server::builder() .name("streamable-http-server") .version("1.0.0") .capabilities(ServerCapabilities::tools_only()) .tool("add", Add) .build()?; let server = Arc::new(Mutex::new(server)); let addr: SocketAddr = ([0,0,0,0], 8080).into(); // Default: stateful with SSE support let http = StreamableHttpServer::new(addr, server.clone()); let (bound, _handle) = http.start().await?; println!("Streamable HTTP listening on {}", bound); Ok(()) }
Stateless vs Stateful
#![allow(unused)] fn main() { // Stateless (serverless-friendly): no session tracking let cfg = StreamableHttpServerConfig { session_id_generator: None, enable_json_response: false, // prefer SSE for notifications event_store: None, on_session_initialized: None, on_session_closed: None, }; let http = StreamableHttpServer::with_config(addr, server, cfg); }
Protocol Details
Headers enforced by the server:
mcp-protocol-version: Protocol version (e.g.,2024-11-05)Accept: Must includeapplication/jsonortext/event-streammcp-session-id: Present in stateful modeLast-Event-Id: For SSE resumption
Accept rules:
Accept: application/json→ JSON responses onlyAccept: text/event-stream→ SSE stream for notifications
Client (Streamable HTTP)
Types: pmcp::shared::streamable_http::{StreamableHttpTransport, StreamableHttpConfig} (feature: streamable-http).
Basic Client
use pmcp::{ClientBuilder, ClientCapabilities}; use pmcp::shared::{StreamableHttpTransport, StreamableHttpConfig}; #[tokio::main] async fn main() -> pmcp::Result<()> { let config = StreamableHttpConfig::new("http://localhost:8080".to_string()) .with_extra_headers(vec![ ("X-Custom-Header".to_string(), "value".to_string()) ]); let transport = StreamableHttpTransport::with_config(config).await?; let mut client = ClientBuilder::new(transport).build(); let _info = client.initialize(ClientCapabilities::minimal()).await?; Ok(()) }
HTTP Middleware Support
StreamableHttpTransport supports HTTP-level middleware for authentication, headers, and request/response processing:
use pmcp::{ClientBuilder, ClientCapabilities}; use pmcp::shared::{StreamableHttpTransport, StreamableHttpConfig}; use pmcp::client::http_middleware::HttpMiddlewareChain; use pmcp::client::oauth_middleware::{OAuthClientMiddleware, BearerToken}; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() -> pmcp::Result<()> { // 1. Create HTTP middleware chain let mut http_chain = HttpMiddlewareChain::new(); // Add OAuth middleware for automatic token injection let token = BearerToken::with_expiry( "api-token-12345".to_string(), Duration::from_secs(3600) // 1 hour ); http_chain.add(Arc::new(OAuthClientMiddleware::new(token))); // 2. Create transport config with HTTP middleware let config = StreamableHttpConfig::new("http://localhost:8080".to_string()) .with_http_middleware(Arc::new(http_chain)); let transport = StreamableHttpTransport::with_config(config).await?; // 3. Create client (protocol middleware optional) let mut client = ClientBuilder::new(transport).build(); let _info = client.initialize(ClientCapabilities::minimal()).await?; Ok(()) }
HTTP Middleware Features:
- Automatic OAuth token injection (OAuthClientMiddleware)
- Token expiry checking and refresh triggers
- Custom header injection (implement HttpMiddleware trait)
- Request/response logging and metrics at HTTP layer
- Priority-based execution ordering
OAuth Precedence: If both auth_provider and HTTP middleware OAuth are configured, auth_provider takes precedence to avoid duplicate authentication.
See Chapter 11: Middleware for complete HTTP middleware documentation.
Examples
examples/22_streamable_http_server_stateful.rs– Stateful mode with SSE notificationsexamples/23_streamable_http_server_stateless.rs– Stateless/serverless-friendly configurationexamples/24_streamable_http_client.rs– Client connecting to both modes
Feature Flags
[dependencies]
pmcp = { version = "1.7", features = ["streamable-http"] }
Chapter 11: Middleware
Middleware in PMCP provides a powerful way to intercept, modify, and extend request/response processing. This chapter covers both the basic Middleware trait and the enhanced AdvancedMiddleware system with priority ordering, context propagation, and advanced patterns.
Table of Contents
- Understanding Middleware
- Basic Middleware
- Advanced Middleware
- Built-in Middleware
- Custom Middleware
- Middleware Ordering
- Performance Considerations
- Examples
Understanding Middleware
Middleware operates as a chain of interceptors that process messages bidirectionally:
Client Middleware Chain Server
| |
|---- Request ---> [MW1] -> [MW2] -> [MW3] -> [Transport] --------->|
| ↓ ↓ ↓ |
|<--- Response --- [MW1] <- [MW2] <- [MW3] <- [Transport] ----------|
| |
When to Use Middleware
- Cross-cutting concerns: Logging, metrics, tracing
- Request modification: Authentication, compression, validation
- Error handling: Retry logic, circuit breakers
- Performance optimization: Caching, rate limiting
- Observability: Request tracking, performance monitoring
Basic Middleware
The Middleware trait provides the foundation for request/response interception.
Trait Definition
#![allow(unused)] fn main() { use pmcp::shared::Middleware; use async_trait::async_trait; #[async_trait] pub trait Middleware: Send + Sync { /// Called before a request is sent async fn on_request(&self, request: &mut JSONRPCRequest) -> Result<()>; /// Called after a response is received async fn on_response(&self, response: &mut JSONRPCResponse) -> Result<()>; /// Called when a message is sent (any type) async fn on_send(&self, message: &TransportMessage) -> Result<()>; /// Called when a message is received (any type) async fn on_receive(&self, message: &TransportMessage) -> Result<()>; } }
Basic Example
#![allow(unused)] fn main() { use pmcp::shared::{Middleware, TransportMessage}; use pmcp::types::{JSONRPCRequest, JSONRPCResponse}; use async_trait::async_trait; use std::time::Instant; /// Custom middleware that tracks request timing struct TimingMiddleware { start_times: dashmap::DashMap<String, Instant>, } impl TimingMiddleware { fn new() -> Self { Self { start_times: dashmap::DashMap::new(), } } } #[async_trait] impl Middleware for TimingMiddleware { async fn on_request(&self, request: &mut JSONRPCRequest) -> pmcp::Result<()> { // Track start time self.start_times.insert( request.id.to_string(), Instant::now() ); tracing::info!("Request started: {}", request.method); Ok(()) } async fn on_response(&self, response: &mut JSONRPCResponse) -> pmcp::Result<()> { // Calculate elapsed time if let Some((_, start)) = self.start_times.remove(&response.id.to_string()) { let elapsed = start.elapsed(); tracing::info!("Response for {} took {:?}", response.id, elapsed); } Ok(()) } } }
MiddlewareChain
Chain multiple middleware together for sequential processing:
#![allow(unused)] fn main() { use pmcp::shared::{MiddlewareChain, LoggingMiddleware}; use std::sync::Arc; use tracing::Level; // Create middleware chain let mut chain = MiddlewareChain::new(); // Add middleware in order chain.add(Arc::new(LoggingMiddleware::new(Level::INFO))); chain.add(Arc::new(TimingMiddleware::new())); chain.add(Arc::new(CustomMiddleware)); // Process request through all middleware chain.process_request(&mut request).await?; // Process response through all middleware chain.process_response(&mut response).await?; }
Advanced Middleware
The AdvancedMiddleware trait adds priority ordering, context propagation, conditional execution, and lifecycle hooks.
Trait Definition
#![allow(unused)] fn main() { use pmcp::shared::{AdvancedMiddleware, MiddlewareContext, MiddlewarePriority}; #[async_trait] pub trait AdvancedMiddleware: Send + Sync { /// Get middleware priority for execution ordering fn priority(&self) -> MiddlewarePriority { MiddlewarePriority::Normal } /// Get middleware name for identification fn name(&self) -> &'static str; /// Check if middleware should execute for this context async fn should_execute(&self, context: &MiddlewareContext) -> bool { true } /// Called before a request is sent with context async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> Result<()>; /// Called after a response is received with context async fn on_response_with_context( &self, response: &mut JSONRPCResponse, context: &MiddlewareContext, ) -> Result<()>; /// Lifecycle hooks async fn on_chain_start(&self, context: &MiddlewareContext) -> Result<()>; async fn on_chain_complete(&self, context: &MiddlewareContext) -> Result<()>; async fn on_error(&self, error: &Error, context: &MiddlewareContext) -> Result<()>; } }
MiddlewarePriority
Control execution order with priority levels:
#![allow(unused)] fn main() { use pmcp::shared::MiddlewarePriority; pub enum MiddlewarePriority { Critical = 0, // Validation, security - executed first High = 1, // Authentication, rate limiting Normal = 2, // Business logic, transformation Low = 3, // Logging, metrics Lowest = 4, // Cleanup, finalization } }
Execution order: Higher priority (lower number) executes first for requests, last for responses.
MiddlewareContext
Share data and metrics across middleware layers:
#![allow(unused)] fn main() { use pmcp::shared::MiddlewareContext; let context = MiddlewareContext::with_request_id("req-123".to_string()); // Set metadata context.set_metadata("user_id".to_string(), "user-456".to_string()); // Get metadata if let Some(user_id) = context.get_metadata("user_id") { tracing::info!("User ID: {}", user_id); } // Record metrics context.record_metric("processing_time_ms".to_string(), 123.45); // Get elapsed time let elapsed = context.elapsed(); }
EnhancedMiddlewareChain
Automatic priority ordering and context support:
#![allow(unused)] fn main() { use pmcp::shared::{EnhancedMiddlewareChain, MiddlewareContext}; use std::sync::Arc; // Create enhanced chain with auto-sorting let mut chain = EnhancedMiddlewareChain::new(); // Add middleware (auto-sorted by priority) chain.add(Arc::new(ValidationMiddleware)); // Critical chain.add(Arc::new(RateLimitMiddleware::new(10, 20, Duration::from_secs(1)))); // High chain.add(Arc::new(MetricsMiddleware::new("my-service".to_string()))); // Low // Create context let context = MiddlewareContext::with_request_id("req-001".to_string()); // Process with context chain.process_request_with_context(&mut request, &context).await?; chain.process_response_with_context(&mut response, &context).await?; }
Built-in Middleware
PMCP provides several production-ready middleware implementations.
LoggingMiddleware
Logs all requests and responses at configurable levels:
#![allow(unused)] fn main() { use pmcp::shared::LoggingMiddleware; use tracing::Level; // Create logging middleware let logger = LoggingMiddleware::new(Level::INFO); // Or use default (DEBUG level) let default_logger = LoggingMiddleware::default(); }
Use cases: Request/response visibility, debugging, audit trails.
AuthMiddleware
Adds authentication to requests:
#![allow(unused)] fn main() { use pmcp::shared::AuthMiddleware; let auth = AuthMiddleware::new("Bearer api-token-12345".to_string()); }
Note: This is a basic implementation. For production, implement custom auth middleware with your authentication scheme.
RetryMiddleware
Configures retry behavior for failed requests:
#![allow(unused)] fn main() { use pmcp::shared::RetryMiddleware; // Custom retry settings let retry = RetryMiddleware::new( 5, // max_retries 1000, // initial_delay_ms 30000 // max_delay_ms (exponential backoff cap) ); // Or use defaults (3 retries, 1s initial, 30s max) let default_retry = RetryMiddleware::default(); }
Use cases: Network resilience, transient failure handling.
RateLimitMiddleware (Advanced)
Token bucket rate limiting with automatic refill:
#![allow(unused)] fn main() { use pmcp::shared::RateLimitMiddleware; use std::time::Duration; // 10 requests per second, burst of 20 let rate_limiter = RateLimitMiddleware::new( 10, // max_requests per refill_duration 20, // bucket_size (burst capacity) Duration::from_secs(1) // refill_duration ); }
Features:
- High priority (MiddlewarePriority::High)
- Automatic token refill based on time
- Thread-safe with atomic operations
- Records rate limit metrics in context
Use cases: API rate limiting, resource protection, QoS enforcement.
CircuitBreakerMiddleware (Advanced)
Fault tolerance with automatic failure detection:
#![allow(unused)] fn main() { use pmcp::shared::CircuitBreakerMiddleware; use std::time::Duration; // Open circuit after 5 failures in 60s window, timeout for 30s let circuit_breaker = CircuitBreakerMiddleware::new( 5, // failure_threshold Duration::from_secs(60), // time_window Duration::from_secs(30), // timeout_duration ); }
States:
- Closed: Normal operation, requests pass through
- Open: Too many failures, requests fail fast
- Half-Open: Testing if service recovered, limited requests allowed
Features:
- High priority (MiddlewarePriority::High)
- Automatic state transitions
- Records circuit breaker state in metrics
Use cases: Cascading failure prevention, service degradation, fault isolation.
MetricsMiddleware (Advanced)
Collects performance and usage metrics:
#![allow(unused)] fn main() { use pmcp::shared::MetricsMiddleware; let metrics = MetricsMiddleware::new("my-service".to_string()); // Query metrics let request_count = metrics.get_request_count("tools/call"); let error_count = metrics.get_error_count("tools/call"); let avg_duration = metrics.get_average_duration("tools/call"); // in microseconds tracing::info!( "Method: tools/call, Requests: {}, Errors: {}, Avg: {}μs", request_count, error_count, avg_duration ); }
Collected metrics:
- Request counts per method
- Error counts per method
- Average processing time per method
- Total processing time
Use cases: Observability, performance monitoring, capacity planning.
CompressionMiddleware (Advanced)
Compresses large messages to reduce network usage:
#![allow(unused)] fn main() { use pmcp::shared::{CompressionMiddleware, CompressionType}; // Gzip compression for messages larger than 1KB let compression = CompressionMiddleware::new( CompressionType::Gzip, 1024 // min_size in bytes ); // Compression types pub enum CompressionType { None, Gzip, Deflate, } }
Features:
- Normal priority (MiddlewarePriority::Normal)
- Size threshold to avoid compressing small messages
- Records compression metrics (original size, compression type)
Use cases: Large payload optimization, bandwidth reduction.
Custom Middleware
Basic Custom Middleware
#![allow(unused)] fn main() { use pmcp::shared::Middleware; use pmcp::types::{JSONRPCRequest, JSONRPCResponse}; use async_trait::async_trait; struct MetadataMiddleware { client_id: String, } #[async_trait] impl Middleware for MetadataMiddleware { async fn on_request(&self, request: &mut JSONRPCRequest) -> pmcp::Result<()> { tracing::info!("Client {} sending request: {}", self.client_id, request.method); // Could add client_id to request params here Ok(()) } async fn on_response(&self, response: &mut JSONRPCResponse) -> pmcp::Result<()> { tracing::info!("Client {} received response for: {:?}", self.client_id, response.id); Ok(()) } } }
Advanced Custom Middleware
#![allow(unused)] fn main() { use pmcp::shared::{AdvancedMiddleware, MiddlewareContext, MiddlewarePriority}; use pmcp::types::JSONRPCRequest; use async_trait::async_trait; struct ValidationMiddleware { strict_mode: bool, } #[async_trait] impl AdvancedMiddleware for ValidationMiddleware { fn name(&self) -> &'static str { "validation" } fn priority(&self) -> MiddlewarePriority { MiddlewarePriority::Critical // Run first } async fn should_execute(&self, context: &MiddlewareContext) -> bool { // Only execute for high-priority requests in strict mode if self.strict_mode { matches!( context.priority, Some(pmcp::shared::transport::MessagePriority::High) ) } else { true } } async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Validate request if request.method.is_empty() { context.record_metric("validation_failures".to_string(), 1.0); return Err(pmcp::Error::Validation("Empty method name".to_string())); } if request.jsonrpc != "2.0" { context.record_metric("validation_failures".to_string(), 1.0); return Err(pmcp::Error::Validation("Invalid JSON-RPC version".to_string())); } context.set_metadata("method".to_string(), request.method.clone()); context.record_metric("validation_passed".to_string(), 1.0); Ok(()) } } }
Middleware Ordering
Recommended Order
#![allow(unused)] fn main() { use pmcp::shared::EnhancedMiddlewareChain; use std::sync::Arc; let mut chain = EnhancedMiddlewareChain::new(); // 1. Critical: Validation, security (first in, last out) chain.add(Arc::new(ValidationMiddleware::new())); // 2. High: Rate limiting, circuit breaker (protect downstream) chain.add(Arc::new(RateLimitMiddleware::new(10, 20, Duration::from_secs(1)))); chain.add(Arc::new(CircuitBreakerMiddleware::new( 5, Duration::from_secs(60), Duration::from_secs(30) ))); // 3. Normal: Business logic, compression, transformation chain.add(Arc::new(CompressionMiddleware::new(CompressionType::Gzip, 1024))); chain.add(Arc::new(CustomBusinessLogic)); // 4. Low: Metrics, logging (observe everything) chain.add(Arc::new(MetricsMiddleware::new("my-service".to_string()))); chain.add(Arc::new(LoggingMiddleware::new(Level::INFO))); }
Ordering Principles
- Validation First: Reject invalid requests before doing expensive work
- Protection Before Processing: Rate limit and circuit break early
- Transform in the Middle: Business logic and compression
- Observe Everything: Logging and metrics wrap all operations
Manual Ordering (No Auto-Sort)
#![allow(unused)] fn main() { // Disable automatic priority sorting let mut chain = EnhancedMiddlewareChain::new_no_sort(); // Add in explicit order chain.add(Arc::new(FirstMiddleware)); chain.add(Arc::new(SecondMiddleware)); chain.add(Arc::new(ThirdMiddleware)); // Manual sort by priority if needed chain.sort_by_priority(); }
Performance Considerations
Minimizing Overhead
#![allow(unused)] fn main() { // ✅ Good: Lightweight check async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Quick validation if !request.method.starts_with("tools/") { return Ok(()); // Skip early } // Expensive operation only when needed self.expensive_validation(request).await } // ❌ Bad: Always does expensive work async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Always expensive, even when unnecessary self.expensive_validation(request).await } }
Async Best Practices
#![allow(unused)] fn main() { // ✅ Good: Non-blocking async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Async I/O is fine let user = self.user_service.get_user(&request.user_id).await?; context.set_metadata("user_name".to_string(), user.name); Ok(()) } // ❌ Bad: Blocking in async async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Blocks the executor! let data = std::fs::read_to_string("config.json")?; Ok(()) } }
Conditional Execution
#![allow(unused)] fn main() { impl AdvancedMiddleware for ExpensiveMiddleware { async fn should_execute(&self, context: &MiddlewareContext) -> bool { // Only run for specific methods context.get_metadata("method") .map(|m| m.starts_with("tools/")) .unwrap_or(false) } async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // This only runs if should_execute returned true self.expensive_operation(request).await } } }
Performance Monitoring
#![allow(unused)] fn main() { use pmcp::shared::PerformanceMetrics; let context = MiddlewareContext::default(); // Metrics are automatically collected chain.process_request_with_context(&mut request, &context).await?; // Access metrics let metrics = context.metrics; tracing::info!( "Requests: {}, Errors: {}, Avg time: {:?}", metrics.request_count(), metrics.error_count(), metrics.average_time() ); }
Examples
Example 1: Basic Middleware Chain
See examples/15_middleware.rs:
use pmcp::shared::{MiddlewareChain, LoggingMiddleware}; use std::sync::Arc; use tracing::Level; #[tokio::main] async fn main() -> pmcp::Result<()> { tracing_subscriber::fmt::init(); // Create middleware chain let mut middleware = MiddlewareChain::new(); middleware.add(Arc::new(LoggingMiddleware::new(Level::DEBUG))); middleware.add(Arc::new(TimingMiddleware::new())); // Use with transport/client // (middleware integration is transport-specific) Ok(()) }
Example 2: Enhanced Middleware System
See examples/30_enhanced_middleware.rs:
use pmcp::shared::{ EnhancedMiddlewareChain, MiddlewareContext, RateLimitMiddleware, CircuitBreakerMiddleware, MetricsMiddleware, CompressionMiddleware, CompressionType, }; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() -> pmcp::Result<()> { tracing_subscriber::fmt().init(); // Create enhanced chain let mut chain = EnhancedMiddlewareChain::new(); // Add middleware (auto-sorted by priority) chain.add(Arc::new(ValidationMiddleware::new(false))); chain.add(Arc::new(RateLimitMiddleware::new(5, 10, Duration::from_secs(1)))); chain.add(Arc::new(CircuitBreakerMiddleware::new( 3, Duration::from_secs(10), Duration::from_secs(5) ))); chain.add(Arc::new(MetricsMiddleware::new("my-service".to_string()))); chain.add(Arc::new(CompressionMiddleware::new(CompressionType::Gzip, 1024))); tracing::info!("Middleware chain configured with {} middleware", chain.len()); // Create context let context = MiddlewareContext::with_request_id("req-001".to_string()); // Process requests let mut request = create_test_request(); chain.process_request_with_context(&mut request, &context).await?; Ok(()) }
Example 3: Custom Validation Middleware
#![allow(unused)] fn main() { use pmcp::shared::{AdvancedMiddleware, MiddlewareContext, MiddlewarePriority}; use async_trait::async_trait; // Uses your preferred JSON Schema library (e.g., jsonschema) struct SchemaValidationMiddleware { schemas: Arc<HashMap<String, JsonSchema>>, } #[async_trait] impl AdvancedMiddleware for SchemaValidationMiddleware { fn name(&self) -> &'static str { "schema_validation" } fn priority(&self) -> MiddlewarePriority { MiddlewarePriority::Critical } async fn on_request_with_context( &self, request: &mut JSONRPCRequest, context: &MiddlewareContext, ) -> pmcp::Result<()> { // Get schema for this method let schema = self.schemas.get(&request.method) .ok_or_else(|| pmcp::Error::Validation( format!("No schema for method: {}", request.method) ))?; // Validate params against schema if let Some(ref params) = request.params { schema.validate(params).map_err(|e| { context.record_metric("schema_validation_failed".to_string(), 1.0); pmcp::Error::Validation(format!("Schema validation failed: {}", e)) })?; } context.record_metric("schema_validation_passed".to_string(), 1.0); Ok(()) } } }
Summary
Key Takeaways
- Two Middleware Systems: Basic
Middlewarefor simple cases,AdvancedMiddlewarefor production - Priority Ordering: Control execution order with
MiddlewarePriority - Context Propagation: Share data and metrics with
MiddlewareContext - Built-in Patterns: Rate limiting, circuit breakers, metrics, compression
- Conditional Execution:
should_execute()for selective middleware - Performance: Use
should_execute(), async operations, and metrics tracking
When to Use Each System
Basic Middleware (MiddlewareChain):
- Simple logging or tracing
- Development and debugging
- Lightweight request modification
Advanced Middleware (EnhancedMiddlewareChain):
- Production deployments
- Complex ordering requirements
- Performance monitoring
- Fault tolerance patterns (rate limiting, circuit breakers)
- Context-dependent behavior
Best Practices
- Keep Middleware Focused: Single responsibility per middleware
- Order Matters: Validation → Protection → Logic → Observation
- Use Priorities: Let
EnhancedMiddlewareChainauto-sort - Conditional Execution: Skip expensive operations when possible
- Monitor Performance: Use
PerformanceMetricsand context - Handle Errors Gracefully: Implement
on_error()for cleanup - Test in Isolation: Unit test middleware independently
Examples Reference
examples/15_middleware.rs: Basic middleware chainexamples/30_enhanced_middleware.rs: Advanced patterns with built-in middleware- Inline doctests in
src/shared/middleware.rsdemonstrate each middleware
HTTP-Level Middleware
HTTP-level middleware operates at the HTTP transport layer, before MCP protocol processing. This is useful for header injection, authentication, compression, and other HTTP-specific concerns.
Architecture: Two-Layer Middleware System
PMCP has two distinct middleware layers:
┌─────────────────────────────────────────────────────────────┐
│ Client Application │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Protocol-Level Middleware (AdvancedMiddleware) │
│ - Operates on JSONRPCRequest/JSONRPCResponse │
│ - LoggingMiddleware, MetricsMiddleware, ValidationMiddleware│
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ HTTP-Level Middleware (HttpMiddleware) │
│ - Operates on HTTP request/response │
│ - OAuthClientMiddleware, header injection, compression │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ HTTP Transport (StreamableHttpTransport) │
└─────────────────────────────────────────────────────────────┘
Key principle: Middleware doesn’t run twice. Protocol-level operates on JSON-RPC messages, HTTP-level operates on HTTP requests.
HttpMiddleware Trait
#![allow(unused)] fn main() { use pmcp::client::http_middleware::{HttpMiddleware, HttpRequest, HttpResponse, HttpMiddlewareContext}; use async_trait::async_trait; #[async_trait] pub trait HttpMiddleware: Send + Sync { /// Called before HTTP request is sent async fn on_request( &self, request: &mut HttpRequest, context: &HttpMiddlewareContext, ) -> pmcp::Result<()> { Ok(()) } /// Called after HTTP response is received async fn on_response( &self, response: &mut HttpResponse, context: &HttpMiddlewareContext, ) -> pmcp::Result<()> { Ok(()) } /// Called when an error occurs async fn on_error( &self, error: &pmcp::Error, context: &HttpMiddlewareContext, ) -> pmcp::Result<()> { Ok(()) } /// Priority for ordering (lower runs first) fn priority(&self) -> i32 { 50 // Default priority } /// Should this middleware execute for this context? async fn should_execute(&self, _context: &HttpMiddlewareContext) -> bool { true } } }
HttpRequest and HttpResponse
Simplified HTTP representations for middleware:
#![allow(unused)] fn main() { pub struct HttpRequest { pub method: String, // "GET", "POST", etc. pub url: String, pub headers: HashMap<String, String>, pub body: Vec<u8>, } pub struct HttpResponse { pub status: u16, pub headers: HashMap<String, String>, pub body: Vec<u8>, } }
HttpMiddlewareContext
Context for HTTP middleware execution:
#![allow(unused)] fn main() { pub struct HttpMiddlewareContext { pub request_id: Option<String>, pub url: String, pub method: String, pub attempt: u32, pub metadata: Arc<RwLock<HashMap<String, String>>>, } // Usage let context = HttpMiddlewareContext::new(url.to_string(), "POST".to_string()); context.set_metadata("user_id".to_string(), "user-123".to_string()); let user_id = context.get_metadata("user_id"); }
OAuthClientMiddleware
Built-in OAuth middleware for automatic token injection:
#![allow(unused)] fn main() { use pmcp::client::oauth_middleware::{OAuthClientMiddleware, BearerToken}; use std::time::Duration; // Create bearer token let token = BearerToken::new("my-api-token".to_string()); // Or with expiration let token = BearerToken::with_expiry( "my-api-token".to_string(), Duration::from_secs(3600) // 1 hour ); // Create OAuth middleware let oauth_middleware = OAuthClientMiddleware::new(token); // Add to HttpMiddlewareChain let mut http_chain = HttpMiddlewareChain::new(); http_chain.add(Arc::new(oauth_middleware)); }
Features:
- Automatic token injection into
Authorizationheader - Token expiry checking before each request
- 401/403 detection for token refresh triggers
- OAuth precedence policy (respects transport auth_provider)
OAuth Precedence Policy
To avoid duplicate authentication, OAuth middleware follows this precedence:
1. Transport auth_provider (highest priority)
↓
2. HttpMiddleware OAuth (OAuthClientMiddleware)
↓
3. Extra headers from config (lowest priority)
The middleware checks auth_already_set metadata to skip injection when transport auth is configured:
#![allow(unused)] fn main() { // OAuth middleware checks metadata if context.get_metadata("auth_already_set").is_some() { // Skip - transport auth_provider takes precedence return Ok(()); } // Also skips if Authorization header already present if request.has_header("Authorization") { // Warn about duplicate auth configuration return Ok(()); } }
Example: Custom HTTP Middleware
#![allow(unused)] fn main() { use pmcp::client::http_middleware::{HttpMiddleware, HttpRequest, HttpMiddlewareContext}; use async_trait::async_trait; /// Adds custom correlation headers struct CorrelationHeaderMiddleware { service_name: String, } #[async_trait] impl HttpMiddleware for CorrelationHeaderMiddleware { async fn on_request( &self, request: &mut HttpRequest, context: &HttpMiddlewareContext, ) -> pmcp::Result<()> { // Add service name header request.add_header("X-Service-Name".to_string(), self.service_name.clone()); // Add timestamp header request.add_header( "X-Request-Timestamp".to_string(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() .to_string(), ); // Add request ID if available if let Some(req_id) = &context.request_id { request.add_header("X-Request-ID".to_string(), req_id.clone()); } Ok(()) } fn priority(&self) -> i32 { 20 // Run after OAuth (priority 10) } } }
Integration with ClientBuilder
Use ClientBuilder::with_middleware() for protocol-level middleware:
#![allow(unused)] fn main() { use pmcp::ClientBuilder; use pmcp::shared::{MetricsMiddleware, LoggingMiddleware}; use std::sync::Arc; let transport = /* your transport */; let client = ClientBuilder::new(transport) .with_middleware(Arc::new(MetricsMiddleware::new("my-client".to_string()))) .with_middleware(Arc::new(LoggingMiddleware::default())) .build(); }
Note: HTTP middleware is configured separately on the transport (StreamableHttpTransport), not via ClientBuilder.
Integration with StreamableHttpTransport
Configure HTTP middleware when creating the transport:
#![allow(unused)] fn main() { use pmcp::shared::{StreamableHttpTransport, StreamableHttpConfig}; use pmcp::client::http_middleware::HttpMiddlewareChain; use pmcp::client::oauth_middleware::{OAuthClientMiddleware, BearerToken}; use std::sync::Arc; use std::time::Duration; // Create HTTP middleware chain let mut http_chain = HttpMiddlewareChain::new(); // Add OAuth middleware let token = BearerToken::with_expiry( "api-token-12345".to_string(), Duration::from_secs(3600) ); http_chain.add(Arc::new(OAuthClientMiddleware::new(token))); // Add custom middleware http_chain.add(Arc::new(CorrelationHeaderMiddleware { service_name: "my-client".to_string(), })); // Create transport config with HTTP middleware let config = StreamableHttpConfig::new("https://api.example.com".to_string()) .with_http_middleware(Arc::new(http_chain)); let transport = StreamableHttpTransport::with_config(config).await?; }
Complete Example: OAuth + Protocol Middleware
use pmcp::{ClientBuilder, ClientCapabilities}; use pmcp::shared::{StreamableHttpTransport, StreamableHttpConfig, MetricsMiddleware}; use pmcp::client::http_middleware::HttpMiddlewareChain; use pmcp::client::oauth_middleware::{OAuthClientMiddleware, BearerToken}; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() -> pmcp::Result<()> { // 1. Create HTTP middleware chain let mut http_chain = HttpMiddlewareChain::new(); // Add OAuth (priority 10 - runs first) let token = BearerToken::with_expiry( std::env::var("API_TOKEN")?, Duration::from_secs(3600) ); http_chain.add(Arc::new(OAuthClientMiddleware::new(token))); // Add correlation headers (priority 20 - runs after OAuth) http_chain.add(Arc::new(CorrelationHeaderMiddleware { service_name: "my-service".to_string(), })); // 2. Create transport with HTTP middleware let config = StreamableHttpConfig::new("https://api.example.com".to_string()) .with_http_middleware(Arc::new(http_chain)); let transport = StreamableHttpTransport::with_config(config).await?; // 3. Create client with protocol middleware let client = ClientBuilder::new(transport) .with_middleware(Arc::new(MetricsMiddleware::new("my-client".to_string()))) .build(); // 4. Use client - both middleware layers automatically apply let mut client = client; let init_result = client.initialize(ClientCapabilities::minimal()).await?; println!("Connected: {}", init_result.server_info.name); Ok(()) }
Middleware Execution Flow
For a typical HTTP POST request:
1. Client.call_tool(name, args)
↓
2. Protocol Middleware (Request):
- MetricsMiddleware::on_request()
- LoggingMiddleware::on_request()
↓
3. Transport serialization (JSON-RPC → bytes)
↓
4. HTTP Middleware (Request):
- OAuthClientMiddleware::on_request() → Add Authorization header
- CorrelationHeaderMiddleware::on_request() → Add X-Service-Name, X-Request-ID
↓
5. HTTP Transport sends POST request
↓
6. HTTP Transport receives response
↓
7. HTTP Middleware (Response) [reverse order]:
- CorrelationHeaderMiddleware::on_response()
- OAuthClientMiddleware::on_response() → Check for 401
↓
8. Transport deserialization (bytes → JSON-RPC)
↓
9. Protocol Middleware (Response) [reverse order]:
- LoggingMiddleware::on_response()
- MetricsMiddleware::on_response()
↓
10. Client receives result
Error Handling
Both middleware layers support error hooks:
#![allow(unused)] fn main() { #[async_trait] impl HttpMiddleware for MyMiddleware { async fn on_error( &self, error: &pmcp::Error, context: &HttpMiddlewareContext, ) -> pmcp::Result<()> { // Log error with context tracing::error!( "HTTP error for {} {}: {}", context.method, context.url, error ); // Clean up resources if needed self.cleanup().await; Ok(()) } } }
Short-circuit behavior: If middleware returns an error:
- Processing stops immediately
on_error()is called for ALL middleware in the chain- Original error is returned to caller
Middleware Priority Reference
HTTP Middleware:
0-9: Reserved for critical security middleware10: OAuthClientMiddleware (default)20-49: Custom authentication/authorization50: Default priority51-99: Logging, metrics, headers
Protocol Middleware:
Critical (0): Validation, securityHigh (1): Rate limiting, circuit breakersNormal (2): Business logic, compressionLow (3): Metrics, loggingLowest (4): Cleanup
Server Middleware
Server middleware provides the same two-layer architecture for MCP servers: protocol-level (JSON-RPC) and HTTP-level (transport).
Architecture: Server Two-Layer Middleware
┌─────────────────────────────────────────────────────────────┐
│ Client HTTP Request │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Server HTTP Middleware (ServerHttpMiddleware) │
│ - Operates on HTTP request/response │
│ - ServerHttpLoggingMiddleware, auth verification, CORS │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Protocol-Level Middleware (AdvancedMiddleware) │
│ - Operates on JSONRPCRequest/JSONRPCResponse │
│ - MetricsMiddleware, validation, business logic │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Server Request Handler │
└─────────────────────────────────────────────────────────────┘
Execution order:
- Request: HTTP middleware → Protocol middleware → Handler
- Response: Handler → Protocol middleware → HTTP middleware
- Notification: Protocol middleware only (SSE streaming)
ServerHttpMiddleware Trait
HTTP-level middleware for server transport:
#![allow(unused)] fn main() { use pmcp::server::http_middleware::{ ServerHttpMiddleware, ServerHttpRequest, ServerHttpResponse, ServerHttpContext }; use async_trait::async_trait; #[async_trait] pub trait ServerHttpMiddleware: Send + Sync { /// Called before processing HTTP request async fn on_request( &self, request: &mut ServerHttpRequest, context: &ServerHttpContext, ) -> pmcp::Result<()> { Ok(()) } /// Called after generating HTTP response async fn on_response( &self, response: &mut ServerHttpResponse, context: &ServerHttpContext, ) -> pmcp::Result<()> { Ok(()) } /// Called when an error occurs async fn on_error( &self, error: &pmcp::Error, context: &ServerHttpContext, ) -> pmcp::Result<()> { Ok(()) } /// Priority for ordering (lower runs first) fn priority(&self) -> i32 { 50 // Default priority } /// Should this middleware execute? async fn should_execute(&self, _context: &ServerHttpContext) -> bool { true } } }
ServerHttpRequest and ServerHttpResponse
Simplified HTTP representations with HeaderMap support:
#![allow(unused)] fn main() { use hyper::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; pub struct ServerHttpRequest { pub method: Method, pub uri: Uri, pub headers: HeaderMap<HeaderValue>, pub body: Vec<u8>, } pub struct ServerHttpResponse { pub status: StatusCode, pub headers: HeaderMap<HeaderValue>, pub body: Vec<u8>, } // Helper methods impl ServerHttpRequest { pub fn get_header(&self, name: &str) -> Option<&str>; pub fn add_header(&mut self, name: &str, value: &str); } impl ServerHttpResponse { pub fn new(status: StatusCode, headers: HeaderMap<HeaderValue>, body: Vec<u8>) -> Self; pub fn get_header(&self, name: &str) -> Option<&str>; pub fn add_header(&mut self, name: &str, value: &str); } }
ServerHttpContext
Context for server HTTP middleware:
#![allow(unused)] fn main() { pub struct ServerHttpContext { pub request_id: String, pub session_id: Option<String>, pub start_time: std::time::Instant, } impl ServerHttpContext { /// Get elapsed time since request started pub fn elapsed(&self) -> std::time::Duration; } }
ServerHttpLoggingMiddleware
Built-in logging middleware with sensitive data redaction:
#![allow(unused)] fn main() { use pmcp::server::http_middleware::ServerHttpLoggingMiddleware; // Create with secure defaults let logging = ServerHttpLoggingMiddleware::new() .with_level(tracing::Level::INFO) // Log level .with_redact_query(true) // Strip query params .with_max_body_bytes(1024); // Log first 1KB of body // Default sensitive headers are redacted: // - authorization, cookie, x-api-key, x-amz-security-token, x-goog-api-key // Add custom redacted header let logging = logging.redact_header("x-internal-token"); // Allow specific header (use with caution!) let logging = logging.allow_header("x-debug-header"); }
Features:
- Secure by default: Redacts authorization, cookies, API keys
- Query stripping: Optionally redact query parameters
- Body gating: Only log safe content types (JSON, text)
- SSE detection: Skips body logging for
text/event-stream - Multi-value headers: Preserves all header values
Use cases: Request/response visibility, debugging, audit trails, compliance.
ServerPreset: Default Middleware Bundles
ServerPreset provides pre-configured middleware for common server scenarios:
#![allow(unused)] fn main() { use pmcp::server::preset::ServerPreset; use pmcp::server::builder::ServerCoreBuilder; use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; use std::sync::Arc; use tokio::sync::Mutex; // Create preset with defaults let preset = ServerPreset::default(); // Build server with protocol middleware let server = ServerCoreBuilder::new() .name("my-server") .version("1.0.0") .protocol_middleware(preset.protocol_middleware()) .build()?; // Create HTTP server with HTTP middleware let config = StreamableHttpServerConfig { http_middleware: preset.http_middleware(), ..Default::default() }; let http_server = StreamableHttpServer::with_config( "127.0.0.1:3000".parse().unwrap(), Arc::new(Mutex::new(server)), config, ); }
Defaults:
- Protocol:
MetricsMiddleware(tracks requests, durations, errors) - HTTP:
ServerHttpLoggingMiddleware(INFO level, redaction enabled)
Opt-in customization:
#![allow(unused)] fn main() { use pmcp::shared::middleware::RateLimitMiddleware; use std::time::Duration; // Add rate limiting (protocol layer) let preset = ServerPreset::new("my-service") .with_rate_limit(RateLimitMiddleware::new(100, 100, Duration::from_secs(60))); // Add custom HTTP middleware (transport layer) let preset = preset.with_http_middleware_item(MyCustomMiddleware); }
Example: Custom Server HTTP Middleware
#![allow(unused)] fn main() { use pmcp::server::http_middleware::{ServerHttpMiddleware, ServerHttpRequest, ServerHttpContext}; use async_trait::async_trait; /// Adds CORS headers for browser clients struct CorsMiddleware { allowed_origins: Vec<String>, } #[async_trait] impl ServerHttpMiddleware for CorsMiddleware { async fn on_response( &self, response: &mut ServerHttpResponse, context: &ServerHttpContext, ) -> pmcp::Result<()> { // Add CORS headers response.add_header( "Access-Control-Allow-Origin", &self.allowed_origins.join(", ") ); response.add_header( "Access-Control-Allow-Methods", "GET, POST, OPTIONS" ); response.add_header( "Access-Control-Allow-Headers", "Content-Type, Authorization" ); Ok(()) } fn priority(&self) -> i32 { 90 // Run late (after logging) } } }
Integration with StreamableHttpServer
Configure middleware when creating the server:
#![allow(unused)] fn main() { use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; use pmcp::server::http_middleware::ServerHttpMiddlewareChain; use pmcp::server::preset::ServerPreset; use std::sync::Arc; use tokio::sync::Mutex; // Option 1: Use ServerPreset (recommended) let preset = ServerPreset::new("my-service"); let server = ServerCoreBuilder::new() .name("my-server") .version("1.0.0") .protocol_middleware(preset.protocol_middleware()) .build()?; let config = StreamableHttpServerConfig { http_middleware: preset.http_middleware(), ..Default::default() }; let http_server = StreamableHttpServer::with_config( "127.0.0.1:3000".parse().unwrap(), Arc::new(Mutex::new(server)), config, ); // Option 2: Custom HTTP middleware chain let mut http_chain = ServerHttpMiddlewareChain::new(); http_chain.add(Arc::new(ServerHttpLoggingMiddleware::new())); http_chain.add(Arc::new(CorsMiddleware { allowed_origins: vec!["https://example.com".to_string()], })); let config = StreamableHttpServerConfig { http_middleware: Some(Arc::new(http_chain)), ..Default::default() }; }
Server Middleware Execution Flow
For a typical HTTP POST request:
1. HTTP POST arrives at StreamableHttpServer
↓
2. Server HTTP Middleware (Request):
- ServerHttpLoggingMiddleware::on_request() → Log request
- CorsMiddleware::on_request() → Check origin
↓
3. Deserialize JSON-RPC from body
↓
4. Protocol Middleware (Request):
- MetricsMiddleware::on_request() → Increment counter
- ValidationMiddleware::on_request() → Validate method
↓
5. Server Request Handler (tool/prompt/resource)
↓
6. Protocol Middleware (Response) [reverse order]:
- ValidationMiddleware::on_response()
- MetricsMiddleware::on_response() → Record duration
↓
7. Serialize JSON-RPC to body
↓
8. Server HTTP Middleware (Response) [reverse order]:
- CorsMiddleware::on_response() → Add CORS headers
- ServerHttpLoggingMiddleware::on_response() → Log response
↓
9. HTTP response sent to client
Protocol Middleware Ordering
Protocol middleware execution order is determined by priority:
#![allow(unused)] fn main() { use pmcp::shared::middleware::MiddlewarePriority; pub enum MiddlewarePriority { Critical = 0, // Validation, security - executed first High = 1, // Rate limiting, circuit breakers Normal = 2, // Business logic, transformation Low = 3, // Metrics Lowest = 4, // Logging, cleanup } }
Request order: Lower priority value executes first (Critical → High → Normal → Low → Lowest) Response order: Reverse (Lowest → Low → Normal → High → Critical) Notification order: Same as request (Critical → High → Normal → Low → Lowest)
SSE Notification Routing
For SSE streaming, notifications go through protocol middleware only:
1. Server generates notification (e.g., progress update)
↓
2. Protocol Middleware (Notification):
- ValidationMiddleware::on_notification()
- MetricsMiddleware::on_notification()
↓
3. Serialize as SSE event
↓
4. Stream to client (no HTTP middleware - already connected)
Note: HTTP middleware does NOT run for SSE notifications, only for the initial connection.
Fast-Path Optimization
StreamableHttpServer uses a fast path when no HTTP middleware is configured:
#![allow(unused)] fn main() { // Fast path: No HTTP middleware if config.http_middleware.is_none() { // Direct JSON-RPC parsing, zero copies return handle_post_fast_path(state, request).await; } // Middleware path: Full chain processing handle_post_with_middleware(state, request).await }
Performance: Fast path skips HTTP request/response conversions entirely.
Example: Complete Server Setup
use pmcp::server::preset::ServerPreset; use pmcp::server::builder::ServerCoreBuilder; use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig}; use pmcp::server::ToolHandler; use pmcp::shared::middleware::RateLimitMiddleware; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; #[tokio::main] async fn main() -> pmcp::Result<()> { // 1. Create preset with defaults + rate limiting let preset = ServerPreset::new("my-api-server") .with_rate_limit(RateLimitMiddleware::new(100, 100, Duration::from_secs(60))); // 2. Build server with protocol middleware let server = ServerCoreBuilder::new() .name("my-api-server") .version("1.0.0") .tool("echo", EchoTool) .tool("calculate", CalculateTool) .protocol_middleware(preset.protocol_middleware()) .build()?; // 3. Create HTTP server with HTTP middleware let config = StreamableHttpServerConfig { http_middleware: preset.http_middleware(), session_id_generator: Some(Box::new(|| { format!("session-{}", uuid::Uuid::new_v4()) })), ..Default::default() }; let http_server = StreamableHttpServer::with_config( "127.0.0.1:3000".parse().unwrap(), Arc::new(Mutex::new(server)), config, ); // 4. Start server let (addr, handle) = http_server.start().await?; println!("Server listening on: {}", addr); // Server now has: // - HTTP logging with redaction // - Protocol metrics collection // - Rate limiting (100 req/min) // - Session management handle.await?; Ok(()) }
Best Practices
- Use ServerPreset for common cases: Defaults cover most production needs
- HTTP middleware for transport concerns: Auth, CORS, logging, compression
- Protocol middleware for business logic: Validation, metrics, rate limiting
- Order matters:
- HTTP: Auth → CORS → Logging
- Protocol: Validation → Rate Limit → Metrics
- Redaction is critical: Never log sensitive headers/query params
- Fast path for performance: Omit HTTP middleware if not needed
- SSE optimization: Don’t buffer bodies for
text/event-stream
Server Middleware Priority Reference
HTTP Middleware:
0-9: Reserved for critical security middleware10-29: Authentication, authorization30-49: CORS, security headers50: Default priority (ServerHttpLoggingMiddleware)51-99: Metrics, custom headers
Protocol Middleware (same as client):
Critical (0): Validation, securityHigh (1): Rate limiting, circuit breakersNormal (2): Business logicLow (3): MetricsLowest (4): Logging, cleanup
Further Reading
- Repository docs:
docs/advanced/middleware-composition.md - Advanced Middleware API: https://docs.rs/pmcp/latest/pmcp/shared/middleware/
- Performance Metrics API: https://docs.rs/pmcp/latest/pmcp/shared/middleware/struct.PerformanceMetrics.html
- Example:
examples/40_middleware_demo.rs- Complete two-layer middleware demonstration
Progress Reporting and Cancellation
Long-running operations require two critical capabilities: progress tracking so users know what’s happening, and cancellation so users can stop operations that are taking too long or are no longer needed.
This is similar to web applications where you show a progress bar or spinning wheel while the server processes a request - it gives users confidence that work is happening and an estimate of how long to wait.
This chapter covers the PMCP SDK’s comprehensive support for both features, following the MCP protocol specifications for progress notifications and request cancellation.
Overview
Why Progress Matters
When a tool processes large datasets, downloads files, or performs complex calculations, users need feedback:
- Visibility: “Is it still working or stuck?”
- Time estimation: “How long until it’s done?”
- Responsiveness: “Should I wait or cancel?”
Without progress updates, long operations feel like black boxes.
Why Cancellation Matters
Users should be able to interrupt operations that:
- Are taking longer than expected
- Were started by mistake
- Are no longer needed (user changed their mind)
- Are consuming too many resources
Proper cancellation prevents wasted work and improves user experience.
Progress Reporting
The PMCP SDK provides a trait-based progress reporting system with automatic rate limiting and validation.
The ProgressReporter Trait
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::error::Result; #[async_trait] pub trait ProgressReporter: Send + Sync { /// Report progress with optional total and message async fn report_progress( &self, progress: f64, total: Option<f64>, message: Option<String>, ) -> Result<()>; /// Report percentage progress (0-100) async fn report_percent(&self, percent: f64, message: Option<String>) -> Result<()> { self.report_progress(percent, Some(100.0), message).await } /// Report count-based progress (e.g., "5 of 10 items processed") async fn report_count( &self, current: usize, total: usize, message: Option<String>, ) -> Result<()> { self.report_progress(current as f64, Some(total as f64), message).await } } }
ServerProgressReporter
The SDK provides a production-ready implementation with several key features:
Features:
- ✅ Rate limiting - Max 10 notifications/second by default (configurable)
- ✅ Float validation - Rejects NaN, infinity, negative values
- ✅ Epsilon comparisons - Handles floating-point precision issues
- ✅ Non-increasing progress handling - Silently ignores backwards progress (no-op)
- ✅ Final notification bypass - Last update always sent, bypassing rate limits
- ✅ Thread-safe - Clone and share across tasks
Validation Rules:
- Progress must be finite and non-negative
- Total (if provided) must be finite and non-negative
- Progress cannot exceed total (with epsilon tolerance)
- Progress should increase (non-increasing updates are no-ops)
Request Metadata and Progress Tokens
The MCP protocol uses the _meta field to pass request-level metadata, including progress tokens.
RequestMeta Structure
#![allow(unused)] fn main() { use pmcp::types::{RequestMeta, ProgressToken}; pub struct RequestMeta { /// Progress token for out-of-band progress notifications pub progress_token: Option<ProgressToken>, } pub enum ProgressToken { String(String), Number(i64), } }
Sending Requests with Progress Tokens
Clients include progress tokens in request metadata:
#![allow(unused)] fn main() { use pmcp::types::{CallToolRequest, RequestMeta, ProgressToken}; use serde_json::json; let request = CallToolRequest { name: "process_data".to_string(), arguments: json!({ "dataset": "large.csv" }), _meta: Some(RequestMeta { progress_token: Some(ProgressToken::String("task-123".to_string())), }), }; }
Automatic Progress Reporter Creation (Available in v1.9+):
When a client includes _meta.progressToken in a request, the server automatically:
- Extracts the token from the request
- Creates a
ServerProgressReporter - Attaches it to
RequestHandlerExtra
If no token is provided, the progress helper methods simply return Ok(()) (no-op).
Note: On versions before v1.9, progress helper methods will no-op unless you manually attach a reporter. The automatic wiring described above is available in v1.9 and later.
Using Progress in Tools
The SDK makes progress reporting simple through RequestHandlerExtra.
Basic Progress Reporting
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::error::Result; use pmcp::server::cancellation::RequestHandlerExtra; use pmcp::server::ToolHandler; use serde_json::{json, Value}; struct DataProcessor; #[async_trait] impl ToolHandler for DataProcessor { async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> { let total_items = 100; for i in 0..total_items { // Process item process_item(i).await?; // Report progress (no-op if no reporter attached) extra.report_count( i + 1, total_items, Some(format!("Processed item {}", i + 1)) ).await?; } Ok(json!({"processed": total_items})) } } }
Progress Helper Methods
RequestHandlerExtra provides three convenience methods:
#![allow(unused)] fn main() { // 1. Generic progress (any scale) extra.report_progress(current, Some(total), Some(message)).await?; // 2. Percentage (0-100 scale) extra.report_percent(75.0, Some("75% complete")).await?; // 3. Count-based (items processed) extra.report_count(75, 100, Some("75 of 100 items")).await?; }
Important: All methods return Ok(()) if no progress reporter is attached, so you can always call them unconditionally. You don’t need to check if a reporter exists - the SDK handles it for you automatically.
Request Cancellation
The SDK uses tokio_util::sync::CancellationToken for async-safe cancellation.
Checking for Cancellation
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::error::{Error, Result}; use pmcp::server::cancellation::RequestHandlerExtra; use pmcp::server::ToolHandler; use serde_json::{json, Value}; struct LongRunningTool; #[async_trait] impl ToolHandler for LongRunningTool { async fn handle(&self, _args: Value, extra: RequestHandlerExtra) -> Result<Value> { for i in 0..1000 { // Check for cancellation if extra.is_cancelled() { return Err(Error::internal("Operation cancelled by client")); } // Do work process_chunk(i).await?; } Ok(json!({"status": "completed"})) } } }
Async Cancellation Waiting
For more sophisticated patterns, you can await cancellation:
#![allow(unused)] fn main() { use tokio::select; async fn handle(&self, _args: Value, extra: RequestHandlerExtra) -> Result<Value> { select! { result = perform_long_operation() => { // Operation completed Ok(json!({"result": result?})) } _ = extra.cancelled() => { // Cancellation received Err(Error::internal("Operation cancelled")) } } } }
Complete Example: Countdown Tool
Let’s walk through a complete example that demonstrates both progress and cancellation.
Tool Implementation
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::error::Result; use pmcp::server::cancellation::RequestHandlerExtra; use pmcp::server::ToolHandler; use serde_json::{json, Value}; use std::time::Duration; struct CountdownTool; #[async_trait] impl ToolHandler for CountdownTool { async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> { // Extract starting number let start = args.get("from") .and_then(|v| v.as_u64()) .unwrap_or(10) as usize; // Count down from start to 0 for i in (0..=start).rev() { // Check for cancellation if extra.is_cancelled() { return Err(pmcp::error::Error::internal( "Countdown cancelled by client" )); } // Report progress (counting DOWN, so progress goes UP) let current = start - i; let message = if i == 0 { "Countdown complete! 🎉".to_string() } else { format!("Counting down: {}", i) }; extra.report_count(current, start, Some(message)).await?; // Sleep between counts (except at the end) if i > 0 { tokio::time::sleep(Duration::from_secs(1)).await; } } Ok(json!({ "result": "Countdown completed successfully", "from": start, })) } } }
Server Setup
#![allow(unused)] fn main() { use pmcp::server::Server; let server = Server::builder() .name("countdown-server") .version("1.0.0") .tool("countdown", CountdownTool) .build()?; }
Client Request with Progress Token
#![allow(unused)] fn main() { use pmcp::types::{CallToolRequest, RequestMeta, ProgressToken}; let request = CallToolRequest { name: "countdown".to_string(), arguments: json!({ "from": 5 }), _meta: Some(RequestMeta { progress_token: Some(ProgressToken::String("countdown-1".to_string())), }), }; }
Expected Output
INFO Starting countdown from 5
INFO Countdown: 5 (progress: 0/5)
INFO Countdown: 4 (progress: 1/5)
INFO Countdown: 3 (progress: 2/5)
INFO Countdown: 2 (progress: 3/5)
INFO Countdown: 1 (progress: 4/5)
INFO Countdown: 0 (progress: 5/5)
✅ Countdown completed!
Run the full example (available in v1.9+):
cargo run --example 11_progress_countdown
For earlier versions, see the basic examples:
- Progress notifications:
examples/10_progress_notifications.rs - Request cancellation: Check existing cancellation examples in the repository
Complete Example: Prompt Workflow with Progress
Best Practice: Long-running prompts should report progress, especially workflow prompts with multiple steps.
Progress reporting works identically for prompts as it does for tools - same API, same automatic setup. This is particularly important for workflow prompts where users need to understand which phase is executing.
When to Use Progress in Prompts
✅ Use progress reporting for:
- Multi-step workflows (analyze → plan → execute → verify)
- Long-running data processing or generation
- Prompts with multiple external API calls
- Complex reasoning chains with distinct phases
❌ Skip progress reporting for:
- Simple single-step prompts
- Fast operations (< 2 seconds)
- Prompts where steps aren’t clearly defined
Workflow Prompt Implementation
#![allow(unused)] fn main() { use async_trait::async_trait; use pmcp::error::Result; use pmcp::server::cancellation::RequestHandlerExtra; use pmcp::server::PromptHandler; use pmcp::types::{Content, GetPromptResult, PromptMessage, Role}; use std::collections::HashMap; use std::time::Duration; struct AnalysisWorkflowPrompt; #[async_trait] impl PromptHandler for AnalysisWorkflowPrompt { async fn handle( &self, args: HashMap<String, String>, extra: RequestHandlerExtra, // ← Same as tools! ) -> Result<GetPromptResult> { let topic = args.get("topic") .cloned() .unwrap_or_else(|| "general analysis".to_string()); // Define workflow steps let steps = vec![ ("gather", "Gathering information and context"), ("analyze", "Analyzing data and patterns"), ("synthesize", "Synthesizing insights"), ("validate", "Validating conclusions"), ("format", "Formatting final report"), ]; let mut results = Vec::new(); // Execute each step with progress reporting for (i, (step_name, step_description)) in steps.iter().enumerate() { // Check for cancellation if extra.is_cancelled() { return Err(pmcp::error::Error::internal( format!("Workflow cancelled during {} step", step_name) )); } // Report progress - same API as tools! extra.report_count( i + 1, steps.len(), Some(format!("Step {}/{}: {}", i + 1, steps.len(), step_description)) ).await?; // Simulate work for this step tokio::time::sleep(Duration::from_secs(1)).await; results.push(format!("✓ {} - {}", step_name, step_description)); } // Return prompt result Ok(GetPromptResult { description: Some(format!("Multi-step analysis workflow for: {}", topic)), messages: vec![PromptMessage { role: Role::User, content: Content::Text { text: format!( "Analysis Workflow Complete\n\nTopic: {}\n\nSteps:\n{}\n\nReady for review.", topic, results.join("\n") ), }, }], }) } } }
Server Setup
#![allow(unused)] fn main() { use pmcp::server::Server; let server = Server::builder() .name("workflow-server") .version("1.0.0") .prompt("analysis_workflow", AnalysisWorkflowPrompt) .build()?; }
Client Request with Progress Token
#![allow(unused)] fn main() { use pmcp::types::{GetPromptRequest, RequestMeta, ProgressToken}; use std::collections::HashMap; let request = GetPromptRequest { name: "analysis_workflow".to_string(), arguments: HashMap::from([ ("topic".to_string(), "Machine Learning".to_string()) ]), _meta: Some(RequestMeta { progress_token: Some(ProgressToken::String("workflow-1".to_string())), }), }; }
Expected Progress Updates
INFO Step 1/5: Gathering information and context
INFO Step 2/5: Analyzing data and patterns
INFO Step 3/5: Synthesizing insights
INFO Step 4/5: Validating conclusions
INFO Step 5/5: Formatting final report
✅ Workflow completed!
Run the full example (available in v1.9+):
cargo run --example 12_prompt_workflow_progress
Key Benefits
- User Visibility: Users see exactly which phase is executing
- Time Estimation: Clear progress (3/5 steps) shows time remaining
- Cancellation Points: Users can cancel between steps if needed
- Same API: No difference between tool and prompt progress reporting
Recommendation: Make progress reporting standard practice for all workflow prompts with 3+ steps or operations longer than 5 seconds.
End-to-End Flow
Understanding the complete flow helps debug issues and implement custom solutions.
Progress Notification Flow
-
Client sends request with
_meta.progressToken{ "method": "tools/call", "params": { "name": "process_data", "arguments": {"file": "data.csv"}, "_meta": { "progressToken": "task-123" } } } -
Server extracts token from request metadata
#![allow(unused)] fn main() { let progress_token = req._meta .as_ref() .and_then(|meta| meta.progress_token.as_ref()); } -
Server creates reporter and attaches to
RequestHandlerExtra#![allow(unused)] fn main() { let reporter = ServerProgressReporter::new( token.clone(), notification_sender, ); let extra = RequestHandlerExtra::new(request_id, cancellation_token) .with_progress_reporter(Some(Arc::new(reporter))); } -
Tool reports progress using helper methods
#![allow(unused)] fn main() { extra.report_count(50, 100, Some("Halfway done")).await?; } -
Reporter sends notification through notification channel
{ "method": "notifications/progress", "params": { "progressToken": "task-123", "progress": 50, "total": 100, "message": "Halfway done" } } -
Client receives notifications and updates UI
Cancellation Flow
-
Client sends cancellation notification
{ "method": "notifications/cancelled", "params": { "requestId": "123", "reason": "User cancelled operation" } } -
Server cancels the token silently (no echo back to client)
When the server receives a client-initiated cancellation, it cancels the token internally without sending a cancellation notification back to the client. The client already knows it cancelled the request, so echoing would be redundant.
#![allow(unused)] fn main() { // Server handles client cancellation silently cancellation_manager.cancel_request_silent(request_id).await?; } -
Tool checks cancellation in its loop
#![allow(unused)] fn main() { if extra.is_cancelled() { return Err(Error::internal("Cancelled")); } } -
Tool returns early with cancellation error
Best Practices
1. Always Report Final Progress
The final notification bypasses rate limiting and confirms completion:
#![allow(unused)] fn main() { // Good: Report 100% at the end for i in 0..100 { process_item(i).await?; extra.report_count(i + 1, 100, None).await?; } // Last call (100/100) always sends notification // Bad: Skip final progress for i in 0..100 { process_item(i).await?; if i < 99 { // ❌ Skips final update extra.report_count(i + 1, 100, None).await?; } } }
2. Check Cancellation Regularly
Check at least once per second of work:
#![allow(unused)] fn main() { // Good: Check in loop for (i, item) in large_dataset.iter().enumerate() { if extra.is_cancelled() { return Err(Error::internal("Cancelled")); } process(item).await?; extra.report_count(i + 1, large_dataset.len(), None).await?; } // Bad: Never check for item in large_dataset { process(item).await?; // ❌ Can't be cancelled } }
3. Provide Meaningful Progress Messages
#![allow(unused)] fn main() { // Good: Descriptive messages extra.report_count( processed, total, Some(format!("Processed {} of {} files", processed, total)) ).await?; // Acceptable: No message (progress bar is enough) extra.report_count(processed, total, None).await?; // Bad: Useless message extra.report_count(processed, total, Some("Working...".to_string())).await?; }
4. Handle Progress Errors Gracefully
Progress reporting failures shouldn’t crash your tool:
#![allow(unused)] fn main() { // Good: Log and continue if let Err(e) = extra.report_progress(current, total, msg).await { tracing::warn!("Failed to report progress: {}", e); // Continue processing } // Also good: Propagate if progress is critical extra.report_progress(current, total, msg).await?; }
5. Use Appropriate Progress Scales
Choose the right method for your use case:
#![allow(unused)] fn main() { // Count-based (items, files, records) extra.report_count(processed_files, total_files, msg).await?; // Percentage (0-100) extra.report_percent(completion_percentage, msg).await?; // Custom scale (bytes, seconds, etc.) extra.report_progress(bytes_downloaded, Some(total_bytes), msg).await?; }
6. Don’t Report Progress Too Frequently
The rate limiter protects against flooding, but be considerate:
#![allow(unused)] fn main() { // Good: Report on significant milestones for i in 0..10000 { process(i).await?; if i % 100 == 0 { // Every 100 items extra.report_count(i, 10000, None).await?; } } // Bad: Report on every iteration (will be rate-limited) for i in 0..10000 { process(i).await?; extra.report_count(i, 10000, None).await?; // Too frequent! } }
The default rate limit (100ms) means you can report ~10 times per second without throttling.
Advanced Patterns
Rate Limiting and Notification Debouncing
Progress notifications are rate-limited at the ServerProgressReporter level (default: max 10 notifications/second). This prevents flooding the client with updates.
Important: If you’re also using a notification debouncer elsewhere in your system, be aware that you’ll have double-throttling. It’s recommended to keep progress throttling in one place:
- Recommended: Use the reporter’s built-in rate limiting (it’s already there!)
- Advanced: If you need custom debouncing logic, disable reporter rate limiting and handle it in your notification pipeline
#![allow(unused)] fn main() { // Custom rate limit (20 notifications/second) let reporter = ServerProgressReporter::with_rate_limit( token, notification_sender, Duration::from_millis(50), // 50ms = 20/sec ); }
Progress with Nested Operations
When operations have sub-tasks, scale progress appropriately:
#![allow(unused)] fn main() { async fn handle(&self, _args: Value, extra: RequestHandlerExtra) -> Result<Value> { let tasks = vec!["download", "process", "upload"]; let total_steps = tasks.len(); for (i, task) in tasks.iter().enumerate() { match *task { "download" => { // Sub-task progress: 0-33% download_with_progress(&extra, 0.0, 33.0).await?; } "process" => { // Sub-task progress: 33-66% process_with_progress(&extra, 33.0, 66.0).await?; } "upload" => { // Sub-task progress: 66-100% upload_with_progress(&extra, 66.0, 100.0).await?; } _ => {} } // Report overall progress extra.report_count(i + 1, total_steps, Some(format!("Completed {}", task))).await?; } Ok(json!({"status": "all tasks completed"})) } }
Cancellation with Cleanup
Always clean up resources on cancellation:
#![allow(unused)] fn main() { async fn handle(&self, _args: Value, extra: RequestHandlerExtra) -> Result<Value> { let temp_file = create_temp_file().await?; let result = tokio::select! { result = process_file(&temp_file) => result, _ = extra.cancelled() => { // Cleanup on cancellation cleanup_temp_file(&temp_file).await?; return Err(Error::internal("Operation cancelled")); } }; // Normal cleanup cleanup_temp_file(&temp_file).await?; result } }
Custom Progress Reporters
Implement ProgressReporter for custom behavior:
#![allow(unused)] fn main() { use pmcp::server::progress::ProgressReporter; struct LoggingProgressReporter; #[async_trait] impl ProgressReporter for LoggingProgressReporter { async fn report_progress( &self, progress: f64, total: Option<f64>, message: Option<String>, ) -> Result<()> { let percentage = total.map(|t| (progress / t) * 100.0); tracing::info!( progress = progress, total = ?total, percentage = ?percentage, message = ?message, "Progress update" ); Ok(()) } } }
NoopProgressReporter (Advanced)
A no-op implementation that discards all progress reports. Most developers won’t need this because RequestHandlerExtra already handles missing reporters gracefully.
When you might need it:
- Testing code that takes ProgressReporter directly:
#![allow(unused)] fn main() { use pmcp::server::progress::{ProgressReporter, NoopProgressReporter}; use std::sync::Arc; async fn process_with_reporter(reporter: Arc<dyn ProgressReporter>) { reporter.report_progress(50.0, Some(100.0), None).await.unwrap(); } #[tokio::test] async fn test_processing() { let reporter = Arc::new(NoopProgressReporter); process_with_reporter(reporter).await; // No notifications sent } }
- Manual context construction without a real reporter.
Note: If you’re using RequestHandlerExtra, you don’t need this! The helper methods already return Ok(()) when no reporter is attached.
Testing Progress and Cancellation
Testing Progress Reporting
#![allow(unused)] fn main() { use pmcp::server::progress::{ProgressReporter, ServerProgressReporter}; use pmcp::types::ProgressToken; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; #[tokio::test] async fn test_progress_reporting() { let counter = Arc::new(AtomicUsize::new(0)); let counter_clone = counter.clone(); let reporter = ServerProgressReporter::with_rate_limit( ProgressToken::String("test".to_string()), Arc::new(move |_| { counter_clone.fetch_add(1, Ordering::SeqCst); }), Duration::ZERO, // No rate limiting for tests ); // Report progress reporter.report_count(1, 10, Some("Starting".to_string())).await.unwrap(); reporter.report_count(5, 10, Some("Halfway".to_string())).await.unwrap(); reporter.report_count(10, 10, Some("Done".to_string())).await.unwrap(); // Verify all notifications sent assert_eq!(counter.load(Ordering::SeqCst), 3); } }
Testing Cancellation
#![allow(unused)] fn main() { use pmcp::server::cancellation::RequestHandlerExtra; use tokio_util::sync::CancellationToken; #[tokio::test] async fn test_cancellation() { let token = CancellationToken::new(); let extra = RequestHandlerExtra::new("test".to_string(), token.clone()); // Not cancelled initially assert!(!extra.is_cancelled()); // Cancel the token token.cancel(); // Now cancelled assert!(extra.is_cancelled()); } }
Troubleshooting
Progress Not Appearing
Symptom: No progress notifications received by client
Checks:
- Version check: Are you using v1.9 or later? Automatic reporter wiring is only available in v1.9+. On earlier versions, progress helper methods will no-op unless you manually attach a reporter.
- Client sent
_meta.progressTokenin request? - Server has
notification_txchannel configured? - Progress values are valid (finite, non-negative)?
- Rate limiting not too aggressive? (check interval)
Cancellation Not Working
Symptom: Tool continues running after cancellation
Checks:
- Tool calls
extra.is_cancelled()regularly? - Tool doesn’t have blocking operations preventing cancellation checks?
- CancellationManager received the cancellation notification?
- Tool returns error on cancellation?
Rate Limiting Too Aggressive
Symptom: Some progress updates missing
Solution: Customize rate limit interval:
#![allow(unused)] fn main() { let reporter = ServerProgressReporter::with_rate_limit( token, notification_sender, Duration::from_millis(50), // 20 notifications/second ); }
Summary
The PMCP SDK provides production-ready progress reporting and cancellation:
Progress Features (v1.9+):
- ✅ Trait-based abstraction
- ✅ Automatic progress reporter creation from
_meta.progressToken - ✅ Automatic rate limiting (configurable, default 10 notifications/second)
- ✅ Float validation and epsilon comparisons
- ✅ Multiple convenience methods (progress/percent/count)
- ✅ Thread-safe and clone-able
- ✅ Graceful no-op when no reporter attached
Cancellation Features:
- ✅ Async-safe tokens (
tokio_util::sync::CancellationToken) - ✅ Easy integration with
RequestHandlerExtra - ✅ Silent cancellation (no echo back to client)
- ✅ Support for cleanup on cancellation
- ✅ Works with
tokio::select!for advanced patterns
Best Practices:
- Always report final progress (bypasses rate limits)
- Check cancellation regularly (at least once per second)
- Provide meaningful progress messages
- Choose appropriate progress scales (count/percent/custom)
- Clean up resources on cancellation
- Use built-in rate limiting (avoid double-throttling)
Examples:
- Complete countdown example (v1.9+):
examples/11_progress_countdown.rs - Basic progress:
examples/10_progress_notifications.rs
Version Notes:
- Automatic reporter wiring requires v1.9 or later
- On earlier versions, progress helpers no-op unless manually attached
Ch13 Production
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch14 Performance
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Chapter 15: Testing MCP Servers
Introduction
Testing is a critical aspect of building any software and specifically reliable MCP servers. Since the MCP ecosystem will have many more servers than clients (similar to how there are many more websites than web browsers), robust server testing is essential for ensuring:
- Protocol compliance - Your server correctly implements the MCP specification
- Capability correctness - Tools, resources, and prompts work as advertised
- Error handling - Graceful degradation under failure conditions
- Performance - Acceptable response times under load
- Integration - Compatibility with Claude and other MCP clients
This chapter covers the testing tools and strategies available for MCP server developers, from interactive browser-based testing to automated CI/CD integration.
Testing Philosophy
Why Focus on Server Testing?
The MCP ecosystem follows a similar pattern to web APIs:
- Many servers - Each organization/developer creates servers for their specific data sources
- Few clients - Claude Desktop, IDE integrations, and other standard clients
- Server diversity - Different languages, deployment models, and capabilities
- Client standardization - Clients follow the MCP spec consistently
Just as you would thoroughly test a REST API before deploying it, MCP servers need comprehensive testing to ensure they work correctly with any compliant MCP client.
Testing Pyramid for MCP Servers:
┌─────────────────┐
│ E2E Scenarios │ ← Full workflows with real clients
│ (mcp-tester) │
└─────────────────┘
┌───────────────────────┐
│ Integration Tests │ ← Tool/Resource/Prompt testing
│ (mcp-tester + unit) │
└───────────────────────┘
┌─────────────────────────────────┐
│ Unit Tests │ ← Handler logic, validation
│ (cargo test) │
└─────────────────────────────────┘
Official MCP Inspector
The MCP Inspector is the official visual testing tool provided by Anthropic for interactive debugging and exploration of MCP servers.
What is MCP Inspector?
The MCP Inspector consists of two components:
- MCP Inspector Client (MCPI) - React-based web UI for interactive testing
- MCP Proxy (MCPP) - Node.js server that acts as a protocol bridge
Repository: github.com/modelcontextprotocol/inspector
Installation and Usage
# One-off debugging (easiest)
npx @modelcontextprotocol/inspector <mcp-server-command>
# Example: Test a stdio server
npx @modelcontextprotocol/inspector node my-server/index.js
# Example: Test with environment variables
npx @modelcontextprotocol/inspector -- MY_VAR=value node server.js
Features
Interactive Browser Interface:
- Tool Discovery - Browse all available tools and their schemas
- Tool Execution - Call tools with custom arguments from a web form
- Resource Exploration - List and read available resources
- Prompt Testing - Test prompts with various argument combinations
- Real-time Feedback - See immediate responses and errors
- Protocol Visualization - View JSON-RPC messages sent/received
When to Use MCP Inspector:
✅ Good for:
- Interactive exploration of server capabilities
- Manual testing during development
- Debugging tool schemas and responses
- Understanding how tools behave
- Quick smoke tests
❌ Not ideal for:
- Automated testing in CI/CD
- Performance testing
- Comprehensive regression testing
- Testing multiple scenarios
- Batch testing of many tools
Security Note
Important: The MCP Inspector proxy requires authentication by default. When starting the server, a random session token is generated and printed to the console. Always use the latest version (0.14.1+) which includes important security fixes.
Example Session
# Start the inspector for a stdio server
npx @modelcontextprotocol/inspector cargo run --bin my-mcp-server
# Output:
# MCP Inspector is running on http://localhost:5173
# Session token: abc123...
# Open this URL in your browser to start testing
# Browser opens showing:
# - Server info (name, version, capabilities)
# - Tools tab with all available tools
# - Resources tab with available resources
# - Prompts tab with available prompts
# - Logs tab with protocol messages
MCP Server Tester (mcp-tester)
For automated, comprehensive, and CI-ready testing, the PMCP SDK provides the MCP Server Tester (mcp-tester) - a powerful command-line tool specifically designed for testing MCP servers in development and production.
Location: examples/26-server-tester/
Why mcp-tester?
Unlike the interactive MCP Inspector, mcp-tester is designed for:
- ✅ Automated testing - Run comprehensive test suites without manual interaction
- ✅ CI/CD integration - JSON output, exit codes, and scripting support
- ✅ Scenario testing - Define complex multi-step workflows in YAML/JSON
- ✅ Protocol compliance - Validate MCP protocol adherence
- ✅ Performance testing - Measure response times and throughput
- ✅ OAuth support - Test authenticated servers with automatic token management
- ✅ Multi-transport - Test HTTP, HTTPS, WebSocket, and stdio servers
Installation
Option 1: Download Pre-built Binaries (Recommended)
Pre-built binaries are available for Windows, macOS, and Linux from the GitHub Releases page:
# macOS (Apple Silicon)
curl -L https://github.com/paiml/rust-mcp-sdk/releases/latest/download/mcp-tester-macos-aarch64.tar.gz | tar xz
sudo mv mcp-tester /usr/local/bin/
# macOS (Intel)
curl -L https://github.com/paiml/rust-mcp-sdk/releases/latest/download/mcp-tester-macos-x86_64.tar.gz | tar xz
sudo mv mcp-tester /usr/local/bin/
# Linux (x86_64)
curl -L https://github.com/paiml/rust-mcp-sdk/releases/latest/download/mcp-tester-linux-x86_64.tar.gz | tar xz
sudo mv mcp-tester /usr/local/bin/
# Windows (PowerShell)
Invoke-WebRequest -Uri "https://github.com/paiml/rust-mcp-sdk/releases/latest/download/mcp-tester-windows-x86_64.zip" -OutFile "mcp-tester.zip"
Expand-Archive -Path "mcp-tester.zip" -DestinationPath "C:\Program Files\mcp-tester"
# Add C:\Program Files\mcp-tester to your PATH
Option 2: Build from Source
# From the SDK repository
cd examples/26-server-tester
cargo build --release
# The binary will be at target/release/mcp-tester
# Optional: Install globally
cargo install --path .
Quick Start
# Test a local HTTP server (basic)
mcp-tester test http://localhost:8080
# Test with tool validation
mcp-tester test http://localhost:8080 --with-tools
# Test a stdio server
mcp-tester test stdio
# Quick connectivity check
mcp-tester quick http://localhost:8080
Core Testing Commands
1. Full Test Suite
Run comprehensive tests including protocol compliance, capability discovery, and tool validation:
mcp-tester test <URL> [OPTIONS]
# Examples:
mcp-tester test http://localhost:8080 --with-tools --format json
mcp-tester test https://api.example.com/mcp --timeout 60
Options:
--with-tools- Test all discovered tools with schema validation--tool <NAME>- Test a specific tool--args <JSON>- Provide custom tool arguments--format <FORMAT>- Output format:pretty,json,minimal,verbose--timeout <SECONDS>- Connection timeout (default: 30)--insecure- Skip TLS certificate verification
Example output (pretty format):
=== MCP Server Test Results ===
✓ Core Tests
✓ Connection establishment (42ms)
✓ Server initialization (158ms)
✓ Capability discovery (23ms)
✓ Protocol Tests
✓ JSON-RPC 2.0 compliance (5ms)
✓ MCP version validation (2ms)
✓ Required methods present (1ms)
✓ Tool Tests
✓ Tool discovery (5 tools) (15ms)
✓ search_wikipedia (234ms)
✓ get_article (156ms)
⚠ get_summary (schema warning) (89ms)
Summary: 12 passed, 0 failed, 1 warning in 725ms
2. Protocol Compliance Testing
Validate strict protocol compliance:
mcp-tester compliance http://localhost:8080 --strict
# Validates:
# - JSON-RPC 2.0 format
# - MCP protocol version support
# - Required methods (initialize, ping)
# - Error code standards
# - Response structure correctness
3. Tool Discovery and Validation
List and validate tool schemas:
mcp-tester tools http://localhost:8080 --verbose
# Output includes:
# - Tool names and descriptions
# - Input schema validation
# - Schema completeness warnings
# - Missing properties/types
Schema validation warnings:
✓ Found 10 tools:
• search_wikipedia - Search for Wikipedia articles by query
✓ Schema properly defined
• get_article - Retrieve full Wikipedia article content
⚠ Tool 'get_article' missing 'properties' field for object type
• get_summary - Get a summary of a Wikipedia article
⚠ Tool 'get_summary' has empty input schema - consider defining parameters
Schema Validation Summary:
⚠ 3 total warnings found
- 1 tools with empty schema
- 2 tools missing 'properties' in schema
4. Resource Testing
Test resource discovery and reading:
mcp-tester resources http://localhost:8080
# Validates:
# - Resource listing
# - URI format correctness
# - MIME type presence
# - Resource metadata
# - Resource content reading
5. Prompt Testing
Test prompt discovery and execution:
mcp-tester prompts http://localhost:8080
# Validates:
# - Prompt listing
# - Description presence
# - Argument schema validation
# - Prompt execution
6. Connection Diagnostics
Troubleshoot connection issues with layer-by-layer diagnostics:
mcp-tester diagnose http://localhost:8080 --network
# Tests in order:
# 1. URL validation
# 2. DNS resolution
# 3. TCP connectivity
# 4. TLS/SSL certificates (for HTTPS)
# 5. HTTP response
# 6. MCP protocol handshake
Example diagnostic output:
=== Layer-by-Layer Diagnostics ===
✓ Layer 1: URL Validation
- URL: http://localhost:8080
- Scheme: http
- Host: localhost
- Port: 8080
✓ Layer 2: DNS Resolution
- Resolved to: 127.0.0.1
✓ Layer 3: TCP Connection
- Connected successfully
✗ Layer 4: HTTP Response
- Error: Connection refused
- Possible causes:
• Server not running
• Wrong port
• Firewall blocking connection
Recommendation: Verify server is running on port 8080
Testing OAuth-Protected Servers
The mcp-tester supports interactive OAuth 2.0 authentication with automatic browser-based login and token caching using OpenID Connect discovery.
Interactive OAuth Flow (OIDC discovery)
Auto-Discovery vs Explicit Issuer: If
--oauth-issueris omitted, the tester attempts OIDC discovery from the MCP server base URL (e.g.,https://api.example.com/.well-known/openid-configuration). Providing--oauth-issuerexplicitly is recommended for reliability, especially when the OAuth provider and MCP server are hosted on different domains.
# Interactive OAuth with automatic browser login (explicit issuer - recommended)
mcp-tester test https://your-oauth-server.com/mcp \
--oauth-issuer "https://auth.example.com" \
--oauth-client-id "your-client-id" \
--oauth-scopes openid,email,profile
What happens:
- ✅ Tester generates secure PKCE challenge
- 🌐 Opens your browser to the OAuth provider login page
- 🔐 You authenticate with your credentials
- ✅ Tester receives the authorization code via local callback server
- 🎫 Exchanges code for access token
- 💾 Caches token locally (
~/.mcp-tester/tokens.json) for future requests (unless--oauth-no-cache) - 🚀 Automatically injects
Authorization: Bearerheader into all MCP requests
AWS Cognito Example
mcp-tester test https://your-api.execute-api.us-west-2.amazonaws.com/mcp \
--oauth-issuer "https://your-pool.auth.us-west-2.amazoncognito.com" \
--oauth-client-id "your-cognito-client-id" \
--oauth-scopes openid \
--with-tools
Subsequent runs reuse cached tokens (no re-authentication needed):
mcp-tester test https://your-api.execute-api.us-west-2.amazonaws.com/mcp \
--oauth-client-id "your-cognito-client-id" \
--with-tools
# ← Uses cached token automatically!
Manual Token (Alternative)
If you already have an access token (for example, from a previous OAuth flow or from another tool):
# Pass token directly
mcp-tester test https://your-oauth-server.com/mcp --api-key "YOUR_ACCESS_TOKEN"
# Or via environment variable
export MCP_API_KEY="YOUR_ACCESS_TOKEN"
mcp-tester test https://your-oauth-server.com/mcp
Pro Tip: Copy Token from MCP Inspector
If you’ve authenticated using the official MCP Inspector’s OAuth flow, you can copy the access token from the final step and reuse it in mcp-tester:
- Run MCP Inspector with OAuth (it will complete the OAuth flow)
- In the Inspector’s console output or browser developer tools, locate the access token
- Copy the token value
- Use it with
mcp-tester:mcp-tester test $SERVER_URL --api-key "eyJhbGci..." --with-tools
This is useful for quickly testing with an already-authenticated session without going through the OAuth flow again in mcp-tester.
Scenario-Based Testing
The most powerful feature of mcp-tester is scenario testing - defining complex, multi-step test workflows in YAML or JSON files.
Why Scenarios?
Scenarios enable:
- ✅ Reproducible tests - Define once, run anywhere
- ✅ Complex workflows - Test multi-step user interactions
- ✅ Data dependencies - Use outputs from one step in later steps
- ✅ Regression testing - Detect breaking changes automatically
- ✅ Documentation - Scenarios serve as executable documentation
Scenario File Structure
name: Wikipedia Server Test # Required
description: Test search and article retrieval # Optional
timeout: 60 # Overall timeout (seconds)
stop_on_failure: true # Stop on first failure
variables: # Define reusable variables
test_query: "artificial intelligence"
test_title: "Artificial intelligence"
setup: # Run before main steps
- name: Verify server health
operation:
type: list_tools
steps: # Main test steps
- name: Search for articles
operation:
type: tool_call
tool: search_wikipedia
arguments:
query: "${test_query}"
limit: 10
store_result: search_results # Store for later use
assertions:
- type: success
- type: array_length
path: results
greater_than: 0
- name: Get first article
operation:
type: tool_call
tool: get_article
arguments:
title: "${search_results.results[0].title}"
assertions:
- type: success
- type: exists
path: content
- type: contains
path: content
value: "${test_query}"
ignore_case: true
cleanup: # Always run, even on failure
- name: Clear cache
operation:
type: tool_call
tool: clear_cache
continue_on_failure: true
Running Scenarios
# Run a scenario file
mcp-tester scenario http://localhost:8080 my-test.yaml
# Run with detailed step-by-step output
mcp-tester scenario http://localhost:8080 my-test.yaml --detailed
# Run with JSON output for CI
mcp-tester scenario http://localhost:8080 my-test.yaml --format json > results.json
Automatic Scenario Generation
The mcp-tester can automatically generate test scenarios from your server’s discovered capabilities:
# Generate basic scenario
mcp-tester generate-scenario http://localhost:8080 -o test.yaml
# Generate comprehensive scenario with all tools
mcp-tester generate-scenario http://localhost:8080 -o full_test.yaml \
--all-tools --with-resources --with-prompts
Generated scenario example:
name: my-server Test Scenario
description: Automated test scenario for server
timeout: 60
stop_on_failure: false
steps:
- name: List available capabilities
operation:
type: list_tools
store_result: available_tools
assertions:
- type: success
- type: exists
path: tools
- name: Test tool: search_wikipedia
operation:
type: tool_call
tool: search_wikipedia
arguments:
query: "TODO: query" # ← Replace with real values
limit: 10
timeout: 30
assertions:
- type: success
# ... more generated tests
Workflow:
- Generate the scenario template
- Edit to replace
TODO:placeholders with real test data - Add custom assertions
- Run the scenario
Operation Types
Tool Call:
operation:
type: tool_call
tool: tool_name
arguments:
param1: value1
List Operations:
operation:
type: list_tools # List all tools
# OR
type: list_resources # List all resources
# OR
type: list_prompts # List all prompts
Resource Operations:
operation:
type: read_resource
uri: resource://path/to/resource
Prompt Operations:
operation:
type: get_prompt
name: prompt_name
arguments:
key: value
Utility Operations:
operation:
type: wait
seconds: 2.5
# OR
type: set_variable
name: my_var
value: some_value
Custom JSON-RPC:
operation:
type: custom
method: some.method
params:
key: value
Assertion Types
Success/Failure:
assertions:
- type: success # Expects no error
- type: failure # Expects an error
Value Comparisons:
assertions:
- type: equals
path: result.status
value: "active"
- type: contains
path: result.message
value: "success"
ignore_case: true
- type: matches
path: result.id
pattern: "^[a-f0-9-]{36}$" # UUID regex
Existence Checks:
assertions:
- type: exists
path: result.data
- type: not_exists
path: result.error
Array and Numeric:
assertions:
- type: array_length
path: results
greater_than: 5
# OR equals: 10
# OR less_than_or_equal: 20
# OR between: {min: 5, max: 15}
**Path Expressions (JSONPath-style):**
```yaml
assertions:
- type: jsonpath
expression: "result.items[0].id"
expected: "abc-123" # Optional: if omitted, only checks presence
- type: jsonpath
expression: "data.user.profile.email" # Dot notation
expected: "test@example.com"
- type: jsonpath
expression: "results[0]" # Array index
Note: The
jsonpathassertion type uses simple path expressions with dot notation and array indexing (e.g.,user.items[0].name), not full JSONPath query language. For full JSONPath support with wildcards, filters, and recursive descent, consider using dedicated assertion tools in your CI pipeline.
Numeric Comparisons:
assertions:
- type: numeric
path: result.count
greater_than_or_equal: 100
Variables and Result Reuse
Define variables:
variables:
user_id: "test_123"
api_key: "${env.API_KEY}" # From environment
Store step results:
steps:
- name: Create item
operation:
type: tool_call
tool: create_item
arguments:
name: "Test"
store_result: created_item # ← Store result
- name: Update item
operation:
type: tool_call
tool: update_item
arguments:
id: "${created_item.result.id}" # ← Use stored result
Complete Scenario Example
File: scenarios/user-workflow.yaml
name: Complete User Workflow Test
description: Test user creation, retrieval, update, and deletion
timeout: 120
stop_on_failure: false
variables:
test_email: "test@example.com"
test_name: "Test User"
setup:
- name: Clean up existing test user
operation:
type: tool_call
tool: delete_user
arguments:
email: "${test_email}"
continue_on_failure: true
steps:
# Step 1: List tools to verify server capabilities
- name: Verify server has user management tools
operation:
type: list_tools
assertions:
- type: success
- type: contains
path: tools
value: "create_user"
# Step 2: Create a new user
- name: Create test user
operation:
type: tool_call
tool: create_user
arguments:
email: "${test_email}"
name: "${test_name}"
store_result: new_user
timeout: 30
assertions:
- type: success
- type: exists
path: result.id
- type: equals
path: result.email
value: "${test_email}"
- type: matches
path: result.id
pattern: "^[a-f0-9-]{36}$"
# Step 3: Verify user exists
- name: Retrieve created user
operation:
type: tool_call
tool: get_user
arguments:
id: "${new_user.result.id}"
assertions:
- type: success
- type: equals
path: result.email
value: "${test_email}"
- type: equals
path: result.name
value: "${test_name}"
# Step 4: Update user
- name: Update user status
operation:
type: tool_call
tool: update_user
arguments:
id: "${new_user.result.id}"
status: "active"
store_result: updated_user
assertions:
- type: success
- type: equals
path: result.status
value: "active"
# Step 5: Verify update persisted
- name: Verify user was updated
operation:
type: tool_call
tool: get_user
arguments:
id: "${new_user.result.id}"
assertions:
- type: equals
path: result.status
value: "active"
# Step 6: List users
- name: Verify user appears in list
operation:
type: tool_call
tool: list_users
arguments:
status: "active"
assertions:
- type: success
- type: array_length
path: users
greater_than: 0
cleanup:
- name: Delete test user
operation:
type: tool_call
tool: delete_user
arguments:
id: "${new_user.result.id}"
continue_on_failure: true
assertions:
- type: success
Run the scenario:
mcp-tester scenario http://localhost:8080 scenarios/user-workflow.yaml --detailed
CI/CD Integration
The mcp-tester is designed for seamless CI/CD integration with JSON output, exit codes, and headless operation.
GitHub Actions
name: MCP Server Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
test-mcp-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Build MCP server
run: cargo build --release
- name: Start MCP server in background
run: |
cargo run --release --bin my-mcp-server &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
sleep 5 # Wait for server to start
- name: Build mcp-tester
working-directory: examples/26-server-tester
run: cargo build --release
- name: Run protocol compliance tests
run: |
examples/26-server-tester/target/release/mcp-tester \
compliance http://localhost:8080 \
--format json \
--strict \
> compliance-results.json
- name: Run tool validation tests
run: |
examples/26-server-tester/target/release/mcp-tester \
tools http://localhost:8080 \
--test-all \
--format json \
> tool-results.json
- name: Run scenario tests
run: |
examples/26-server-tester/target/release/mcp-tester \
scenario http://localhost:8080 \
tests/scenarios/smoke-test.yaml \
--format json \
> scenario-results.json
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
compliance-results.json
tool-results.json
scenario-results.json
- name: Stop server
if: always()
run: kill $SERVER_PID || true
GitLab CI
test-mcp-server:
stage: test
image: rust:latest
services:
- name: my-mcp-server:latest
alias: mcp-server
before_script:
- cd examples/26-server-tester
- cargo build --release
script:
# Run tests against service
- >
./target/release/mcp-tester
test http://mcp-server:8080
--with-tools
--format json
> test-results.json
# Check exit code
- if [ $? -ne 0 ]; then exit 1; fi
artifacts:
paths:
- test-results.json
when: always
Note: The
mcp-testeroutputs JSON format (via--format json), not JUnit XML. If your CI system requires JUnit XML reports, you can convert the JSON output using tools likejqor write a custom converter script.
Jenkins
pipeline {
agent any
environment {
SERVER_URL = 'http://localhost:8080'
}
stages {
stage('Build Server') {
steps {
sh 'cargo build --release'
}
}
stage('Start Server') {
steps {
sh '''
cargo run --release &
echo $! > server.pid
sleep 5
'''
}
}
stage('Build Tester') {
steps {
dir('examples/26-server-tester') {
sh 'cargo build --release'
}
}
}
stage('Run Tests') {
parallel {
stage('Protocol Compliance') {
steps {
sh '''
examples/26-server-tester/target/release/mcp-tester \
compliance ${SERVER_URL} \
--format json \
> compliance.json
'''
}
}
stage('Tool Validation') {
steps {
sh '''
examples/26-server-tester/target/release/mcp-tester \
tools ${SERVER_URL} \
--test-all \
--format json \
> tools.json
'''
}
}
stage('Scenario Tests') {
steps {
sh '''
examples/26-server-tester/target/release/mcp-tester \
scenario ${SERVER_URL} \
scenarios/regression.yaml \
--format json \
> scenario.json
'''
}
}
}
}
}
post {
always {
sh 'kill $(cat server.pid) || true'
archiveArtifacts artifacts: '*.json', allowEmptyArchive: true
}
}
}
Docker-based Testing
Dockerfile for testing:
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
# Build server
RUN cargo build --release
# Build tester
WORKDIR /app/examples/26-server-tester
RUN cargo build --release
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy binaries
COPY --from=builder /app/target/release/my-mcp-server /usr/local/bin/
COPY --from=builder /app/examples/26-server-tester/target/release/mcp-tester /usr/local/bin/
# Copy test scenarios
COPY scenarios /scenarios
# Test script
COPY <<EOF /test.sh
#!/bin/bash
set -e
# Start server in background
my-mcp-server &
SERVER_PID=$!
# Wait for server
sleep 5
# Run tests
mcp-tester test http://localhost:8080 --format json > /results/test-results.json
mcp-tester scenario http://localhost:8080 /scenarios/main.yaml --format json > /results/scenario-results.json
# Stop server
kill $SERVER_PID
echo "All tests passed!"
EOF
RUN chmod +x /test.sh
VOLUME /results
CMD ["/test.sh"]
Run tests in Docker:
# Build test image
docker build -t my-mcp-server-tests .
# Run tests
docker run -v $(pwd)/test-results:/results my-mcp-server-tests
# Check results
cat test-results/test-results.json
Pre-commit Hook
Add MCP server testing to your pre-commit workflow:
#!/bin/bash
# .git/hooks/pre-commit
echo "Running MCP server tests..."
# Start server
cargo run --bin my-mcp-server &
SERVER_PID=$!
sleep 3
# Run quick tests
cd examples/26-server-tester
cargo run --release -- test http://localhost:8080 --format minimal
RESULT=$?
# Cleanup
kill $SERVER_PID 2>/dev/null || true
if [ $RESULT -ne 0 ]; then
echo "MCP server tests failed! Commit aborted."
exit 1
fi
echo "MCP server tests passed!"
exit 0
Testing Best Practices
1. Test Pyramid Strategy
Unit Tests (Foundation):
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_tool_input_validation() { let tool = MyTool; let invalid_input = json!({"missing": "required_field"}); assert!(tool.validate_input(&invalid_input).is_err()); } } }
Integration Tests (Middle):
# Test individual tools with real server
mcp-tester test http://localhost:8080 --tool search --args '{"query": "test"}'
Scenario Tests (Top):
# Test complete workflows
mcp-tester scenario http://localhost:8080 scenarios/user-workflow.yaml
2. Schema-Driven Testing
Always define complete JSON schemas for your tools:
#![allow(unused)] fn main() { // Good - Complete schema ToolInfo { name: "search".to_string(), description: Some("Search for items".to_string()), input_schema: json!({ "type": "object", "properties": { "query": { "type": "string", "description": "Search query", "minLength": 1 }, "limit": { "type": "number", "description": "Max results", "minimum": 1, "maximum": 100, "default": 10 } }, "required": ["query"] }) } // Bad - Empty schema ToolInfo { name: "search".to_string(), description: Some("Search for items".to_string()), input_schema: json!({}) // ← mcp-tester will warn! } }
3. Test Data Management
Use variables for reusable test data:
variables:
test_user_email: "test@example.com"
test_date: "2024-01-01"
api_base_url: "${env.API_BASE_URL}" # From environment
steps:
- name: Create user
operation:
type: tool_call
tool: create_user
arguments:
email: "${test_user_email}"
4. Comprehensive Assertions
Test success AND content:
assertions:
# Not enough - only checks for success
- type: success
# Better - verify actual data
- type: success
- type: exists
path: result.id
- type: array_length
path: result.items
greater_than: 0
- type: matches
path: result.created_at
pattern: "^\\d{4}-\\d{2}-\\d{2}T"
5. Error Case Testing
Test failure scenarios explicitly:
steps:
- name: Test invalid input handling
operation:
type: tool_call
tool: create_user
arguments:
email: "invalid-email" # Missing @ symbol
assertions:
- type: failure # Expect this to fail
- type: exists
path: error.message
- type: contains
path: error.message
value: "invalid email"
ignore_case: true
6. Performance Testing
Set appropriate timeouts and measure performance:
steps:
- name: Fast operation
operation:
type: list_tools
timeout: 5 # Should be fast
- name: Slow operation (large data processing)
operation:
type: tool_call
tool: process_large_dataset
arguments:
size: 10000
timeout: 120 # Allow more time
7. Idempotent Tests
Use setup/cleanup for consistent test state:
setup:
# Clean slate before each run
- name: Delete existing test data
operation:
type: tool_call
tool: cleanup_test_data
continue_on_failure: true
steps:
# ... tests ...
cleanup:
# Always clean up, even on failure
- name: Remove test artifacts
operation:
type: tool_call
tool: cleanup_test_data
continue_on_failure: true
8. Versioned Test Scenarios
Maintain scenarios alongside code:
my-mcp-server/
├── src/
│ └── main.rs
├── tests/
│ └── scenarios/
│ ├── v1.0/
│ │ ├── smoke-test.yaml
│ │ └── regression.yaml
│ ├── v1.1/
│ │ ├── smoke-test.yaml
│ │ ├── regression.yaml
│ │ └── new-feature.yaml
│ └── current -> v1.1/
└── Cargo.toml
Troubleshooting
Common Issues
Connection Refused:
# Use diagnostics to identify the problem
mcp-tester diagnose http://localhost:8080 --network
# Common causes:
# - Server not running
# - Wrong port
# - Firewall blocking connection
TLS Certificate Errors:
# For self-signed certificates in development
mcp-tester test https://localhost:8443 --insecure
Timeout Issues:
# Increase timeout for slow servers or cold starts
mcp-tester test $URL --timeout 120
OAuth Authentication Failures:
# Clear cached tokens and re-authenticate
rm ~/.mcp-tester/tokens.json
mcp-tester test $URL --oauth-client-id $CLIENT_ID
Schema Validation Warnings:
# Fix by adding complete schema
input_schema:
type: object
properties:
param1:
type: string
description: "What this parameter does"
required: ["param1"]
Summary
Effective MCP server testing requires a layered approach:
- Interactive Testing - Use MCP Inspector for exploration and manual debugging
- Automated Testing - Use mcp-tester for comprehensive, reproducible tests
- Scenario Testing - Define complex workflows in YAML for regression testing
- CI/CD Integration - Automate testing in your deployment pipeline
Key Takeaways:
- ✅ More servers than clients - Server testing is critical for ecosystem health
- ✅ MCP Inspector - Official tool for interactive, browser-based testing
- ✅ mcp-tester - Comprehensive CLI tool for automated testing and CI/CD
- ✅ Scenarios - Define reproducible test workflows in YAML/JSON
- ✅ OAuth support - Test authenticated servers with automatic token management
- ✅ Multi-transport - Test HTTP, HTTPS, WebSocket, and stdio servers
- ✅ Schema validation - Catch incomplete tool definitions early
- ✅ CI/CD ready - JSON output, exit codes, and headless operation
Next Steps:
- Set up MCP Inspector for interactive development testing
- Create basic smoke test scenario for your server
- Add mcp-tester to your CI/CD pipeline
- Build comprehensive regression test suite
- Add pre-commit hooks for fast feedback
With comprehensive testing in place, you can confidently deploy MCP servers that work reliably with Claude and other MCP clients.
Ch16 Deployment
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch17 Examples
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch17 01 Parallel Clients
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch17 02 Structured Output
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch17 03 Sampling Tools
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch18 Patterns
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch19 Integration
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch20 Typescript Interop
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch21 Migration
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch22 Feature Parity
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch23 Custom Transports
Introduction
MCP is transport-agnostic: it can run over any bidirectional communication channel that supports JSON-RPC 2.0 message exchange. While the PMCP SDK provides built-in transports (stdio, HTTP, WebSocket, SSE), you can build custom transports for specialized use cases.
When to Build Custom Transports
Consider custom transports when:
✅ Good Reasons:
- Async messaging systems: SQS, SNS, Kafka, RabbitMQ for decoupled architectures
- Custom protocols: Domain-specific protocols in your infrastructure
- Performance optimization: Specialized binary protocols, compression, batching
- Testing: In-memory or mock transports for unit/integration tests
- Legacy integration: Wrapping existing communication channels
- Security requirements: Custom encryption, authentication flows
❌ Avoid Custom Transports For:
- Standard HTTP/HTTPS servers → Use built-in
HttpTransport - Local processes → Use built-in
StdioTransport - Real-time WebSocket → Use built-in
WebSocketTransport
Client vs Server Reality
Critical Insight: Like web browsers vs websites, there will be far fewer MCP clients than MCP servers.
- Few clients: Claude Desktop, IDEs, agent frameworks (like web browsers)
- Many servers: Every tool, API, database, service (like websites)
Implication: Custom transports require both sides to implement the same protocol. Unless you control both client and server (e.g., internal infrastructure), stick to standard transports for maximum compatibility.
The Transport Trait
All transports in PMCP implement the Transport trait:
#![allow(unused)] fn main() { use pmcp::shared::{Transport, TransportMessage}; use async_trait::async_trait; #[async_trait] pub trait Transport: Send + Sync + Debug { /// Send a message (request, response, or notification) async fn send(&mut self, message: TransportMessage) -> Result<()>; /// Receive a message (blocks until message arrives) async fn receive(&mut self) -> Result<TransportMessage>; /// Close the transport gracefully async fn close(&mut self) -> Result<()>; /// Check if transport is still connected (optional) fn is_connected(&self) -> bool { true } /// Transport type name for debugging (optional) fn transport_type(&self) -> &'static str { "unknown" } } }
Message Types
TransportMessage represents all MCP communication:
#![allow(unused)] fn main() { pub enum TransportMessage { /// Request with ID (expects response) Request { id: RequestId, request: Request, // ClientRequest or ServerRequest }, /// Response to a request Response(JSONRPCResponse), /// Notification (no response expected) Notification(Notification), } }
Key Design Points:
- Framing: Your transport handles message boundaries
- Serialization: PMCP handles JSON-RPC serialization
- Bidirectional: Both send and receive must work concurrently
- Error handling: Use
pmcp::error::TransportErrorfor transport-specific errors
Example 1: In-Memory Transport (Testing)
Use case: Unit tests, benchmarks, integration tests without network
#![allow(unused)] fn main() { use pmcp::shared::{Transport, TransportMessage}; use pmcp::error::Result; use async_trait::async_trait; use tokio::sync::mpsc; use std::sync::Arc; use tokio::sync::Mutex; /// In-memory transport using channels #[derive(Debug)] pub struct InMemoryTransport { /// Channel for sending messages tx: mpsc::Sender<TransportMessage>, /// Channel for receiving messages rx: Arc<Mutex<mpsc::Receiver<TransportMessage>>>, /// Connected state connected: Arc<std::sync::atomic::AtomicBool>, } impl InMemoryTransport { /// Create a pair of connected transports (client <-> server) pub fn create_pair() -> (Self, Self) { let (tx1, rx1) = mpsc::channel(100); let (tx2, rx2) = mpsc::channel(100); let transport1 = Self { tx: tx1, rx: Arc::new(Mutex::new(rx2)), connected: Arc::new(std::sync::atomic::AtomicBool::new(true)), }; let transport2 = Self { tx: tx2, rx: Arc::new(Mutex::new(rx1)), connected: Arc::new(std::sync::atomic::AtomicBool::new(true)), }; (transport1, transport2) } } #[async_trait] impl Transport for InMemoryTransport { async fn send(&mut self, message: TransportMessage) -> Result<()> { use std::sync::atomic::Ordering; if !self.connected.load(Ordering::Relaxed) { return Err(pmcp::error::Error::Transport( pmcp::error::TransportError::ConnectionClosed )); } self.tx .send(message) .await .map_err(|_| pmcp::error::Error::Transport( pmcp::error::TransportError::ConnectionClosed )) } async fn receive(&mut self) -> Result<TransportMessage> { let mut rx = self.rx.lock().await; rx.recv() .await .ok_or_else(|| pmcp::error::Error::Transport( pmcp::error::TransportError::ConnectionClosed )) } async fn close(&mut self) -> Result<()> { use std::sync::atomic::Ordering; self.connected.store(false, Ordering::Relaxed); Ok(()) } fn is_connected(&self) -> bool { use std::sync::atomic::Ordering; self.connected.load(Ordering::Relaxed) } fn transport_type(&self) -> &'static str { "in-memory" } } // Usage in tests #[cfg(test)] mod tests { use super::*; use pmcp::{Client, Server, ClientCapabilities}; #[tokio::test] async fn test_in_memory_transport() { // Create connected pair let (client_transport, server_transport) = InMemoryTransport::create_pair(); // Create client and server let mut client = Client::new(client_transport); let server = Server::builder() .name("test-server") .version("1.0.0") .build() .unwrap(); // Run server in background tokio::spawn(async move { server.run(server_transport).await }); // Client can now communicate with server let result = client.initialize(ClientCapabilities::minimal()).await; assert!(result.is_ok()); } } }
Benefits:
- ✅ Zero network overhead
- ✅ Deterministic testing
- ✅ Fast benchmarks
- ✅ Isolated test environments
Example 2: Async Queue Transport (Production)
Use case: Decoupled architectures with message queues (SQS, Kafka, RabbitMQ)
This example shows a conceptual async transport using AWS SQS:
#![allow(unused)] fn main() { use pmcp::shared::{Transport, TransportMessage}; use pmcp::error::Result; use async_trait::async_trait; use aws_sdk_sqs::Client as SqsClient; use aws_config; // For load_from_env use tokio::sync::mpsc; use std::sync::Arc; use tokio::sync::Mutex; /// SQS-based async transport #[derive(Debug)] pub struct SqsTransport { /// SQS client sqs: SqsClient, /// Request queue URL (for sending) request_queue_url: String, /// Response queue URL (for receiving) response_queue_url: String, /// Local message buffer message_rx: Arc<Mutex<mpsc::Receiver<TransportMessage>>>, message_tx: mpsc::Sender<TransportMessage>, /// Background poller handle poller_handle: Option<tokio::task::JoinHandle<()>>, } impl SqsTransport { pub async fn new( request_queue_url: String, response_queue_url: String, ) -> Result<Self> { let config = aws_config::load_from_env().await; let sqs = SqsClient::new(&config); let (tx, rx) = mpsc::channel(100); let mut transport = Self { sqs: sqs.clone(), request_queue_url, response_queue_url: response_queue_url.clone(), message_rx: Arc::new(Mutex::new(rx)), message_tx: tx, poller_handle: None, }; // Start background poller for incoming messages transport.start_poller().await?; Ok(transport) } async fn start_poller(&mut self) -> Result<()> { let sqs = self.sqs.clone(); let queue_url = self.response_queue_url.clone(); let tx = self.message_tx.clone(); let handle = tokio::spawn(async move { loop { // Long-poll SQS for messages (20 seconds) match sqs .receive_message() .queue_url(&queue_url) .max_number_of_messages(10) .wait_time_seconds(20) .send() .await { Ok(output) => { if let Some(messages) = output.messages { for msg in messages { if let Some(body) = msg.body { // Parse JSON-RPC message if let Ok(transport_msg) = serde_json::from_str::<TransportMessage>(&body) { let _ = tx.send(transport_msg).await; } // Delete message from queue if let Some(receipt) = msg.receipt_handle { let _ = sqs .delete_message() .queue_url(&queue_url) .receipt_handle(receipt) .send() .await; } } } } } Err(e) => { tracing::error!("SQS polling error: {}", e); tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } } } }); self.poller_handle = Some(handle); Ok(()) } } #[async_trait] impl Transport for SqsTransport { async fn send(&mut self, message: TransportMessage) -> Result<()> { // Serialize message to JSON let json = serde_json::to_string(&message) .map_err(|e| pmcp::error::Error::Transport( pmcp::error::TransportError::InvalidMessage(e.to_string()) ))?; // Send to SQS request queue self.sqs .send_message() .queue_url(&self.request_queue_url) .message_body(json) .send() .await .map_err(|e| pmcp::error::Error::Transport( pmcp::error::TransportError::InvalidMessage(e.to_string()) ))?; Ok(()) } async fn receive(&mut self) -> Result<TransportMessage> { let mut rx = self.message_rx.lock().await; rx.recv() .await .ok_or_else(|| pmcp::error::Error::Transport( pmcp::error::TransportError::ConnectionClosed )) } async fn close(&mut self) -> Result<()> { if let Some(handle) = self.poller_handle.take() { handle.abort(); } Ok(()) } fn transport_type(&self) -> &'static str { "sqs-async" } } }
Architecture Benefits:
- ✅ Decoupled: Client/server don’t need to be online simultaneously
- ✅ Scalable: Multiple servers can consume from same queue
- ✅ Reliable: Message persistence and retry mechanisms
- ✅ Async workflows: Long-running operations without blocking
Trade-offs:
- ❌ Latency: Higher than direct connections (100ms+)
- ❌ Cost: Message queue service fees
- ❌ Complexity: Requires queue infrastructure setup
Example 3: WebSocket Transport (Built-in)
The SDK includes a production-ready WebSocket transport. Here’s how it works internally:
#![allow(unused)] fn main() { // From src/shared/websocket.rs (simplified) use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; use futures::{SinkExt, StreamExt}; pub struct WebSocketTransport { config: WebSocketConfig, state: Arc<RwLock<ConnectionState>>, message_tx: mpsc::Sender<TransportMessage>, message_rx: Arc<AsyncMutex<mpsc::Receiver<TransportMessage>>>, } #[async_trait] impl Transport for WebSocketTransport { async fn send(&mut self, message: TransportMessage) -> Result<()> { // Serialize to JSON let json = serde_json::to_vec(&message)?; // Send as WebSocket text frame let ws_msg = Message::Text(String::from_utf8(json)?); self.sink.send(ws_msg).await?; Ok(()) } async fn receive(&mut self) -> Result<TransportMessage> { // Wait for incoming WebSocket frame let mut rx = self.message_rx.lock().await; rx.recv().await.ok_or(TransportError::ConnectionClosed) } // ... reconnection logic, ping/pong handling, etc. } }
Key WebSocket Features:
- ✅ Bidirectional: True full-duplex communication
- ✅ Low latency: Direct TCP connection
- ✅ Server push: Notifications without polling
- ✅ Auto-reconnect: Built-in resilience
Example 4: Kafka Transport with Topic Design
Use case: Event-driven, scalable, multi-client/server architectures
Kafka provides a robust foundation for MCP when you need decoupled, event-driven communication. The key challenge is topic design for request-response patterns in a pub/sub system.
Topic Architecture
┌─────────────────────────────────────────────────────────────┐
│ Kafka Cluster │
│ │
│ mcp.requests.* mcp.responses.* │
│ ├─ mcp.requests.global ├─ mcp.responses.client-A │
│ ├─ mcp.requests.tool-analysis ├─ mcp.responses.client-B │
│ └─ mcp.requests.data-proc └─ mcp.responses.client-C │
│ │
│ mcp.server.discovery │
│ └─ Heartbeats from servers with capabilities │
└─────────────────────────────────────────────────────────────┘
▲ │ ▲ │
│ Produce │ Consume │ Consume │ Produce
│ requests │ requests │ responses │ responses
│ ▼ │ ▼
┌─────────┐ ┌──────────────┐
│ Client │ │ MCP Server │
│ (Agent) │ │ Pool │
└─────────┘ └──────────────┘
Key Design Patterns
1. Request Routing:
- Global topic:
mcp.requests.global- Any server can handle - Capability-based:
mcp.requests.tool-analysis- Only servers with specific tools - Dedicated:
mcp.requests.server-id-123- Target specific server instance
2. Response Routing:
- Client-specific topics: Each client subscribes to
mcp.responses.{client-id} - Correlation IDs: Message headers contain
request-id+client-id - TTL: Messages expire after configurable timeout
3. Server Discovery:
- Servers publish heartbeats to
mcp.server.discoverywith:- Server ID
- Capabilities (tools, resources, prompts)
- Load metrics
- Status (online/busy/draining)
Implementation
#![allow(unused)] fn main() { use pmcp::shared::{Transport, TransportMessage}; use pmcp::error::Result; use async_trait::async_trait; use rdkafka::consumer::{Consumer, StreamConsumer}; use rdkafka::producer::{FutureProducer, FutureRecord}; use rdkafka::message::Message; use std::time::Duration; use tokio::sync::mpsc; /// Kafka-based MCP transport #[derive(Debug)] pub struct KafkaTransport { /// Kafka producer for sending producer: FutureProducer, /// Kafka consumer for receiving consumer: StreamConsumer, /// Client/Server ID for routing instance_id: String, /// Request topic name request_topic: String, /// Response topic name (client-specific) response_topic: String, /// Local message buffer message_rx: Arc<Mutex<mpsc::Receiver<TransportMessage>>>, message_tx: mpsc::Sender<TransportMessage>, } impl KafkaTransport { pub async fn new_client( brokers: &str, client_id: String, ) -> Result<Self> { use rdkafka::config::ClientConfig; // Create producer let producer: FutureProducer = ClientConfig::new() .set("bootstrap.servers", brokers) .set("message.timeout.ms", "5000") .create()?; // Create consumer let consumer: StreamConsumer = ClientConfig::new() .set("group.id", &client_id) .set("bootstrap.servers", brokers) .set("enable.auto.commit", "true") .create()?; // Subscribe to client-specific response topic let response_topic = format!("mcp.responses.{}", client_id); consumer.subscribe(&[&response_topic])?; let (tx, rx) = mpsc::channel(100); let mut transport = Self { producer, consumer, instance_id: client_id, request_topic: "mcp.requests.global".to_string(), response_topic, message_rx: Arc::new(Mutex::new(rx)), message_tx: tx, }; // Start background consumer transport.start_consumer().await?; Ok(transport) } async fn start_consumer(&mut self) -> Result<()> { let consumer = self.consumer.clone(); let tx = self.message_tx.clone(); tokio::spawn(async move { loop { match consumer.recv().await { Ok(msg) => { if let Some(payload) = msg.payload() { // Parse JSON-RPC message if let Ok(transport_msg) = serde_json::from_slice::<TransportMessage>(payload) { let _ = tx.send(transport_msg).await; } } } Err(e) => { tracing::error!("Kafka consumer error: {}", e); tokio::time::sleep(Duration::from_secs(5)).await; } } } }); Ok(()) } } #[async_trait] impl Transport for KafkaTransport { async fn send(&mut self, message: TransportMessage) -> Result<()> { // Serialize message let json = serde_json::to_vec(&message)?; // Extract request ID for correlation let correlation_id = match &message { TransportMessage::Request { id, .. } => format!("{:?}", id), _ => uuid::Uuid::new_v4().to_string(), }; // Build Kafka record with headers let record = FutureRecord::to(&self.request_topic) .payload(&json) .key(&correlation_id) .headers(rdkafka::message::OwnedHeaders::new() .insert(rdkafka::message::Header { key: "client-id", value: Some(self.instance_id.as_bytes()), }) .insert(rdkafka::message::Header { key: "response-topic", value: Some(self.response_topic.as_bytes()), })); // Send to Kafka self.producer .send(record, Duration::from_secs(5)) .await .map_err(|(e, _)| pmcp::error::Error::Transport( pmcp::error::TransportError::InvalidMessage(e.to_string()) ))?; Ok(()) } async fn receive(&mut self) -> Result<TransportMessage> { let mut rx = self.message_rx.lock().await; rx.recv() .await .ok_or_else(|| pmcp::error::Error::Transport( pmcp::error::TransportError::ConnectionClosed )) } async fn close(&mut self) -> Result<()> { // Graceful shutdown Ok(()) } fn transport_type(&self) -> &'static str { "kafka" } } }
Server-Side Topic Patterns
#![allow(unused)] fn main() { impl KafkaTransport { /// Create server transport with capability-based subscription pub async fn new_server( brokers: &str, server_id: String, capabilities: Vec<String>, // ["tool-analysis", "data-proc"] ) -> Result<Self> { use rdkafka::config::ClientConfig; // Create producer for server discovery let producer: FutureProducer = ClientConfig::new() .set("bootstrap.servers", brokers) .set("message.timeout.ms", "5000") .create()?; // Create consumer let consumer: StreamConsumer = ClientConfig::new() .set("group.id", &format!("mcp-server-{}", server_id)) .set("bootstrap.servers", brokers) .create()?; // Subscribe to relevant request topics let mut topics = vec!["mcp.requests.global".to_string()]; for cap in &capabilities { topics.push(format!("mcp.requests.{}", cap)); } consumer.subscribe(&topics.iter().map(|s| s.as_str()).collect::<Vec<_>>())?; // Publish server discovery Self::publish_discovery( &producer, &server_id, &capabilities, ).await?; // ... rest of initialization (similar to new_client) let (tx, rx) = mpsc::channel(100); let mut transport = Self { producer, consumer, instance_id: server_id, request_topic: "mcp.requests.global".to_string(), response_topic: String::new(), // Server doesn't have response topic message_rx: Arc::new(Mutex::new(rx)), message_tx: tx, }; transport.start_consumer().await?; Ok(transport) } async fn publish_discovery( producer: &FutureProducer, server_id: &str, capabilities: &[String], ) -> Result<()> { let discovery_msg = serde_json::json!({ "server_id": server_id, "capabilities": capabilities, "status": "online", "timestamp": SystemTime::now(), }); let record = FutureRecord::to("mcp.server.discovery") .payload(&serde_json::to_vec(&discovery_msg)?); producer.send(record, Duration::from_secs(1)).await?; Ok(()) } /// Send response back to specific client async fn send_response( &self, response: TransportMessage, client_id: &str, correlation_id: &str, ) -> Result<()> { let json = serde_json::to_vec(&response)?; let response_topic = format!("mcp.responses.{}", client_id); let record = FutureRecord::to(&response_topic) .payload(&json) .key(correlation_id); self.producer .send(record, Duration::from_secs(5)) .await?; Ok(()) } } }
Kafka Benefits for MCP
✅ Decoupled Communication:
- Clients/servers operate independently
- No direct connections required
- Timeouts managed at application layer
✅ Event-Driven Architecture:
- React to MCP requests as events
- Multiple consumers can process same request stream
- Event sourcing: Full message history
✅ Scalability:
- Horizontal scaling: Add more consumer groups
- Partitioning: Distribute load across brokers
- Retention: Replay historical requests
✅ Multi-Tenant Support:
- Topic isolation per client/tenant
- ACLs for security boundaries
- Quota management per client
❌ Trade-offs:
- Higher latency (50-200ms vs 1-5ms for WebSocket)
- Complex topic design required
- Kafka infrastructure overhead
- Request-response correlation complexity
Note on GraphQL: GraphQL APIs belong at the MCP server layer (as tools exposing queries/mutations), not as a custom transport. Use standard transports (HTTP, WebSocket) and build MCP servers that wrap GraphQL backends.
Best Practices
1. Preserve JSON-RPC Message Format
Your transport must not modify MCP messages:
#![allow(unused)] fn main() { // ✅ Correct: Transport as dumb pipe async fn send(&mut self, message: TransportMessage) -> Result<()> { let bytes = serde_json::to_vec(&message)?; self.underlying_channel.write(&bytes).await?; Ok(()) } // ❌ Wrong: Modifying message structure async fn send(&mut self, message: TransportMessage) -> Result<()> { // Don't do this! let mut custom_msg = CustomMessage::from(message); custom_msg.add_custom_field("timestamp", SystemTime::now()); self.underlying_channel.write(&custom_msg).await?; Ok(()) } }
2. Handle Framing Correctly
Ensure message boundaries are preserved:
#![allow(unused)] fn main() { // ✅ Correct: Length-prefixed framing async fn send(&mut self, message: TransportMessage) -> Result<()> { let json = serde_json::to_vec(&message)?; // Send length prefix (4 bytes, big-endian) let len = (json.len() as u32).to_be_bytes(); self.writer.write_all(&len).await?; // Send message self.writer.write_all(&json).await?; Ok(()) } async fn receive(&mut self) -> Result<TransportMessage> { // Read length prefix let mut len_buf = [0u8; 4]; self.reader.read_exact(&mut len_buf).await?; let len = u32::from_be_bytes(len_buf) as usize; // Read exact message let mut buf = vec![0u8; len]; self.reader.read_exact(&mut buf).await?; serde_json::from_slice(&buf).map_err(Into::into) } }
3. Implement Proper Error Handling
Map transport-specific errors to TransportError:
#![allow(unused)] fn main() { use pmcp::error::{Error, TransportError}; async fn send(&mut self, message: TransportMessage) -> Result<()> { match self.underlying_send(&message).await { Ok(()) => Ok(()), Err(e) if e.is_connection_error() => { Err(Error::Transport(TransportError::ConnectionClosed)) } Err(e) if e.is_timeout() => { Err(Error::Timeout(5000)) // 5 seconds } Err(e) => { Err(Error::Transport(TransportError::InvalidMessage( format!("Send failed: {}", e) ))) } } } }
4. Support Concurrent send/receive
Transports must handle concurrent operations:
#![allow(unused)] fn main() { // ✅ Correct: Separate channels for send/receive pub struct MyTransport { send_tx: mpsc::Sender<TransportMessage>, recv_rx: Arc<Mutex<mpsc::Receiver<TransportMessage>>>, } // ❌ Wrong: Single shared state without synchronization pub struct BadTransport { connection: TcpStream, // Can't safely share between send/receive } }
5. Security Considerations
Even for internal transports:
#![allow(unused)] fn main() { // ✅ Bind to localhost only let listener = TcpListener::bind("127.0.0.1:8080").await?; // ❌ Binding to all interfaces exposes to network let listener = TcpListener::bind("0.0.0.0:8080").await?; // ✅ Validate message sizes to prevent DoS const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024; // 10 MB async fn receive(&mut self) -> Result<TransportMessage> { let len = self.read_length().await?; if len > MAX_MESSAGE_SIZE { return Err(Error::Transport(TransportError::InvalidMessage( format!("Message too large: {} bytes", len) ))); } // ... continue reading } }
6. Message Encryption (End-to-End Security)
For high-security environments (defense, healthcare, finance), encrypt messages at the transport layer:
⚠️ Important Caveat: This example wraps messages in a custom
Notificationenvelope. Both client and server MUST useEncryptedTransport- this is not interoperable with standard MCP clients/servers. For transparent encryption, use TLS/mTLS at the transport layer instead (e.g.,wss://for WebSocket, HTTPS for HTTP).This pattern is appropriate when you control both ends and need application-layer encryption with custom key management.
#![allow(unused)] fn main() { use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Nonce, }; use rand::RngCore; /// Encrypted transport wrapper pub struct EncryptedTransport<T: Transport> { inner: T, cipher: Aes256Gcm, } impl<T: Transport> EncryptedTransport<T> { /// Create encrypted transport with 256-bit AES-GCM pub fn new(inner: T, key: &[u8; 32]) -> Result<Self> { let cipher = Aes256Gcm::new(key.into()); Ok(Self { inner, cipher }) } /// Encrypt message payload fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> { // Generate random nonce (12 bytes for AES-GCM) let mut nonce_bytes = [0u8; 12]; rand::rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); // Encrypt let ciphertext = self .cipher .encrypt(nonce, plaintext) .map_err(|e| Error::Transport(TransportError::InvalidMessage( format!("Encryption failed: {}", e) )))?; // Prepend nonce to ciphertext for decryption let mut result = nonce_bytes.to_vec(); result.extend_from_slice(&ciphertext); Ok(result) } /// Decrypt message payload fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> { if encrypted.len() < 12 { return Err(Error::Transport(TransportError::InvalidMessage( "Message too short for nonce".to_string() ))); } // Extract nonce and ciphertext let (nonce_bytes, ciphertext) = encrypted.split_at(12); let nonce = Nonce::from_slice(nonce_bytes); // Decrypt let plaintext = self .cipher .decrypt(nonce, ciphertext) .map_err(|e| Error::Transport(TransportError::InvalidMessage( format!("Decryption failed: {}", e) )))?; Ok(plaintext) } } #[async_trait] impl<T: Transport> Transport for EncryptedTransport<T> { async fn send(&mut self, message: TransportMessage) -> Result<()> { // Serialize message let plaintext = serde_json::to_vec(&message)?; // Encrypt let encrypted = self.encrypt(&plaintext)?; // Wrap in envelope let envelope = TransportMessage::Notification(Notification::Custom { method: "encrypted".to_string(), params: serde_json::json!({ "data": base64::encode(&encrypted), }), }); // Send via underlying transport self.inner.send(envelope).await } async fn receive(&mut self) -> Result<TransportMessage> { // Receive encrypted envelope let envelope = self.inner.receive().await?; // Extract encrypted data let encrypted_b64 = match envelope { TransportMessage::Notification(Notification::Custom { params, .. }) => { params["data"].as_str().ok_or_else(|| { Error::Transport(TransportError::InvalidMessage( "Missing encrypted data".to_string() )) })? } _ => return Err(Error::Transport(TransportError::InvalidMessage( "Expected encrypted envelope".to_string() ))), }; // Decode and decrypt let encrypted = base64::decode(encrypted_b64) .map_err(|e| Error::Transport(TransportError::InvalidMessage(e.to_string())))?; let plaintext = self.decrypt(&encrypted)?; // Deserialize message serde_json::from_slice(&plaintext).map_err(Into::into) } async fn close(&mut self) -> Result<()> { self.inner.close().await } fn is_connected(&self) -> bool { self.inner.is_connected() } fn transport_type(&self) -> &'static str { "encrypted" } } // Usage let base_transport = HttpTransport::new(/* ... */); let encryption_key: [u8; 32] = derive_key_from_password("secure-password"); let encrypted_transport = EncryptedTransport::new(base_transport, &encryption_key)?; let client = Client::new(encrypted_transport); }
Security Benefits:
- ✅ End-to-end encryption: Only sender/receiver can decrypt
- ✅ Authenticated encryption: AES-GCM provides integrity checks
- ✅ Nonce-based: Each message has unique nonce (prevents replay)
- ✅ Tamper-evident: Modified ciphertext fails decryption
Use Cases:
- Defense contractor systems (provably encrypted)
- Healthcare data (HIPAA compliance)
- Financial transactions (PCI-DSS requirements)
- Cross-border data transfers (GDPR encryption at rest/in transit)
7. Performance Optimization
For high-throughput scenarios:
#![allow(unused)] fn main() { // Message batching pub struct BatchingTransport { pending: Vec<TransportMessage>, flush_interval: Duration, } impl BatchingTransport { async fn send(&mut self, message: TransportMessage) -> Result<()> { self.pending.push(message); if self.pending.len() >= 100 { // Batch size threshold self.flush().await?; } Ok(()) } async fn flush(&mut self) -> Result<()> { if self.pending.is_empty() { return Ok(()); } // Send all pending messages in one operation let batch = std::mem::take(&mut self.pending); self.underlying_send_batch(batch).await?; Ok(()) } } // Compression for large messages async fn send_with_compression( &mut self, message: TransportMessage, ) -> Result<()> { let json = serde_json::to_vec(&message)?; if json.len() > 1024 { // Compress large messages let compressed = compress(&json)?; self.send_compressed(compressed).await?; } else { self.send_raw(json).await?; } Ok(()) } }
Testing Your Transport
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use pmcp::types::*; #[tokio::test] async fn test_send_receive() { let mut transport = MyTransport::new().await.unwrap(); // Create test message let request = TransportMessage::Request { id: RequestId::from(1), request: Request::Client(Box::new(ClientRequest::Ping)), }; // Send and receive transport.send(request.clone()).await.unwrap(); let received = transport.receive().await.unwrap(); // Verify round-trip assert!(matches!(received, TransportMessage::Request { .. })); } #[tokio::test] async fn test_error_handling() { let mut transport = MyTransport::new().await.unwrap(); // Close transport transport.close().await.unwrap(); // Verify sends fail after close let result = transport.send(/* ... */).await; assert!(result.is_err()); } } }
Integration Tests with mcp-tester
Use mcp-tester to validate transport behavior:
# Test custom transport server
cargo build --release --example my_custom_transport_server
# Run integration tests
mcp-tester test <CUSTOM_TRANSPORT_URL> \
--with-tools \
--with-resources \
--format json > results.json
Property-Based Testing
#![allow(unused)] fn main() { use proptest::prelude::*; proptest! { #[test] fn test_transport_roundtrip( id in any::<i64>(), method in "[a-z]{1,20}", ) { tokio_test::block_on(async { let mut transport = MyTransport::new().await.unwrap(); let message = TransportMessage::Request { id: RequestId::from(id), request: Request::Client(Box::new( ClientRequest::ListTools(ListToolsParams { cursor: None }) )), }; transport.send(message.clone()).await.unwrap(); let received = transport.receive().await.unwrap(); // Verify message integrity prop_assert!(matches!(received, TransportMessage::Request { .. })); }); } } }
Business Use Cases for Custom Transports
Custom transports require significant development effort (both client and server implementations). Here are 8 business scenarios where the investment pays off:
1. Long-Running Operations (Fintech, Legal-Tech)
Business Problem: A risk modeling platform runs MCP tools that perform complex calculations taking 5-30 minutes (credit risk analysis, compliance checks, multi-agent simulations).
Why Async Transport:
┌──────────┐ Request ┌──────────┐ Job Queue ┌──────────┐
│ Client │─────────────────►│ SQS │─────────────────►│ Lambda │
│ (Agent) │ │ Queue │ │ Worker │
└──────────┘ └──────────┘ └──────────┘
│ ▲ │
│ Job ID │ Completed │ Process
│ returned │ notification │ 5-30 min
│ immediately └─────────────────────────────┘
│
│ Poll/Subscribe
│ for results
▼
┌──────────┐
│ Results │
│ Queue │
└──────────┘
Benefits:
- ✅ No timeouts: Client doesn’t wait; gets job ID immediately
- ✅ Job orchestration: Multiple workers process queue in parallel
- ✅ UX improvement: Frontend shows “Processing…” with progress updates
- ✅ Cost optimization: Workers scale based on queue depth
Transport Choice: SQS (AWS), Azure Service Bus, Google Cloud Tasks
2. Regulated/Air-Gapped Environments (Healthcare, Government)
Business Problem: Healthcare organization needs MCP tools to access sensitive patient data inside secure network, but analysts/AI agents run outside that zone (HIPAA compliance).
Why Async Transport:
Public Zone Security Boundary Secure Zone
┌──────────┐ │ ┌──────────┐
│ AI Agent │ │ │ MCP │
│ (Client) │ │ │ Server │
└──────────┘ │ │ (PHI │
│ │ │ Access) │
│ Encrypted │ └──────────┘
│ request │ ▲
▼ │ │
┌──────────┐ │ ┌──────────┐
│ Kafka │◄────────────┼───────────►│ Kafka │
│ Public │ Firewall │ Consumer │ Secure │
│ Broker │ Rules │ Group │ Broker │
└──────────┘ │ └──────────┘
Benefits:
- ✅ Security isolation: No direct socket connections across zones
- ✅ Encryption at rest: Kafka stores encrypted messages
- ✅ Audit trail: All requests/responses logged for compliance
- ✅ Policy enforcement: Messages routed based on classification level
Transport Choice: Kafka (with encryption), AWS SQS (cross-account), Azure Event Hubs
3. Burst Load & Elastic Scaling (SaaS)
Business Problem: SaaS provider offers MCP servers for text-to-structured-data conversion. During peak hours (9-11am), thousands of clients issue requests simultaneously.
Why Async Transport:
┌──────────┐
Peak: 10k req/min───►│ Kafka │
│ Topic │
│ (Buffer)│
└──────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ MCP │ │ MCP │ │ MCP │
│ Server │ │ Server │ │ Server │
│ Pod 1 │ │ Pod 2 │ │ Pod N │
└─────────┘ └─────────┘ └─────────┘
Auto-scales based on queue depth (lag)
Benefits:
- ✅ Elastic scaling: Workers scale up/down based on queue lag
- ✅ Cost optimization: Turn off servers when idle (queue empty)
- ✅ Smooth bursts: Queue absorbs spikes; no client rate limit errors
- ✅ Priority queues: High-priority clients use separate topic
Transport Choice: Kafka (for throughput), RabbitMQ (for priority queues), Redis Streams
4. Multi-Tenant, Multi-Region (Enterprise)
Business Problem: Global enterprise has data centers in EU, US, Asia. MCP clients and servers may reside in different regions. Data sovereignty requires EU data stays in EU.
Why Async Transport:
EU Region US Region Asia Region
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Client │ │ Client │
│ (Germany)│ │ (Ohio) │ │ (Tokyo) │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Kafka │ │ Kafka │ │ Kafka │
│ EU │ │ US │ │ Asia │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ MCP │ │ MCP │ │ MCP │
│ Servers │ │ Servers │ │ Servers │
│ (EU) │ │ (US) │ │ (Asia) │
└──────────┘ └──────────┘ └──────────┘
Benefits:
- ✅ Latency optimization: Process requests close to data source
- ✅ Regulatory compliance: EU data never leaves EU (GDPR)
- ✅ Failover: Route EU requests to US if EU region down
- ✅ Topic-based routing: Geo-tagged messages route automatically
Transport Choice: Kafka (multi-region replication), AWS SQS (cross-region)
5. Cross-System Integration (AI + Legacy)
Business Problem: MCP client acts as AI orchestrator needing to trigger workflows on legacy ERP/CRM systems that cannot maintain live socket connections (batch-oriented, mainframe integration).
Why Async Transport:
┌──────────┐ Modern ┌──────────┐ Bridge ┌──────────┐
│ AI │ MCP │ Message │ Adapter │ Legacy │
│ Agent │───────────────►│ Queue │─────────────►│ ERP │
│ (Claude) │ JSON-RPC │ (SQS) │ XML/SOAP │ (SAP) │
└──────────┘ └──────────┘ └──────────┘
▲ │ │
│ │ │
│ Result │ Poll every │ Batch
│ notification │ 30 seconds │ process
└────────────────────────────┴─────────────────────────┘
Benefits:
- ✅ Bridge async systems: AI doesn’t need to know if ERP is online
- ✅ Decouple failure domains: AI continues working if ERP down
- ✅ Extend MCP reach: Wrap legacy services with MCP-compatible async interface
- ✅ Protocol translation: Queue adapter converts JSON-RPC ↔ XML/SOAP
Transport Choice: SQS (simple integration), Apache Camel + Kafka (complex routing)
6. High-Security Messaging (Defense, Blockchain)
Business Problem: Defense contractor uses MCP tools to perform sensitive computations that must be provably encrypted, tamper-evident, and non-repudiable (zero-trust architecture).
Why Custom Transport:
┌──────────┐ Encrypted ┌──────────┐ Encrypted ┌──────────┐
│ Client │ MCP │ Kafka │ MCP │ MCP │
│ (Secret) │───────────────►│ (TLS + │───────────────►│ Server │
│ Agent │ AES-256-GCM │ ACLs) │ AES-256-GCM │ (Secure │
└──────────┘ └──────────┘ │ Enclave)│
│ └──────────┘
│
▼
┌──────────┐
│ Immutable│
│ Audit │
│ Log │
└──────────┘
Benefits:
- ✅ End-to-end encryption: Payloads encrypted by client, decrypted by server only
- ✅ Non-repudiation: Kafka’s immutable log proves message history
- ✅ Policy enforcement: Custom headers enforce classification levels (TOP SECRET, etc.)
- ✅ Compliance: FIPS 140-2 validated encryption modules
Transport Choice: Encrypted Kafka transport (Example 6 above), AWS KMS + SQS
7. Event-Driven Workflows (Analytics)
Business Problem: Analytics platform uses MCP clients embedded in microservices that react to real-time business events (user signup, transaction alert, IoT sensor data).
Why Async Transport:
Event Sources Kafka Streams MCP Processors
┌──────────┐ ┌──────────┐
│ User │─┐ ┌►│ MCP │
│ Signups │ │ ┌──────────┐ │ │ Tool: │
└──────────┘ ├───────────►│ Kafka │──────────┤ │ Enrich │
│ │ Topic: │ │ │ Profile │
┌──────────┐ │ │ events │ │ └──────────┘
│ Trans- │─┤ └──────────┘ │
│ actions │ │ │ │ ┌──────────┐
└──────────┘ │ │ └►│ MCP │
│ │ │ Tool: │
┌──────────┐ │ ▼ │ Score │
│ IoT │─┘ ┌──────────┐ │ Risk │
│ Sensors │ │ MCP │ └──────────┘
└──────────┘ │ Clients │
│(Consumers)│ ┌──────────┐
└──────────┘ ┌►│ MCP │
│ │ Tool: │
Process each event │ │ Alert │
through MCP tools │ │ Webhook │
│ └──────────┘
│
│ ┌──────────┐
└►│ MCP │
│ Tool: │
│ ML │
│ Predict │
└──────────┘
Benefits:
- ✅ Reactive design: Kafka events trigger MCP workflows automatically
- ✅ Backpressure control: MCP servers process events at their own pace
- ✅ Composability: Multiple event-driven MCP clients coordinate on shared bus
- ✅ Stream processing: Kafka Streams integrates with MCP tools for windowing, joins
Transport Choice: Kafka (for event streaming), AWS Kinesis, Apache Pulsar
8. Offline/Intermittent Connectivity (Edge Devices)
Business Problem: Ships, drones, field sensors act as MCP clients but can’t maintain stable network links. They collect data offline and sync when connectivity returns.
Why Async Transport:
Edge Device (Ship) Satellite Link Cloud
┌──────────┐ ┌──────────┐
│ MCP │ Offline: │ Kafka │
│ Client │ Queue locally │ Broker │
│ │ │ │ │
│ Local │ ▼ └──────────┘
│ Queue │◄──┐ ┌──────┐ │
│ (SQLite)│ └─│ Net │ ▼
└──────────┘ │ Down │ ┌──────────┐
│ └──────┘ │ MCP │
│ │ Servers │
│ Online: │ (Cloud) │
│ Sync queue └──────────┘
▼
┌──────────┐ Network Up ┌──────────┐
│ Sync │────────────────────►│ Kafka │
│ Process │◄────────────────────│ Topic │
└──────────┘ ACKs └──────────┘
Benefits:
- ✅ Offline queuing: Requests queued locally (SQLite, LevelDB)
- ✅ Resilience: Automatic retry when connection restored
- ✅ Simplified sync: Kafka acts as durable buffer for unreliable endpoints
- ✅ Conflict resolution: Last-write-wins or custom merge strategies
Transport Choice: Local queue + Kafka sync, MQTT (IoT-optimized), AWS IoT Core
Summary: When to Build Custom Transports
| Use Case | Latency Tolerance | Complexity | ROI |
|---|---|---|---|
| Long-running ops | High (minutes) | Medium | ✅ High |
| Air-gapped security | Medium (seconds) | High | ✅ High |
| Burst scaling | Medium (100-500ms) | Medium | ✅ High |
| Multi-region | Medium (100-500ms) | High | ✅ Medium |
| Legacy integration | High (minutes) | Medium | ✅ High |
| High-security | Low (milliseconds) | High | ✅ Medium |
| Event-driven | Medium (100-500ms) | Medium | ✅ High |
| Offline/edge | High (minutes-hours) | Medium | ✅ High |
Decision Criteria:
- ✅ Build custom transport if: Regulatory requirements, legacy constraints, or operational patterns prevent standard transports
- ❌ Avoid if: Standard HTTP/WebSocket/stdio meets needs (99% of cases)
Gateway/Proxy Pattern (Recommended)
The Right Way to Use Custom Transports: Instead of requiring all clients to implement custom transports, use a gateway that translates between standard and custom transports.
Architecture
Standard MCP Clients Gateway Custom Backend
┌──────────┐ ┌──────────┐
│ Claude │ WebSocket ┌──────────┐ Kafka │ MCP │
│ Desktop │◄────────────────►│ │◄──────►│ Server │
└──────────┘ │ │ │ Pool │
│ MCP │ └──────────┘
┌──────────┐ │ Gateway │ ┌──────────┐
│ Custom │ HTTP │ │ SQS │ Lambda │
│ Client │◄────────────────►│ Bridge │◄──────►│ Workers │
└──────────┘ │ │ └──────────┘
│ Policy │ ┌──────────┐
┌──────────┐ │ Layer │ HTTP │ Legacy │
│ IDE │ stdio/HTTP │ │◄──────►│ Backend │
│ Plugin │◄────────────────►│ │ └──────────┘
└──────────┘ └──────────┘
Benefits
✅ Client Compatibility:
- Standard MCP clients work unchanged (Claude Desktop, IDEs)
- No client-side custom transport implementation needed
- One gateway serves all clients
✅ Control Point:
- Authorization: Check permissions before routing
- DLP: Redact PII, filter sensitive data
- Rate Limiting: Per-client quotas
- Schema Validation: Reject malformed requests
- Audit: Centralized logging of all MCP traffic
✅ Backend Flexibility:
- Route to different backends based on capability
- Load balancing across server pool
- Circuit breakers for failing backends
- Automatic retries with backoff
- Protocol translation (JSON-RPC ↔ XML/SOAP)
✅ Observability:
- Centralized metrics (latency, throughput, errors)
- Distributed tracing across transports
- Real-time monitoring dashboards
Implementation
Minimal Bridge (Bidirectional Forwarder):
#![allow(unused)] fn main() { use pmcp::shared::{Transport, TransportMessage}; use pmcp::error::Result; use async_trait::async_trait; use std::sync::Arc; use tokio::sync::Mutex; /// Bridge that forwards messages between two transports pub async fn bridge_transports( mut client_side: Box<dyn Transport + Send>, mut backend_side: Box<dyn Transport + Send>, ) -> Result<()> { // Client -> Backend let c2b = tokio::spawn(async move { loop { match client_side.receive().await { Ok(msg) => { if let Err(e) = backend_side.send(msg).await { tracing::error!("Failed to forward to backend: {}", e); break; } } Err(e) => { tracing::info!("Client disconnected: {}", e); break; } } } Result::<()>::Ok(()) }); // Backend -> Client let b2c = tokio::spawn(async move { loop { match backend_side.receive().await { Ok(msg) => { if let Err(e) = client_side.send(msg).await { tracing::error!("Failed to forward to client: {}", e); break; } } Err(e) => { tracing::info!("Backend disconnected: {}", e); break; } } } Result::<()>::Ok(()) }); // If either side exits, close the other tokio::select! { r = c2b => { let _ = r?; } r = b2c => { let _ = r?; } } Ok(()) } // Usage let client_transport = WebSocketTransport::new(config)?; let backend_transport = KafkaTransport::new_client(brokers, client_id).await?; tokio::spawn(bridge_transports( Box::new(client_transport), Box::new(backend_transport), )); }
Policy-Enforcing Gateway:
#![allow(unused)] fn main() { /// Transport wrapper that enforces policies pub struct PolicyTransport<T: Transport> { inner: T, policy: Arc<PolicyEngine>, } #[async_trait] impl<T: Transport + Send + Sync> Transport for PolicyTransport<T> { async fn send(&mut self, msg: TransportMessage) -> Result<()> { // Enforce outbound policies (rate limits, quotas) self.policy.check_send(&msg).await?; // Log for audit tracing::info!("Sending: {:?}", msg); self.inner.send(msg).await } async fn receive(&mut self) -> Result<TransportMessage> { let msg = self.inner.receive().await?; // Enforce inbound policies (schema validation, PII redaction) let sanitized = self.policy.sanitize(msg).await?; // Log for audit tracing::info!("Received: {:?}", sanitized); Ok(sanitized) } async fn close(&mut self) -> Result<()> { self.inner.close().await } fn is_connected(&self) -> bool { self.inner.is_connected() } fn transport_type(&self) -> &'static str { "policy" } } // Usage let base = KafkaTransport::new_client(brokers, client_id).await?; let policy_engine = Arc::new(PolicyEngine::new()); let policy_transport = PolicyTransport { inner: base, policy: policy_engine, }; }
Full Gateway Service:
use pmcp::shared::{Transport, WebSocketTransport, WebSocketConfig}; use tokio::net::TcpListener; #[tokio::main] async fn main() -> Result<()> { // Listen for WebSocket connections from clients let listener = TcpListener::bind("0.0.0.0:8080").await?; tracing::info!("Gateway listening on port 8080"); loop { let (stream, addr) = listener.accept().await?; tracing::info!("New client connection from {}", addr); tokio::spawn(async move { // Create client-facing transport (WebSocket) let ws_config = WebSocketConfig { /* ... */ }; let client_transport = WebSocketTransport::from_stream(stream, ws_config); // Create backend transport (Kafka/SQS based on routing logic) let backend_transport = select_backend_transport(addr).await?; // Wrap in policy layer let policy_transport = PolicyTransport { inner: backend_transport, policy: Arc::new(PolicyEngine::new()), }; // Bridge the two transports bridge_transports( Box::new(client_transport), Box::new(policy_transport), ).await }); } } async fn select_backend_transport(client_addr: SocketAddr) -> Result<Box<dyn Transport>> { // Route based on client, capability, tenant, etc. if client_addr.ip().is_loopback() { // Local clients -> direct HTTP Ok(Box::new(HttpTransport::new(/* ... */))) } else { // External clients -> Kafka let client_id = format!("gateway-{}", uuid::Uuid::new_v4()); Ok(Box::new(KafkaTransport::new_client(KAFKA_BROKERS, client_id).await?)) } }
Routing Strategies
1. Capability-Based:
#![allow(unused)] fn main() { async fn route_by_capability(request: &TransportMessage) -> String { match request { TransportMessage::Request { request, .. } => { match request { Request::Client(ClientRequest::CallTool(params)) => { // Route to Kafka topic based on tool name if params.name.starts_with("ml_") { "mcp.requests.ml-tools".to_string() } else if params.name.starts_with("data_") { "mcp.requests.data-tools".to_string() } else { "mcp.requests.global".to_string() } } _ => "mcp.requests.global".to_string(), } } _ => "mcp.requests.global".to_string(), } } }
2. Load Balancing:
#![allow(unused)] fn main() { struct BackendPool { backends: Vec<Box<dyn Transport>>, current_index: AtomicUsize, } impl BackendPool { fn get_next(&self) -> &Box<dyn Transport> { let idx = self.current_index.fetch_add(1, Ordering::Relaxed); &self.backends[idx % self.backends.len()] } } }
3. Circuit Breaker:
#![allow(unused)] fn main() { struct CircuitBreakerTransport<T: Transport> { inner: T, state: Arc<Mutex<CircuitState>>, } enum CircuitState { Closed { failures: u32 }, Open { until: Instant }, HalfOpen, } impl<T: Transport> Transport for CircuitBreakerTransport<T> { async fn send(&mut self, msg: TransportMessage) -> Result<()> { let state = self.state.lock().await; match *state { CircuitState::Open { until } if Instant::now() < until => { return Err(Error::Transport(TransportError::ConnectionClosed)); } _ => {} } drop(state); match self.inner.send(msg).await { Ok(()) => { // Success - reset failures let mut state = self.state.lock().await; *state = CircuitState::Closed { failures: 0 }; Ok(()) } Err(e) => { // Failure - increment counter let mut state = self.state.lock().await; if let CircuitState::Closed { failures } = *state { if failures + 1 >= 5 { // Trip circuit breaker *state = CircuitState::Open { until: Instant::now() + Duration::from_secs(30), }; } else { *state = CircuitState::Closed { failures: failures + 1 }; } } Err(e) } } } // ... rest of implementation } }
Testing with Gateway
Integration Testing:
# Start gateway
cargo run --bin mcp-gateway
# Test with mcp-tester against gateway (WebSocket)
mcp-tester test ws://localhost:8080 \
--with-tools \
--format json > results.json
# Gateway exercises custom backend transport (Kafka/SQS)
# under realistic conditions
Key Advantages:
- ✅ Client compatibility: Standard clients work without modification
- ✅ Flexibility: Change backend transport without client changes
- ✅ Testability:
mcp-testervalidates end-to-end flow - ✅ Incremental migration: Gradually move backends to custom transports
Advanced Topics
Connection Pooling
For HTTP-like transports, implement pooling:
#![allow(unused)] fn main() { use deadpool::managed::{Manager, Pool, RecycleResult}; struct MyTransportManager; #[async_trait] impl Manager for MyTransportManager { type Type = MyTransport; type Error = Error; async fn create(&self) -> Result<MyTransport, Error> { MyTransport::connect().await } async fn recycle(&self, conn: &mut MyTransport) -> RecycleResult<Error> { if conn.is_connected() { Ok(()) } else { Err(RecycleResult::StaticMessage("Connection lost")) } } } // Usage let pool: Pool<MyTransportManager> = Pool::builder(MyTransportManager) .max_size(10) .build() .unwrap(); let transport = pool.get().await?; }
Middleware Support
Wrap transports with cross-cutting concerns:
#![allow(unused)] fn main() { pub struct LoggingTransport<T: Transport> { inner: T, } #[async_trait] impl<T: Transport> Transport for LoggingTransport<T> { async fn send(&mut self, message: TransportMessage) -> Result<()> { tracing::info!("Sending message: {:?}", message); let start = std::time::Instant::now(); let result = self.inner.send(message).await; tracing::info!("Send completed in {:?}", start.elapsed()); result } async fn receive(&mut self) -> Result<TransportMessage> { tracing::debug!("Waiting for message..."); let message = self.inner.receive().await?; tracing::info!("Received message: {:?}", message); Ok(message) } async fn close(&mut self) -> Result<()> { self.inner.close().await } } }
Conclusion
Custom transports unlock MCP’s full potential for specialized environments:
Simple Use Cases:
- ✅ Use in-memory transports for testing
- ✅ Use built-in transports (HTTP, WebSocket, stdio) for standard cases
Advanced Use Cases:
- ✅ Async messaging (SQS, Kafka) for decoupled architectures
- ✅ Custom protocols when integrating with legacy systems
- ✅ Performance optimizations for high-throughput scenarios
Remember:
- GraphQL → Build MCP servers with GraphQL tools (not transport)
- Custom transports require both client and server support
- Stick to standard transports unless you have specific infrastructure needs
- Test thoroughly with unit tests, integration tests, and mcp-tester
Additional Resources:
- MCP Specification: Transports
- asyncmcp GitHub: AWS SQS/SNS transport examples
- Chapter 15: Testing: Use mcp-tester to validate custom transports
- Apollo MCP Server: GraphQL + MCP example
Ch24 Extensions
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch25 Analysis
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Ch26 Contributing
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix A Installation
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix B Config
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix C Api
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix D Errors
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix E Troubleshooting
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix F Glossary
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases
Appendix G Resources
This chapter is under development. Check back soon!
Coming Soon
This section will cover:
- Core concepts and implementation
- Working examples with explanations
- Best practices and patterns
- Real-world use cases