MCP Transports¶
Communication methods for MCP client-server connections.
Overview¶
Transports handle the low-level communication between MCP::Client and external MCP servers. All transports implement the same interface defined by Transports::Base, using JSON-RPC 2.0 for message exchange and the MCP protocol version 2024-11-05 for initialization.
RobotLab provides four built-in transport types:
| Transport | Class | Use Case | Status |
|---|---|---|---|
| Stdio | Transports::Stdio |
Local subprocess servers | Fully working |
| WebSocket | Transports::WebSocket |
Real-time bidirectional | Broken — see below |
| SSE | Transports::SSE |
Server-sent events | Working, but connect reports success unconditionally |
| Streamable HTTP | Transports::StreamableHTTP |
HTTP with session support | Working, but connect reports success unconditionally |
Read this before using a non-stdio transport. Only
Stdioperforms its connection and MCP handshake synchronously. The other three wrap that work in anAsync do ... endblock whose result is never awaited, so any error raised inside — including a refused TCP connection — is discarded.connectreturnsselfregardless. The specific consequences are documented per transport below.
Base Interface¶
All transports inherit from RobotLab::MCP::Transports::Base and implement:
class RobotLab::MCP::Transports::Base
DEFAULT_TIMEOUT = 15 # seconds
attr_reader :config # => Hash (symbolized keys, :timeout removed)
attr_reader :timeout # => Numeric (seconds, extracted from config)
def connect # Establish connection, returns self
def send_request(message) # Send JSON-RPC message, returns Hash response
def close # Close connection, returns self
def connected? # Returns Boolean
end
The timeout is extracted from the config hash during initialization (and removed from config). If not provided, it defaults to DEFAULT_TIMEOUT (15 seconds). The timeout is propagated from MCP::Server through MCP::Client to the transport.
Only Stdio enforces the timeout. SSE, WebSocket, and StreamableHTTP
store @timeout and expose it through the timeout reader, but never reference
it — their requests are not time-bounded by this value. If you need a bound on a
non-stdio transport, wrap the call yourself, or route the client through an
MCP::ConnectionPoller (stdio only).
Stdio Transport¶
Class: RobotLab::MCP::Transports::Stdio
Spawns a subprocess and communicates via stdin/stdout using JSON-RPC messages (one per line). Automatically sends MCP initialize and notifications/initialized on connect. All blocking I/O is wrapped with Timeout.timeout so a missing or hung server cannot block the caller forever.
Configuration¶
{
type: "stdio",
command: "mcp-server-filesystem", # Required: executable command
args: ["--root", "/data"], # Optional: command arguments
env: { "DEBUG" => "true" }, # Optional: environment variables
timeout: 10 # Optional: request timeout in seconds (default: 15)
}
| Key | Type | Required | Description |
|---|---|---|---|
command |
String |
Yes | Executable command to spawn |
args |
Array<String> |
No | Command arguments |
env |
Hash |
No | Environment variables (merged with current env) |
timeout |
Numeric |
No | Request timeout in seconds (default: 15) |
Behavior¶
- Uses
Open3.popen3to spawn the subprocess - Verifies the process actually started (raises
MCPErrorif it exits immediately) - Writes JSON-RPC messages to stdin (one per line)
- Reads responses from stdout, skipping notifications (messages without
id) - All blocking reads are wrapped with
Timeout.timeout— raisesMCPErrorif the server does not respond within the timeout period connected?returnstruewhen the subprocess is aliveclosecallscleanup_processto reliably close stdin, stdout, stderr and kill the subprocess- Handles
Errno::ENOENT(command not found),Errno::EPIPE/IOError(broken pipe / connection lost), andTimeout::Error(hung server) with clear error messages
Example¶
transport = RobotLab::MCP::Transports::Stdio.new(
command: "mcp-server-filesystem",
args: ["--root", "/data"],
env: { "DEBUG" => "true" },
timeout: 10
)
transport.connect
response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
transport.close
WebSocket Transport¶
Class: RobotLab::MCP::Transports::WebSocket
Intended to use async-websocket for non-blocking bidirectional communication.
This transport does not currently work.
connectcallsAsync::HTTP::Endpoint.parse, but only requiresasyncandasync/websocket/client— theAsync::HTTPnamespace is never loaded, so the call raisesNameError. That happens inside an un-awaitedAsyncblock, so the error is swallowed:connectreturnsself, noMCPErroris raised, andconnected?staysfalse. Every subsequentsend_requestthen raisesMCPError, "Not connected".Verified:
WebSocket.new(url: "ws://127.0.0.1:9/ws").connect.connected?isfalsewith no exception surfacing.The rescue on
connectonly catchesLoadError, which is raised ifasync-websocketis missing. Because of theAsync::HTTP::Endpointcall, this transport also needsasync-httpeven onceasync-websocketis installed — both are declared as runtime dependencies of the gem.
Configuration¶
| Key | Type | Required | Description |
|---|---|---|---|
url |
String |
Yes | WebSocket endpoint URL |
Behavior¶
- Intends to use
Async::WebSocket::Client.connectwithin anAsyncblock, then send the MCPinitializehandshake - In practice the block raises
NameErroronAsync::HTTP::Endpointbefore the connection is created, and the un-awaited block discards it send_requestsends JSON-RPC messages as JSON strings and reads the response inside an awaitedAsyncblock — but it raisesMCPError, "Not connected"because@connectedwas never setconnectraisesMCPErroronly forLoadError(missingasync-websocket); it does not raise for a connection failurecloseis a no-op while@connectedisfalse
Example¶
transport = RobotLab::MCP::Transports::WebSocket.new(
url: "ws://localhost:8080"
)
transport.connect
transport.connected? # => false, even against a live server
# Raises MCPError: "Not connected"
transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
SSE Transport¶
Class: RobotLab::MCP::Transports::SSE
Uses async-http for HTTP-based communication. Sends requests via HTTP POST and reads responses. Requires the async-http gem.
Configuration¶
| Key | Type | Required | Description |
|---|---|---|---|
url |
String |
Yes | SSE/HTTP endpoint URL |
Behavior¶
- Creates an
Async::HTTP::Clienton connect, then sends the MCPinitializehandshake - Sends JSON-RPC messages via HTTP POST with
Content-Type: application/json - Reads and parses JSON response body
- Raises
MCPErrorif theasync-httpgem is not installed (LoadErroronly) - The
timeoutfrom the server config is stored but never applied
connectalways reports success.@connected = trueis assigned beforesend_initializeruns, and the whole sequence is inside an un-awaitedAsyncblock. Against an unreachable host,connectreturnsself,connected?returnstrue, and the handshake failure is never surfaced. The first realsend_requestis where the failure appears.Verified:
SSE.new(url: "http://127.0.0.1:9/sse").connect.connected?istrue.
Example¶
transport = RobotLab::MCP::Transports::SSE.new(
url: "http://localhost:8080/sse"
)
transport.connect
# connected? is true here whether or not the server exists
response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
transport.close
Streamable HTTP Transport¶
Class: RobotLab::MCP::Transports::StreamableHTTP
HTTP-based transport with session management and optional authentication. Supports session IDs for maintaining server-side state across requests. Requires the async-http gem.
Configuration¶
{
type: "streamable-http", # or "http"
url: "https://server.smithery.ai/neon/mcp", # Required: HTTP endpoint
session_id: "abc123", # Optional: session identifier
auth_provider: -> { "Bearer #{token}" } # Optional: auth callback
}
| Key | Type | Required | Description |
|---|---|---|---|
url |
String |
Yes | HTTP endpoint URL |
session_id |
String |
No | Pre-existing session identifier |
auth_provider |
Proc |
No | Callback returning Authorization header value |
Behavior¶
- Creates an
Async::HTTP::Clienton connect - Sends MCP
initializeon connect; if no session ID was provided, extracts it from the server response (serverInfo.sessionId) - Sends
X-Session-IDheader with each request when a session ID is available - Calls
auth_providerfor each request to populate theAuthorizationheader - Exposes
session_idreader for accessing the current session ID - Raises
MCPErrorif theasync-httpgem is not installed (LoadErroronly) - The
timeoutfrom the server config is stored but never applied
connectalways reports success, for the same reason as SSE:@connected = trueprecedessend_initialize, and the enclosingAsyncblock is never awaited.connected?returnstrueagainst an unreachable host, andsession_idstays at whatever you configured (nilif you configured nothing) because the handshake result was discarded.Verified:
StreamableHTTP.new(url: "http://127.0.0.1:9/mcp").connectyieldsconnected? == true,session_id == nil.
Example¶
transport = RobotLab::MCP::Transports::StreamableHTTP.new(
url: "https://server.smithery.ai/neon/mcp",
auth_provider: -> { "Bearer #{ENV['MCP_TOKEN']}" }
)
transport.connect
puts transport.session_id # => pre-configured value, or nil until a request lands
response = transport.send_request({ jsonrpc: "2.0", id: 1, method: "tools/list" })
transport.close
Connection Lifecycle¶
All transports expose the same four-step lifecycle:
- Create -- instantiate with configuration hash
- Connect -- establish connection and perform MCP protocol initialization
- Request/Response -- send JSON-RPC requests, receive responses
- Close -- tear down connection and release resources
Step 2 behaves differently per transport:
| Transport | connect is synchronous |
Errors surface from connect |
connected? reflects reality |
|---|---|---|---|
Stdio |
Yes | Yes (MCPError) |
Yes — also checks the process is alive |
SSE |
No (un-awaited Async) |
No | No — always true after connect |
StreamableHTTP |
No (un-awaited Async) |
No | No — always true after connect |
WebSocket |
No (un-awaited Async) |
No | No — always false after connect |
Only Stdio gives you a trustworthy answer at connect time. For the other three,
treat the first send_request as the real connection test.
Each transport builds the same MCP initialize message during connect:
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "RobotLab",
"version": "<current version>"
}
}
}
Error Handling¶
All transports raise RobotLab::MCPError for connection and communication failures:
begin
transport.connect
transport.send_request(message)
rescue RobotLab::MCPError => e
puts "Transport error: #{e.message}"
ensure
transport.close
end
Specific error cases:
- Not connected -- calling send_request before connect raises MCPError (all transports)
- Missing gem -- WebSocket, SSE, and HTTP transports raise MCPError with a LoadError message if the required gem (async-websocket / async-http) is not installed. This is the only error connect re-raises on those three
- Connection refused / unreachable host -- not reported by SSE, WebSocket, or StreamableHTTP connect; the error is discarded with the un-awaited Async block
- No response -- Stdio transport raises MCPError if the subprocess produces no output (EOF on stdout)
- Command not found -- Stdio transport raises MCPError with the original Errno::ENOENT message
- Timeout -- Stdio transport raises MCPError if the server does not respond within the configured timeout. No other transport enforces a timeout
- Broken pipe -- Stdio transport raises MCPError and marks itself disconnected on Errno::EPIPE or IOError
- Immediate exit -- Stdio transport raises MCPError if the server process exits immediately after spawn