πŸ“šguideadvanced

Stdio vs. SSE: Choosing the Right Transport for Ruby MCP Servers

Learn when to use Stdio for local development vs SSE for production. Deploy scalable Ruby MCP servers with authentication and Docker.

ByMCP Directory Team
Published
⏱️40 minutes
rubytransportstdiosseproductiondocker

Stdio vs. SSE: Choosing the Right Transport for Ruby MCP Servers

The MCP protocol supports multiple transport mechanisms. Understanding when to use Stdio versus SSE (Server-Sent Events) is crucial for building production-ready AI integrations.

Table of Contents

  1. The Two Transport Modes
  2. Stdio: Local Process Communication
  3. SSE: Remote HTTP Communication
  4. Comparison Table
  5. Implementing SSE in Ruby
  6. Authentication for SSE
  7. Deploying with Docker
  8. Production Best Practices
  9. Choosing the Right Transport

The Two Transport Modes

MCP defines how clients (like Claude) communicate with servers (your tools). The transport layer determines how messages flow between them.

Stdio (Standard I/O)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     stdin/stdout     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Claude    β”‚ ←──────────────────→ β”‚  Ruby MCP   β”‚
β”‚   Desktop   β”‚                      β”‚   Server    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     Host                            Child Process

Claude Desktop spawns your Ruby script as a child process and communicates via stdin/stdout.

SSE (Server-Sent Events)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      HTTPS/SSE       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Claude    β”‚ ←──────────────────→ β”‚  Ruby MCP   β”‚
β”‚   Client    β”‚                      β”‚   Server    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   Any Client                        Remote Server

Your Ruby server runs as an HTTP server. Clients connect over the network using HTTP for requests and SSE for streaming responses.

Stdio: Local Process Communication

How It Works

  1. Claude Desktop reads your config file
  2. When your server is needed, Claude runs ruby your_server.rb
  3. Claude sends JSON-RPC messages to your process via stdin
  4. Your process responds via stdout
  5. When done, Claude terminates the process

Configuration

{
  "mcpServers": {
    "my-ruby-server": {
      "command": "ruby",
      "args": ["/path/to/server.rb"]
    }
  }
}

Implementation

require 'mcp'

server = MCP::Server.new(name: "stdio-server")

server.register_tool(
  name: "hello",
  description: "Says hello",
  input_schema: { type: "object", properties: {} }
) do |input|
  { message: "Hello from Stdio!" }
end

# Uses Stdio transport by default
server.run

Advantages

| Benefit | Description | |---------|-------------| | Secure by default | No network exposure; only local process | | No authentication needed | Same user context as Claude | | Simple setup | Just point to your script | | Access local resources | Files, databases, environment variables | | Process isolation | Each conversation gets fresh state |

Disadvantages

| Limitation | Description | |------------|-------------| | Local only | Can't be shared across machines | | No persistence | Process dies when conversation ends | | One client | Only Claude Desktop on that machine | | Environment dependent | Requires Ruby installed locally |

Best For

  • Personal development tools
  • Local file system access
  • Desktop automation
  • Quick prototyping
  • Sensitive local data

SSE: Remote HTTP Communication

How It Works

  1. Your Ruby server runs as an HTTP service
  2. Claude connects via HTTP/HTTPS
  3. Requests go via POST to your server
  4. Responses stream back via Server-Sent Events
  5. Connection persists for the session

The Protocol

Client β†’ Server: HTTP POST /messages
                 Content-Type: application/json
                 {"method": "tools/call", "params": {...}}

Server β†’ Client: HTTP 200
                 Content-Type: text/event-stream
                 data: {"result": {...}}

Implementation

require 'mcp'
require 'rack'

server = MCP::Server.new(name: "sse-server")

server.register_tool(
  name: "hello",
  description: "Says hello",
  input_schema: { type: "object", properties: {} }
) do |input|
  { message: "Hello from SSE!" }
end

# Run with SSE transport
server.run(transport: :sse, port: 3000)

Advantages

| Benefit | Description | |---------|-------------| | Remote access | Connect from anywhere | | Multiple clients | Shared server for team | | Persistent state | Server maintains state between requests | | Scalable | Standard web infrastructure | | Containerizable | Deploy with Docker, Kubernetes |

Disadvantages

| Limitation | Description | |------------|-------------| | Requires auth | Must implement security | | Network overhead | Latency from HTTP | | More complex | Need web server, SSL, etc. | | Exposure risk | Accessible over network |

Best For

  • Team shared tools
  • Production deployments
  • Multi-user access
  • Stateful applications
  • Integration with web services

Comparison Table

| Aspect | Stdio | SSE | |--------|-------|-----| | Deployment | Local machine | Any server | | Security | Inherent (local) | Must implement | | Clients | Single (Claude Desktop) | Multiple | | State | Per-conversation | Persistent | | Setup | Minimal | Requires web stack | | Latency | Very low | Network dependent | | Scalability | Not applicable | Horizontal scaling | | SSL/TLS | Not needed | Required for production | | Auth | Not needed | Required |

Implementing SSE in Ruby

Basic Rack Implementation

# sse_server.rb
require 'mcp'
require 'rack'
require 'json'

class MCPSSEApp
  def initialize
    @server = MCP::Server.new(name: "sse-ruby-server")
    setup_tools
  end

  def setup_tools
    @server.register_tool(
      name: "get_time",
      description: "Returns current server time",
      input_schema: { type: "object", properties: {} }
    ) do |input|
      { time: Time.now.iso8601, timezone: "UTC" }
    end
  end

  def call(env)
    request = Rack::Request.new(env)

    case [request.request_method, request.path_info]
    when ["GET", "/health"]
      health_check
    when ["POST", "/mcp/messages"]
      handle_mcp_message(request)
    when ["GET", "/mcp/sse"]
      sse_connection(env)
    else
      [404, { "Content-Type" => "application/json" }, ['{"error": "Not found"}']]
    end
  end

  private

  def health_check
    [200, { "Content-Type" => "application/json" }, ['{"status": "ok"}']]
  end

  def handle_mcp_message(request)
    body = JSON.parse(request.body.read)
    result = @server.handle_message(body)

    [
      200,
      { "Content-Type" => "application/json" },
      [result.to_json]
    ]
  end

  def sse_connection(env)
    # SSE streaming response
    [
      200,
      {
        "Content-Type" => "text/event-stream",
        "Cache-Control" => "no-cache",
        "Connection" => "keep-alive"
      },
      SSEStream.new(@server)
    ]
  end
end

# SSE Stream wrapper
class SSEStream
  def initialize(server)
    @server = server
  end

  def each
    # Keep connection alive
    loop do
      yield "data: #{heartbeat.to_json}\n\n"
      sleep 15
    end
  rescue IOError
    # Client disconnected
  end

  private

  def heartbeat
    { type: "heartbeat", timestamp: Time.now.iso8601 }
  end
end

# Run the server
run MCPSSEApp.new

Running with Puma

# config.ru
require_relative 'sse_server'

run MCPSSEApp.new
# Start the server
bundle exec puma -p 3000 -t 5:5

Rails Integration

# config/routes.rb
Rails.application.routes.draw do
  post '/mcp/messages', to: 'mcp#messages'
  get '/mcp/sse', to: 'mcp#sse'
end

# app/controllers/mcp_controller.rb
class McpController < ApplicationController
  skip_before_action :verify_authenticity_token

  def messages
    result = mcp_server.handle_message(params.permit!.to_h)
    render json: result
  end

  def sse
    response.headers['Content-Type'] = 'text/event-stream'
    response.headers['Cache-Control'] = 'no-cache'

    # ActionController::Live for streaming
    sse = SSE.new(response.stream)

    begin
      loop do
        sse.write({ type: 'heartbeat' }, event: 'ping')
        sleep 15
      end
    rescue IOError
      # Client disconnected
    ensure
      sse.close
    end
  end

  private

  def mcp_server
    @mcp_server ||= Rails.application.config.mcp_server
  end
end

Authentication for SSE

SSE servers are network-accessible and must implement authentication.

API Key Authentication

class MCPSSEApp
  def call(env)
    request = Rack::Request.new(env)

    # Skip auth for health checks
    unless request.path_info == "/health"
      return unauthorized unless valid_api_key?(request)
    end

    # ... rest of routing
  end

  private

  def valid_api_key?(request)
    api_key = request.get_header("HTTP_AUTHORIZATION")&.gsub("Bearer ", "")
    api_key == ENV["MCP_API_KEY"]
  end

  def unauthorized
    [
      401,
      { "Content-Type" => "application/json" },
      ['{"error": "Unauthorized"}']
    ]
  end
end

Client Configuration with Auth

{
  "mcpServers": {
    "remote-ruby-server": {
      "url": "https://mcp.example.com",
      "headers": {
        "Authorization": "Bearer your-api-key-here"
      }
    }
  }
}

JWT Authentication

require 'jwt'

class MCPSSEApp
  def valid_token?(request)
    token = request.get_header("HTTP_AUTHORIZATION")&.gsub("Bearer ", "")
    return false unless token

    begin
      decoded = JWT.decode(token, ENV["JWT_SECRET"], true, algorithm: 'HS256')
      @current_user = decoded[0]["user_id"]
      true
    rescue JWT::DecodeError
      false
    end
  end
end

OAuth2 Integration

class MCPSSEApp
  def valid_oauth_token?(request)
    token = request.get_header("HTTP_AUTHORIZATION")&.gsub("Bearer ", "")

    # Validate with OAuth provider
    response = HTTP.get(
      "https://oauth.provider.com/tokeninfo",
      params: { access_token: token }
    )

    response.status.success?
  end
end

Deploying with Docker

Dockerfile

# Dockerfile
FROM ruby:3.2-slim

WORKDIR /app

# Install dependencies
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test

# Copy application
COPY . .

# Expose SSE port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:3000/health || exit 1

# Start server
CMD ["bundle", "exec", "puma", "-p", "3000"]

docker-compose.yml

version: '3.8'

services:
  mcp-server:
    build: .
    ports:
      - "3000:3000"
    environment:
      - MCP_API_KEY=${MCP_API_KEY}
      - DATABASE_URL=${DATABASE_URL}
      - RAILS_ENV=production
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3

  nginx:
    image: nginx:alpine
    ports:
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - mcp-server

Nginx Reverse Proxy with SSL

# nginx.conf
events {
    worker_connections 1024;
}

http {
    upstream mcp {
        server mcp-server:3000;
    }

    server {
        listen 443 ssl;
        server_name mcp.example.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;

        # SSE specific settings
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_buffering off;
        proxy_cache off;

        location / {
            proxy_pass http://mcp;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_read_timeout 86400;  # 24 hours for SSE
        }
    }
}

Kubernetes Deployment

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server
    spec:
      containers:
        - name: mcp-server
          image: your-registry/mcp-server:latest
          ports:
            - containerPort: 3000
          env:
            - name: MCP_API_KEY
              valueFrom:
                secretKeyRef:
                  name: mcp-secrets
                  key: api-key
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: mcp-server
spec:
  selector:
    app: mcp-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

Production Best Practices

Logging

require 'logger'

class MCPSSEApp
  def initialize
    @logger = Logger.new(STDOUT)
    @logger.level = Logger::INFO
    # ...
  end

  def handle_mcp_message(request)
    start_time = Time.now

    body = JSON.parse(request.body.read)
    @logger.info "MCP Request: #{body['method']}"

    result = @server.handle_message(body)

    duration = Time.now - start_time
    @logger.info "MCP Response: #{result['result'] ? 'success' : 'error'} (#{duration.round(3)}s)"

    [200, { "Content-Type" => "application/json" }, [result.to_json]]
  rescue => e
    @logger.error "MCP Error: #{e.message}"
    [500, { "Content-Type" => "application/json" }, [{ error: e.message }.to_json]]
  end
end

Rate Limiting

require 'rack/attack'

class MCPSSEApp
  use Rack::Attack

  Rack::Attack.throttle("mcp/ip", limit: 100, period: 60) do |req|
    req.ip if req.path.start_with?("/mcp")
  end

  Rack::Attack.throttle("mcp/api_key", limit: 1000, period: 60) do |req|
    req.get_header("HTTP_AUTHORIZATION") if req.path.start_with?("/mcp")
  end
end

Monitoring

require 'prometheus/client'

prometheus = Prometheus::Client.registry
mcp_requests = prometheus.counter(
  :mcp_requests_total,
  docstring: 'Total MCP requests',
  labels: [:method, :status]
)

mcp_duration = prometheus.histogram(
  :mcp_request_duration_seconds,
  docstring: 'MCP request duration',
  labels: [:method]
)

class MCPSSEApp
  def handle_mcp_message(request)
    method = body['method']
    start_time = Time.now

    result = @server.handle_message(body)

    mcp_requests.increment(labels: { method: method, status: 'success' })
    mcp_duration.observe(Time.now - start_time, labels: { method: method })

    # ...
  end
end

Graceful Shutdown

# Trap signals for graceful shutdown
shutdown = false

%w[INT TERM].each do |signal|
  Signal.trap(signal) do
    puts "Shutting down gracefully..."
    shutdown = true
  end
end

# In your SSE stream
class SSEStream
  def each
    loop do
      break if $shutdown
      yield "data: #{heartbeat.to_json}\n\n"
      sleep 15
    end
  end
end

Choosing the Right Transport

Decision Matrix

| Scenario | Recommendation | |----------|----------------| | Personal tools, local files | Stdio | | Quick prototyping | Stdio | | Team-shared tools | SSE | | Production deployment | SSE | | Multi-user application | SSE | | Sensitive local data | Stdio | | CI/CD integration | SSE | | Desktop automation | Stdio | | Mobile/web clients | SSE |

Migration Path

Start with Stdio for development, then migrate to SSE for production:

# server.rb
require 'mcp'

server = MCP::Server.new(name: "my-server")

# ... register tools ...

# Choose transport based on environment
if ENV['MCP_TRANSPORT'] == 'sse'
  server.run(transport: :sse, port: ENV.fetch('PORT', 3000))
else
  server.run  # Defaults to Stdio
end

Hybrid Approach

Some teams run both:

  • Stdio for local development and personal tools
  • SSE for shared production services
{
  "mcpServers": {
    "local-tools": {
      "command": "ruby",
      "args": ["/path/to/local_server.rb"]
    },
    "team-tools": {
      "url": "https://mcp.company.com",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}

Summary

| Transport | Use When | |-----------|----------| | Stdio | Local development, personal tools, sensitive data, simple setups | | SSE | Production, teams, remote access, scalability, stateful services |

Both transports use the same MCP protocolβ€”your tool logic stays the same. Only the communication layer changes.

Start with Stdio to get up and running quickly, then graduate to SSE when you need production-grade deployment.

Next Steps