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:

  1. Install and configure PMCP for your environment
  2. Build your first server with tools, resources, and prompts
  3. Create robust clients that handle errors gracefully
  4. Implement advanced features like authentication and middleware
  5. Deploy to production with confidence and monitoring
  6. 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

  1. Real-Time Data: Your AI system always has access to the most current information, eliminating the “stale data” problem inherent in fine-tuned models.
  2. 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.
  3. Cost-Effective: Avoids the significant computational and financial costs associated with repeatedly fine-tuning large models.
  4. 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.
  5. 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

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

FeatureDescriptionDependencies
defaultCore functionality + validationjsonschema, garde
fullAll features enabledAll dependencies
websocketWebSocket transporttokio-tungstenite
httpHTTP transporthyper, hyper-util
streamable-httpStreamable HTTP serveraxum, tokio-stream
sseServer-Sent Eventsbytes, tokio-util
validationInput validationjsonschema, garde
resource-watcherFile system watchingnotify, glob-match
wasmWebAssembly supportwasm-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:

  1. Build your first server - Chapter 2 walks through creating a basic MCP server
  2. Create a client - Chapter 3 shows how to connect and interact with servers
  3. Explore examples - Check out the examples/ directory for real-world patterns

Getting Help

If you encounter issues:

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:

  1. Tool handlers - Functions that clients can call
  2. Server configuration - Name, version, capabilities
  3. 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

  1. Tool handlers are the core - They define what your server can do
  2. Error handling is crucial - Use PMCP’s error types for protocol compliance
  3. State management works - Use Rust’s sync primitives for shared state
  4. Testing is straightforward - PMCP handlers are easy to unit test
  5. 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 ecosystemMCP ecosystemPurpose
Chrome, Firefox, Safari, EdgeClaude Desktop, ChatGPT, Cursor, Co-PilotEnd-user applications that humans use
Selenium, Playwright, PuppeteerMCP Tester, custom test clientsAutomated testing and validation tools
cURL, Postman, HTTPieSimple MCP clients, debugging toolsDeveloper tools for exploration and debugging
Browser DevToolsMCP InspectorProtocol 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:

  1. Connection Management: Establish and maintain the connection to a server
  2. Protocol Handling: Implement JSON-RPC 2.0 and MCP protocol semantics
  3. 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:

  1. Initialize first: Always call client.initialize() before use
  2. Check capabilities: Verify the server supports what you need
  3. Handle errors: Distinguish between protocol, transport, and application errors
  4. Use the right transport: stdio for local, HTTP for cloud, WebSocket for real-time
  5. Test thoroughly: Use MCP Tester to validate your implementations
  6. 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 elementMCP primitivePurpose for agents
Home page instructionsPromptsSet expectations, goals, and usage patterns for the agent
Navigation tabsCapability discoveryShow what exists: tools, prompts, resources
Forms with fieldsTools (actions)Perform operations with validated, typed arguments
Form labels/help textTool schema + descriptionsGuide correct input and communicate constraints
Docs/FAQ/PoliciesResourcesProvide reference material the agent can read and cite
Notifications/toastsProgress and eventsCommunicate long-running work, partial results, and completion
Error pagesStructured errorsTell 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/list and prompts/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/list to surface available workflows; each prompt advertises arguments and a description.
  • Templated: Clients call prompts/get with 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)

  1. Land on “home” → The agent discovers prompts and reads guidance on how to use the server.

  2. Browse navigation → The agent calls tools/list, resources/list, and optionally prompts/list to map the surface area.

  3. Open a form → The agent selects a tool, reads its schema and descriptions, and prepares arguments.

  4. Submit the form → The agent calls tools/call with structured arguments.

  5. Watch progress → The server emits progress updates and finally returns a result or error.

  6. Read the docs → The agent calls resources/read to consult policies, FAQs, or domain data.

  7. 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 SimpleTool or 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 FormsMCP Tools (PMCP)Security Benefit
HTML form with input fieldsRust struct with typed fieldsCompile-time type checking
JavaScript validationserde validation + custom checksZero-cost abstractions
Server-side sanitizationRust’s ownership & borrowingMemory safety guaranteed
Form submissionTool invocation via JSON-RPCType-safe parsing
Success/error responseTyped ResultExhaustive 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:

  1. Schema Generation: Rust types automatically generate accurate JSON schemas
  2. Validation: Invalid inputs rejected before handler execution
  3. Error Messages: Type mismatches produce actionable errors LLMs can fix
  4. Examples: Type definitions document expected inputs
  5. 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:

  1. Name + Description → What the tool does
  2. Input Types → Typed struct with validation
  3. Output Types → Structured response
  4. Validation → Check inputs thoroughly
  5. Error Handling → Clear, actionable messages
  6. 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:

  1. Doc comments on every field → LLMs read these as descriptions

  2. 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
    }
  3. 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.

  4. 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"
    }
  5. Clear field namesa and b are 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 (result vs parsing strings)
  • Easier to chain tools (next tool uses result field directly)
  • Type-safe: consumers know exact structure at compile time
  • PMCP automatically wraps this in CallToolResult for 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:

  1. Type validation (automatic via serde)
  2. Domain validation (division by zero, infinity)
  3. 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 TypeWhen to UsePMCP ConstructorExample
ValidationInvalid arguments, bad formats, constraint violationsError::validation("...")“Amount must be positive. Received: -50.0”
Protocol MisuseWrong parameter types, missing required fields (protocol-level)Error::protocol(ErrorCode::INVALID_PARAMS, "...")“Missing required ‘customer_id’ field”
Not FoundTool/resource/prompt doesn’t existError::protocol(ErrorCode::METHOD_NOT_FOUND, "...")“Tool ‘unknown_tool’ not found”
InternalServer-side failures, database errors, unexpected statesError::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:

  1. Happy path: Most common use case
  2. Edge case: Boundary values (zero, negative, large numbers)
  3. 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 is better than serde defaults:

  1. Schema Accuracy: JSON schema clearly shows ["number", "null"] (optional)
  2. Type Safety: Compiler enforces handling the None case
  3. LLM Clarity: LLM sees field is optional in the required array
  4. No Magic: No hidden default functions, behavior is explicit
  5. 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(),
            &params.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(&params);
        {
            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(&params)?;

        // 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:

  1. Use typed structs for all tools (not dynamic JSON)
  2. Document every field with examples
  3. Write error messages that guide LLMs to success (problem + fix + example)
  4. Use Option for optional fields (not serde defaults)
  5. Validate early and thoroughly
  6. 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 StaticResource and ResourceCollection
  • 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 ElementMCP ResourceAgent Use Case
Documentation pagesText resourcesRead policies, guides, references
FAQ/Help articlesMarkdown/HTML resourcesLearn how to use the service
Configuration filesJSON/YAML resourcesUnderstand settings and options
Data exportsCSV/JSON resourcesAccess structured data
Images/diagramsImage resourcesView visual information
API specsOpenAPI/JSON resourcesUnderstand 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:

  1. Policies & Rules: “Can I refund orders over $1000?” → Read docs://policies/refunds
  2. Data for Reasoning: “What products are popular?” → Read data://products/trending.json
  3. Templates & Examples: “How do I format emails?” → Read templates://email/welcome.html
  4. Current State: “What’s in the config?” → Read config://app/settings.json
  5. 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:

ComponentRequired?PurposeExample
URI✅ RequiredUnique, stable identifierdocs://policies/refunds
Name✅ RequiredHuman-readable label“Refund Policy”
Description⚠️ RecommendedExplains purpose & content“30-day refund rules…”
MIME Type⚠️ RecommendedContent formattext/markdown
Priority⚠️ RecommendedImportance (0.0–1.0)0.9 (must-read policy)
Modified At⚠️ RecommendedLast update timestamp2025-01-15T10:30:00Z
Content✅ RequiredThe actual dataText, Image, or JSON
List Method✅ RequiredDiscovery (enumerate)Returns all resources
Read Method✅ RequiredFetch content by URIReturns resource content
Notify⚠️ OptionalUpdate subscriptionsWhen 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 ResourceHandler trait (Advanced section)
  • File system? → Use ResourceWatcher (Advanced section)

Resource Anatomy: Step-by-Step

Every resource follows this anatomy:

  1. URI + Description → Unique identifier and purpose
  2. Content Types → Text, Image, or Resource content
  3. Resource Metadata → Name, MIME type, description
  4. List Implementation → Enumerate available resources
  5. Read Implementation → Return resource content
  6. 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.json not usr/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
  • 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_FOUND for 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 ResourceUpdated notifications
  • ✅ 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

DoDon’t
Use stable, hierarchical URIs with clear namesUse resources for actions (that’s a tool)
Populate priority and modified_at accuratelyExpose internal filesystem paths or external URLs directly
Keep resources small, focused, and well-describedShip 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” referencesUse non-stable URIs that change across versions
Test resources with mcp-tester and integration testsAssume 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 (refunds not r1)
  • 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 section
  • docs://api-reference#post-orders - Direct link to specific endpoint

Consistency wins: Keep heading formats predictable across all 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:

  1. Use static resources for fixed content (docs, configs, templates)
  2. Use dynamic handlers for database/API-backed content
  3. Use URI templates for parameterized resources
  4. Use ResourceWatcher for file system monitoring
  5. Provide clear metadata (name, description, MIME type)
  6. 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 ElementMCP PromptPurpose
“Get Started” buttonSimple promptQuick access to common workflow
Multi-step wizardWorkflow promptGuided multi-tool orchestration
Template formsPrompt with argumentsPre-filled workflows with user input
Help tooltipsPrompt descriptionsExplain what the prompt does
Form validationArgument validationEnsure 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:

  1. User Intent Clarity: User selects “Generate weekly report” → LLM knows exact workflow
  2. Tool Orchestration: Prompt defines sequence (fetch data → calculate → format → save)
  3. Context Pre-loading: Prompt includes system instructions and resource references
  4. Argument Guidance: User provides structured inputs (date range, format, recipients)
  5. 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:

  1. Name + Description → What the prompt does
  2. Arguments → User inputs (required vs optional)
  3. Messages → Structured conversation (System, User, Assistant)
  4. Message Content Types → Text, Image, or Resource references
  5. 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-post not blog
  • 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:

  1. Clear expectations: System message defines workflow steps
  2. Resource integration: Assistant provides style guides
  3. 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:

  1. Closure captures arguments: |args| { ... }
  2. Validation: Check required fields and values
  3. Message construction: System → Assistant → User
  4. Metadata: Description helps users understand the prompt
  5. 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:

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:

  1. SequentialWorkflow: Defines a multi-step workflow

  2. WorkflowStep: Individual steps that call tools

  3. Bindings: .bind("name") creates named outputs

  4. DSL helpers:

    • prompt_arg("a") - Reference workflow argument
    • from_step("discriminant") - Use entire output from previous step
    • field("discriminant", "value") - Extract specific field from output
    • constant(json!("value")) - Provide constant value
  5. 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() and field()
  • ❌ 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:

  1. 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")
}
  1. 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)
}
  1. 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:

  1. Executing deterministic steps server-side (can’t be skipped)
  2. Providing complete context (tool results + resources)
  3. Offering clear guidance for remaining steps
  4. 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:

  1. Executes tools server-side for steps with resolved parameters
  2. Fetches and embeds resources to provide context
  3. Returns conversation trace showing what was done
  4. 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") or field("binding", "field"))
    • Constants (via constant(json!(...)))
  • ✅ 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:

  1. Registration time (.prompt_workflow()):

    • Validates workflow (bindings, arguments, tool references exist)
    • Registers as prompt (discoverable via prompts/list)
    • Returns error if validation fails
  2. 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)
  3. 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

  1. Use descriptive binding names:
#![allow(unused)]
fn main() {
// ❌ Bad: Unclear
.bind("r1")
.bind("out")

// ✅ Good: Clear purpose
.bind("analysis_result")
.bind("formatted_output")
}
  1. 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(...)
}
  1. 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
)
}
  1. 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"))
}
  1. Validate workflows early:
#![allow(unused)]
fn main() {
let workflow = create_my_workflow();
workflow.validate()?; // ← Catch errors before registration
}
  1. 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
)
}
  1. 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
)
}
  1. 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

FeatureSimple Prompt (SyncPrompt)Workflow (SequentialWorkflow)
Use caseSingle-message promptsMulti-step tool orchestration
ExecutionReturns instructions onlyExecutes tools server-side
ComplexitySimpleModerate to complex
Tool compositionLLM decidesPre-defined sequence
Data flowNoneExplicit bindings
ValidationArgument checksFull workflow validation
Compliance~60-70% (LLM decides)~85-95% (server guides)
Resource embeddingManual referencesAutomatic fetch & embed
ExamplesCode review, blog post generationLogseq 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:

  1. Start with SyncPrompt for simple instruction-only prompts
  2. Use workflows when you need high compliance and multi-step orchestration
  3. Design workflows for hybrid execution: server executes what it can, client continues with guidance
  4. Use .with_guidance() for steps requiring LLM reasoning
  5. Use .with_resource() to embed documentation and reduce hallucination
  6. Validate arguments thoroughly and workflows early
  7. Test with mcp-tester and unit tests
  8. 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 type T
  • Err(E) - Failure with error of type E

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:

  1. Returns the error if the operation failed
  2. Unwraps the success value if it succeeded
  3. 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:

CodeConstantWhen to Use
-32700PARSE_ERRORInvalid JSON received
-32600INVALID_REQUESTRequest structure is wrong
-32601METHOD_NOT_FOUNDUnknown method/tool name
-32602INVALID_PARAMSParameter validation failed
-32603INTERNAL_ERRORServer-side failure

MCP-Specific Error Codes:

CodeConstantWhen to Use
-32001REQUEST_TIMEOUTRequest exceeded timeout
-32002UNSUPPORTED_CAPABILITYFeature not supported
-32003AUTHENTICATION_REQUIREDAuth needed
-32004PERMISSION_DENIEDUser lacks permission
-32005RATE_LIMITEDRate limit exceeded
-32006CIRCUIT_BREAKER_OPENCircuit 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:

  1. Different Error Types - Parse, validation, internal, rate limiting
  2. Input Validation - Length checks, character validation
  3. Retry Logic - Exponential backoff for transient failures
  4. Timeout Handling - Preventing hung operations
  5. Recovery Strategies - Fallback and circuit breaker patterns
  6. 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 ErrorCode for 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 Error enum for all public APIs
  • Avoid Error::Other(anyhow::Error) in library interfaces
  • Provide specific error types that callers can match on
  • Use thiserror for 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::validationAdd 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

  1. Rust makes errors visible - Result<T, E> shows what can fail
  2. Pattern matching is powerful - Handle all cases exhaustively
  3. The ? operator is your friend - Concise error propagation
  4. PMCP provides rich error types - Aligned with MCP and JSON-RPC standards
  5. Different errors need different strategies - Retry, fallback, fail fast
  6. Clear error messages help users - Be specific and actionable
  7. Test your error handling - Errors are part of your API
  8. 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

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:

  1. Receive access tokens from MCP clients
  2. Validate tokens against your existing OAuth provider
  3. Extract user identity from validated tokens
  4. Enforce permissions based on scopes and roles
  5. 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:

  1. OAuth flow is external - The client handles authorization code flow, token exchange, and refresh
  2. MCP server validates tokens - Server checks tokens against OAuth provider for each request
  3. User context is propagated - Tools know which user is calling them
  4. 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-configuration endpoint for provider metadata

This is the most secure flow for MCP clients:

  1. 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
    
  2. User authenticates and grants permission

  3. Authorization server redirects back with code

    http://localhost:3000/callback?code=auth_code_xyz&state=random-state-value
    
  4. 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
    
  5. Receive tokens

    {
      "access_token": "eyJhbGc...",
      "token_type": "Bearer",
      "expires_in": 3600,
      "refresh_token": "refresh_xyz...",
      "scope": "openid profile read:tools write:tools"
    }
    
  6. 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 OAuthProvider trait

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 scopes
  • ScopeMiddleware::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:

  1. Authentication - Validates token and extracts user identity
  2. Authorization - Checks if user has permission for this specific action
  3. Audit logging - Records who accessed what
  4. 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 identifier
  • authorization_endpoint - Where to redirect users for login
  • token_endpoint - Where to exchange codes for tokens
  • jwks_uri - Public keys for validating JWT signatures
  • userinfo_endpoint - Where to fetch user profile data
  • scopes_supported - Available permission scopes
  • grant_types_supported - Supported OAuth flows
  • token_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 BearerTokenMiddleware and ScopeMiddleware
  • 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_id in 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

  1. Reuse existing OAuth infrastructure - Don’t reinvent authentication
  2. MCP servers = web servers - Same token validation patterns apply
  3. OAuth flows are external - MCP server only validates tokens
  4. Act on behalf of users - Use access tokens to enforce user permissions
  5. Validate everything - Signature, expiration, audience, scopes
  6. Log security events - Track authentication and authorization
  7. Use HTTPS always - Protect tokens in transit
  8. Keep tokens short-lived - Use refresh tokens for long sessions
  9. Enforce least privilege - Grant minimum necessary permissions
  10. 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

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:

ClientPreferred TransportWhy?
Claude DesktopstdioSimple process spawning, secure local execution
Cursor IDEHTTP/WebSocketNetwork-accessible, works with remote servers
Web-based AI agentsHTTP/WebSocketBrowser compatibility, CORS support
ChatGPTHTTPRESTful integration, scalable backend
GitHub Copilotstdio/HTTPIDE integration flexibility
Custom enterprise toolsAnyDepends 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 as pmcp::server::transport::websocket::WebSocketServerTransport (feature: websocket) and demonstrated in examples/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 wasm32 targets, Transport relaxes Send + Sync requirements; use WasmHttpTransport and WasmWebSocketTransport

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 transport
  • http: Base HTTP utilities (included with streamable-http)
  • websocket: WebSocket client transport (crate root) and server transport (pmcp::server::transport::websocket), requires tokio
  • wasm: 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-id on 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-stream header
  • Server responds with SSE stream for notifications
  • Supports resumption via Last-Event-Id header
  • 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

FeatureStateless + JSONStateful + JSONStateful + 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 forLambda, WorkersContainers, VPSReal-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

ScenarioRecommended TransportModeWhy?
Claude DesktopStdioN/ANative support, simplest setup
Cursor IDEStreamable HTTPStateful + SSECursor expects SSE for notifications
AWS LambdaStreamable HTTPStateless + JSONNo sessions, cold-start friendly
Cloudflare WorkersStreamable HTTPStateless + JSONServerless edge, no state
Google Cloud FunctionsStreamable HTTPStateless + JSONServerless, auto-scaling
Docker/KubernetesStreamable HTTPStateful + JSON or SSELong-lived, sessions work
Fly.io / RailwayStreamable HTTPStateful + SSEPersistent servers, real-time
Traditional VPSStreamable HTTPStateful + SSEFull control, long-lived
Browser demoWASM + Streamable HTTPClient-sideNo backend required
Enterprise proxyStreamable HTTPAny modeFirewall-friendly HTTPS
Connect to WS serverWebSocket (client)N/AFull-duplex communication
Multi-tenant SaaSStreamable HTTPStateful + SSESession 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 include application/json or text/event-stream
    • Accept: application/json → Simple JSON responses
    • Accept: 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 notifications
  • examples/23_streamable_http_server_stateless.rs - Serverless-friendly, no sessions
  • examples/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

FeatureStdioStreamable HTTPWebSocket (client)
Setup ComplexityTrivialMediumMedium
LatencyLowestLowLowest
Server NotificationsFullSSE streamsFull
State ManagementPer-processOptional sessionsConnection-based
Firewall FriendlyN/A✅ Yes⚠️ Sometimes
Scalability1:1 processHorizontal/session-awareConnection pools
Browser Support❌ No✅ Yes✅ Yes
Message FormatLine-delimited JSONJSON + SSEJSON text frames
Connection PersistenceProcess lifetimeSession-scopedPersistent
Server ImplementationBuilt-inBuilt-in (Axum)Client-only
Best forLocal desktop appsCursor IDE, web apps, cloudConnecting 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)

  1. Stdio: ~0.1ms (local IPC, no network)
  2. WebSocket (client): ~1-5ms (persistent connection, JSON text frames)
  3. Streamable HTTP: ~2-10ms (HTTP POST + SSE)

Throughput Rankings (Messages/sec)

  1. Stdio: 5,000+ (line-buffered, local IPC)
  2. WebSocket (client): 3,000+ (persistent, JSON text)
  3. Streamable HTTP: 1,000+ (HTTP overhead, optional sessions)

Memory Rankings (Per Connection)

  1. Stdio: ~100KB (process isolation, stdio buffers)
  2. Streamable HTTP (stateless): ~50KB (no session state)
  3. WebSocket (client): ~200KB (frame buffers, reconnection logic)
  4. 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_endpoint for 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 include application/json or text/event-stream
  • mcp-session-id: Present in stateful mode
  • Last-Event-Id: For SSE resumption

Accept rules:

  • Accept: application/json → JSON responses only
  • Accept: 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 notifications
  • examples/23_streamable_http_server_stateless.rs – Stateless/serverless-friendly configuration
  • examples/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

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

#![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

  1. Validation First: Reject invalid requests before doing expensive work
  2. Protection Before Processing: Rate limit and circuit break early
  3. Transform in the Middle: Business logic and compression
  4. 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

  1. Two Middleware Systems: Basic Middleware for simple cases, AdvancedMiddleware for production
  2. Priority Ordering: Control execution order with MiddlewarePriority
  3. Context Propagation: Share data and metrics with MiddlewareContext
  4. Built-in Patterns: Rate limiting, circuit breakers, metrics, compression
  5. Conditional Execution: should_execute() for selective middleware
  6. 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

  1. Keep Middleware Focused: Single responsibility per middleware
  2. Order Matters: Validation → Protection → Logic → Observation
  3. Use Priorities: Let EnhancedMiddlewareChain auto-sort
  4. Conditional Execution: Skip expensive operations when possible
  5. Monitor Performance: Use PerformanceMetrics and context
  6. Handle Errors Gracefully: Implement on_error() for cleanup
  7. Test in Isolation: Unit test middleware independently

Examples Reference

  • examples/15_middleware.rs: Basic middleware chain
  • examples/30_enhanced_middleware.rs: Advanced patterns with built-in middleware
  • Inline doctests in src/shared/middleware.rs demonstrate 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 Authorization header
  • 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:

  1. Processing stops immediately
  2. on_error() is called for ALL middleware in the chain
  3. Original error is returned to caller

Middleware Priority Reference

HTTP Middleware:

  • 0-9: Reserved for critical security middleware
  • 10: OAuthClientMiddleware (default)
  • 20-49: Custom authentication/authorization
  • 50: Default priority
  • 51-99: Logging, metrics, headers

Protocol Middleware:

  • Critical (0): Validation, security
  • High (1): Rate limiting, circuit breakers
  • Normal (2): Business logic, compression
  • Low (3): Metrics, logging
  • Lowest (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

  1. Use ServerPreset for common cases: Defaults cover most production needs
  2. HTTP middleware for transport concerns: Auth, CORS, logging, compression
  3. Protocol middleware for business logic: Validation, metrics, rate limiting
  4. Order matters:
    • HTTP: Auth → CORS → Logging
    • Protocol: Validation → Rate Limit → Metrics
  5. Redaction is critical: Never log sensitive headers/query params
  6. Fast path for performance: Omit HTTP middleware if not needed
  7. SSE optimization: Don’t buffer bodies for text/event-stream

Server Middleware Priority Reference

HTTP Middleware:

  • 0-9: Reserved for critical security middleware
  • 10-29: Authentication, authorization
  • 30-49: CORS, security headers
  • 50: Default priority (ServerHttpLoggingMiddleware)
  • 51-99: Metrics, custom headers

Protocol Middleware (same as client):

  • Critical (0): Validation, security
  • High (1): Rate limiting, circuit breakers
  • Normal (2): Business logic
  • Low (3): Metrics
  • Lowest (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:

  1. Progress must be finite and non-negative
  2. Total (if provided) must be finite and non-negative
  3. Progress cannot exceed total (with epsilon tolerance)
  4. 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:

  1. Extracts the token from the request
  2. Creates a ServerProgressReporter
  3. 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

  1. User Visibility: Users see exactly which phase is executing
  2. Time Estimation: Clear progress (3/5 steps) shows time remaining
  3. Cancellation Points: Users can cancel between steps if needed
  4. 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

  1. Client sends request with _meta.progressToken

    {
      "method": "tools/call",
      "params": {
        "name": "process_data",
        "arguments": {"file": "data.csv"},
        "_meta": {
          "progressToken": "task-123"
        }
      }
    }
    
  2. 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());
    }
  3. 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)));
    }
  4. Tool reports progress using helper methods

    #![allow(unused)]
    fn main() {
    extra.report_count(50, 100, Some("Halfway done")).await?;
    }
  5. Reporter sends notification through notification channel

    {
      "method": "notifications/progress",
      "params": {
        "progressToken": "task-123",
        "progress": 50,
        "total": 100,
        "message": "Halfway done"
      }
    }
    
  6. Client receives notifications and updates UI

Cancellation Flow

  1. Client sends cancellation notification

    {
      "method": "notifications/cancelled",
      "params": {
        "requestId": "123",
        "reason": "User cancelled operation"
      }
    }
    
  2. 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?;
    }
  3. Tool checks cancellation in its loop

    #![allow(unused)]
    fn main() {
    if extra.is_cancelled() {
        return Err(Error::internal("Cancelled"));
    }
    }
  4. 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:

  1. 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
}
}
  1. 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:

  1. 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.
  2. Client sent _meta.progressToken in request?
  3. Server has notification_tx channel configured?
  4. Progress values are valid (finite, non-negative)?
  5. Rate limiting not too aggressive? (check interval)

Cancellation Not Working

Symptom: Tool continues running after cancellation

Checks:

  1. Tool calls extra.is_cancelled() regularly?
  2. Tool doesn’t have blocking operations preventing cancellation checks?
  3. CancellationManager received the cancellation notification?
  4. 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:

  1. MCP Inspector Client (MCPI) - React-based web UI for interactive testing
  2. 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-issuer is omitted, the tester attempts OIDC discovery from the MCP server base URL (e.g., https://api.example.com/.well-known/openid-configuration). Providing --oauth-issuer explicitly 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:

  1. ✅ Tester generates secure PKCE challenge
  2. 🌐 Opens your browser to the OAuth provider login page
  3. 🔐 You authenticate with your credentials
  4. ✅ Tester receives the authorization code via local callback server
  5. 🎫 Exchanges code for access token
  6. 💾 Caches token locally (~/.mcp-tester/tokens.json) for future requests (unless --oauth-no-cache)
  7. 🚀 Automatically injects Authorization: Bearer header 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:

  1. Run MCP Inspector with OAuth (it will complete the OAuth flow)
  2. In the Inspector’s console output or browser developer tools, locate the access token
  3. Copy the token value
  4. 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:

  1. Generate the scenario template
  2. Edit to replace TODO: placeholders with real test data
  3. Add custom assertions
  4. 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 jsonpath assertion 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-tester outputs JSON format (via --format json), not JUnit XML. If your CI system requires JUnit XML reports, you can convert the JSON output using tools like jq or 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:

  1. Interactive Testing - Use MCP Inspector for exploration and manual debugging
  2. Automated Testing - Use mcp-tester for comprehensive, reproducible tests
  3. Scenario Testing - Define complex workflows in YAML for regression testing
  4. 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:

  1. Set up MCP Inspector for interactive development testing
  2. Create basic smoke test scenario for your server
  3. Add mcp-tester to your CI/CD pipeline
  4. Build comprehensive regression test suite
  5. 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::TransportError for 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.discovery with:
    • 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 Notification envelope. Both client and server MUST use EncryptedTransport - 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:

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 CaseLatency ToleranceComplexityROI
Long-running opsHigh (minutes)Medium✅ High
Air-gapped securityMedium (seconds)High✅ High
Burst scalingMedium (100-500ms)Medium✅ High
Multi-regionMedium (100-500ms)High✅ Medium
Legacy integrationHigh (minutes)Medium✅ High
High-securityLow (milliseconds)High✅ Medium
Event-drivenMedium (100-500ms)Medium✅ High
Offline/edgeHigh (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)

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-tester validates 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:

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