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.