docs/MCP_HTTP_TRANSPORT.md
~> 0.6 in gemspec)MCP::Server::Transports::StdioTransport — stdin/stdout JSON-RPC (current default)MCP::Server::Transports::StreamableHTTPTransport — HTTP POST/GET with optional SSE streamingNative support: YES. The mcp gem v0.6.0 ships StreamableHTTPTransport with full Rack compatibility.
The transport (lib/mcp/server/transports/streamable_http_transport.rb) provides:
transport.handle_request(rack_request) returns [status, headers, body]Mcp-Session-Id header on initialization, tracks sessions server-sideStreamableHTTPTransport.new(server, stateless: true) for multi-node deploymentstransport.send_notification(method, params, session_id:) pushes events to connected SSE streamsThe gem includes example servers (examples/http_server.rb, examples/streamable_http_server.rb) demonstrating the Rack integration:
# Build the MCP server (same as stdio)
server = Woods::MCP::Server.build(index_dir: index_dir)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
server.transport = transport
# Wrap in a Rack app
app = proc do |env|
request = Rack::Request.new(env)
transport.handle_request(request)
end
# Run with any Rack-compatible server (Puma, Falcon, WEBrick)
Rackup::Handler.get("puma").run(app, Port: 9292, Host: "localhost")
For embedding in a Rails app, the Server#handle_json method enables a minimal controller:
class McpController < ApplicationController
skip_before_action :verify_authenticity_token
def handle
response = @mcp_server.handle_json(request.body.read)
render json: response
end
end
This provides non-streaming Streamable HTTP transport (POST-only, no SSE).
Add exe/woods-mcp-http alongside the existing stdio executable:
#!/usr/bin/env ruby
# frozen_string_literal: true
require "rackup"
require_relative "../lib/woods"
require_relative "../lib/woods/mcp/server"
# ... other requires ...
index_dir = ARGV[0] || ENV["WOODS_DIR"] || Dir.pwd
port = (ENV["PORT"] || 9292).to_i
server = Woods::MCP::Server.build(index_dir: index_dir, retriever: retriever)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
server.transport = transport
app = proc { |env| transport.handle_request(Rack::Request.new(env)) }
Rackup::Handler.get("puma").run(app, Port: port, Host: "localhost")
Complexity: Low — ~30 lines, mirrors the existing exe structure.
Dependencies: Requires rackup gem + a Rack server (e.g., puma). The gemspec already has puma as a dev dependency. For production use, rackup would need to be added as an optional dependency or documented as a user-provided requirement.
A Rack middleware that mounts the MCP server at a configurable path:
# lib/woods/mcp/rack_middleware.rb
module Woods
module MCP
class RackMiddleware
def initialize(app, index_dir:, path: "/mcp")
@app = app
@path = path
server = Server.build(index_dir: index_dir)
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server)
server.transport = @transport
end
def call(env)
if env["PATH_INFO"].start_with?(@path)
@transport.handle_request(Rack::Request.new(env))
else
@app.call(env)
end
end
end
end
end
Complexity: Low-medium — adds a mountable middleware class. Useful for host apps that want to expose MCP alongside their existing routes.
A single woods-mcp executable that supports both transports:
woods-mcp # stdio (default, backward compatible)
woods-mcp --http # HTTP on port 9292
woods-mcp --http --port 8080 # HTTP on custom port
Complexity: Low — add CLI flag parsing to existing exe.
Start with Option A (standalone HTTP executable). Rationale:
mcp gem already provides the full transport; we just need a thin wrapperrackup/puma are already dev dependencies; users wanting HTTP would install themexe/woods-mcp-http), 1 modified (woods.gemspec to register the new executable)spec/mcp/server_spec.rb covers all tool behavior; transport-level testing would be integration-only (Rack test with mock requests)mcp gem tracks the spec closely; v0.6.0 already implements the full Streamable HTTP spec including stateless modeThe MCP gem does not authenticate at the transport layer, so exe/woods-mcp-http enforces authentication itself. The rules:
HOST | WOODS_MCP_HTTP_TOKEN set? | Result |
|---|---|---|
localhost / 127.0.0.1 / ::1 | no | Boots with a warning; unauthenticated loopback access only |
localhost / 127.0.0.1 / ::1 | yes | Boots; every request must present Authorization: Bearer … |
| anything else | no | Refuses to boot — aborts with a pointer to this section |
| anything else | yes | Boots; every request must present Authorization: Bearer … |
This matches the posture used by other unauthenticated local servers (Redis protected-mode, Postgres listen_addresses): loopback works freely, non-loopback requires an explicit credential.
bundle exec rake woods:generate_token
# prints a 64-char hex token to stdout
Any cryptographically random string works; openssl rand -hex 32 is equivalent.
export WOODS_MCP_HTTP_TOKEN=$(bundle exec rake woods:generate_token 2>/dev/null)
HOST=0.0.0.0 PORT=9292 bundle exec woods-mcp-http
Clients must send Authorization: Bearer $WOODS_MCP_HTTP_TOKEN on every request. Missing or mismatched tokens get HTTP 401 with a WWW-Authenticate: Bearer header; comparison is constant-time (Rack::Utils.secure_compare).
A second middleware, Woods::MCP::OriginGuard, rejects requests whose Origin header is outside an allow-list. Requests without an Origin header (curl, MCP stdio clients, server-to-server) pass through — bearer auth still gates them.
| Scenario | WOODS_MCP_HTTP_ALLOWED_ORIGINS | Origins accepted |
|---|---|---|
| default | unset | http(s)://localhost, 127.0.0.1, ::1 (any port) |
| explicit list | https://app.example.com | exactly https://app.example.com — loopback no longer allowed |
| multiple origins | https://a.example,https://b.example | each listed origin |
OPTIONS preflights are answered with the matching Access-Control-Allow-* headers; successful responses carry Access-Control-Allow-Origin, Access-Control-Expose-Headers: Mcp-Session-Id, and Vary: Origin.
The server speaks plain HTTP. Any deployment beyond a single trusted host should front it with a reverse proxy that handles TLS, HTTP/2, and connection limits.
Caddy (automatic HTTPS via Let's Encrypt):
mcp.example.com {
reverse_proxy 127.0.0.1:9292
}
nginx (bring-your-own cert):
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9292;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
# SSE streaming: disable buffering, raise timeouts
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Bind woods-mcp-http to HOST=127.0.0.1 when a proxy handles the public surface; keep WOODS_MCP_HTTP_TOKEN set so the proxy-to-app hop still requires a bearer.