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

Declare only what you need:

#![allow(unused)]
fn main() {
let capabilities = ClientCapabilities {
    // Only request tools if you'll use them
    tools: Some(pmcp::types::ToolCapabilities::default()),

    // Don't request sampling if your client doesn't support LLM calls
    sampling: None,

    ..Default::default()
};
}

Check server capabilities before use:

#![allow(unused)]
fn main() {
if !server_info.capabilities.provides_tools() {
    eprintln!("Server doesn't support tools");
    return Err("Missing required capability".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.