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
- The Two Transport Modes
- Stdio: Local Process Communication
- SSE: Remote HTTP Communication
- Comparison Table
- Implementing SSE in Ruby
- Authentication for SSE
- Deploying with Docker
- Production Best Practices
- 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
- Claude Desktop reads your config file
- When your server is needed, Claude runs
ruby your_server.rb - Claude sends JSON-RPC messages to your process via stdin
- Your process responds via stdout
- 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
- Your Ruby server runs as an HTTP service
- Claude connects via HTTP/HTTPS
- Requests go via POST to your server
- Responses stream back via Server-Sent Events
- 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
- Building Your First MCP Server: Start with Stdio
- Understanding Tools vs. Resources: Know when to use each primitive
- Rails ActiveRecord Integration: Connect your database
- Testing with RSpec: Test both transports