Skip to content

Commit 361288f

Browse files
committed
Make StreamableHTTPTransport a Rack application
Add `call(env)` to `StreamableHTTPTransport`, making it a Rack application that works with `mount`, `run`, and Rack middleware. Refactor examples to use Rack middleware classes for MCP logging instead of proc wrappers, demonstrating idiomatic Rack composition with the new `run(transport)` pattern. Update README.md with mount and controller integration patterns. Closes #59, #60 ## How Has This Been Tested? Added tests for `call(env)` as a Rack app. All tests pass. ## Breaking Change No breaking changes. All existing APIs are preserved: - `StreamableHTTPTransport.new(server)` continues to work as before. - `handle_request(request)` is unchanged. The new `call(env)` is a purely additive public method.
1 parent 283c7a4 commit 361288f

File tree

6 files changed

+199
-104
lines changed

6 files changed

+199
-104
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,6 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
102102

103103
### Integration patterns
104104

105-
- **Rails controllers**: Use `server.handle_json(request.body.read)` for HTTP endpoints
105+
- **Rails/Rack apps**: Mount `StreamableHTTPTransport` as a Rack app (e.g., `mount transport => "/mcp"`)
106106
- **Command-line tools**: Use `StdioTransport.new(server).open` for CLI applications
107-
- **HTTP services**: Use `StreamableHttpTransport` for web-based servers
107+
- **HTTP services**: Use `StreamableHTTPTransport` for web-based servers

README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,14 +301,34 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, statele
301301

302302
### Usage
303303

304-
#### Rails Controller
304+
#### Rails (mount)
305305

306-
When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming
307-
[Streamable HTTP](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http) transport
308-
requests.
306+
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
309307

310-
You can use `StreamableHTTPTransport#handle_request` to handle requests with proper HTTP
311-
status codes (e.g., 202 Accepted for notifications).
308+
```ruby
309+
# config/routes.rb
310+
server = MCP::Server.new(
311+
name: "my_server",
312+
title: "Example Server Display Name",
313+
version: "1.0.0",
314+
instructions: "Use the tools of this server as a last resort",
315+
tools: [SomeTool, AnotherTool],
316+
prompts: [MyPrompt],
317+
)
318+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
319+
server.transport = transport
320+
321+
Rails.application.routes.draw do
322+
mount transport => "/mcp"
323+
end
324+
```
325+
326+
#### Rails (controller)
327+
328+
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.
329+
This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route).
330+
331+
`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications):
312332

313333
```ruby
314334
class McpController < ActionController::Base

examples/http_server.rb

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -96,52 +96,49 @@ def template(args, server_context:)
9696
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
9797
server.transport = transport
9898

99-
# Create a logger for MCP-specific logging
100-
mcp_logger = Logger.new($stdout)
101-
mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
102-
"[MCP] #{msg}\n"
103-
end
99+
# Rack middleware for MCP-specific request/response logging
100+
class McpRequestLogger
101+
def initialize(app)
102+
@app = app
103+
@logger = Logger.new($stdout)
104+
@logger.formatter = proc { |_sev, _dt, _prog, msg| "[MCP] #{msg}\n" }
105+
end
104106

105-
# Create a Rack application with logging
106-
app = proc do |env|
107-
request = Rack::Request.new(env)
108-
109-
# Log MCP-specific details for POST requests
110-
if request.post?
111-
body = request.body.read
112-
request.body.rewind
113-
begin
114-
parsed_body = JSON.parse(body)
115-
mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})")
116-
mcp_logger.debug("Request body: #{JSON.pretty_generate(parsed_body)}")
117-
rescue JSON::ParserError
118-
mcp_logger.warn("Request body (raw): #{body}")
107+
def call(env)
108+
if env["REQUEST_METHOD"] == "POST"
109+
body = env["rack.input"].read
110+
env["rack.input"].rewind
111+
begin
112+
parsed = JSON.parse(body)
113+
@logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})")
114+
@logger.debug("Request body: #{JSON.pretty_generate(parsed)}")
115+
rescue JSON::ParserError
116+
@logger.warn("Request body (raw): #{body}")
117+
end
119118
end
120-
end
121119

122-
# Handle the request
123-
response = transport.handle_request(request)
124-
125-
# Log the MCP response details
126-
_, _, body = response
127-
if body.is_a?(Array) && !body.empty? && body.first
128-
begin
129-
parsed_response = JSON.parse(body.first)
130-
if parsed_response["error"]
131-
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
132-
else
133-
mcp_logger.info("Response: #{parsed_response["result"] ? "success" : "empty"} (id: #{parsed_response["id"]})")
120+
status, headers, response_body = @app.call(env)
121+
122+
if response_body.is_a?(Array) && !response_body.empty? && response_body.first
123+
begin
124+
parsed = JSON.parse(response_body.first)
125+
if parsed["error"]
126+
@logger.error("Response error: #{parsed["error"]["message"]}")
127+
else
128+
@logger.info("Response: #{parsed["result"] ? "success" : "empty"} (id: #{parsed["id"]})")
129+
end
130+
@logger.debug("Response body: #{JSON.pretty_generate(parsed)}")
131+
rescue JSON::ParserError
132+
@logger.warn("Response body (raw): #{response_body}")
134133
end
135-
mcp_logger.debug("Response body: #{JSON.pretty_generate(parsed_response)}")
136-
rescue JSON::ParserError
137-
mcp_logger.warn("Response body (raw): #{body}")
138134
end
139-
end
140135

141-
response
136+
[status, headers, response_body]
137+
end
142138
end
143139

144-
# Wrap the app with Rack middleware
140+
# Build the Rack application with middleware
141+
# StreamableHTTPTransport responds to `call(env)`, so it can be used directly as a Rack app
145142
rack_app = Rack::Builder.new do
146143
# Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector)
147144
# WARNING: origins("*") allows all origins. Restrict this in production.
@@ -159,11 +156,9 @@ def template(args, server_context:)
159156

160157
# Use CommonLogger for standard HTTP request logging
161158
use(Rack::CommonLogger, Logger.new($stdout))
162-
163-
# Add other useful middleware
164159
use(Rack::ShowExceptions)
165-
166-
run(app)
160+
use(McpRequestLogger)
161+
run(transport)
167162
end
168163

169164
# Start the server

examples/streamable_http_server.rb

Lines changed: 42 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -65,70 +65,57 @@ def call(message:, delay: 0)
6565
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
6666
server.transport = transport
6767

68-
# Create a logger for MCP request/response logging
69-
mcp_logger = Logger.new($stdout)
70-
mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
71-
"[MCP] #{msg}\n"
72-
end
68+
# Rack middleware for MCP request/response and SSE logging
69+
class McpSseLogger
70+
def initialize(app)
71+
@app = app
72+
@mcp_logger = Logger.new($stdout)
73+
@mcp_logger.formatter = proc { |_sev, _dt, _prog, msg| "[MCP] #{msg}\n" }
74+
@sse_logger = Logger.new($stdout)
75+
@sse_logger.formatter = proc { |sev, dt, _prog, msg| "[SSE] #{sev} #{dt.strftime("%H:%M:%S.%L")} - #{msg}\n" }
76+
end
7377

74-
# Create the Rack application
75-
app = proc do |env|
76-
request = Rack::Request.new(env)
77-
78-
# Log request details
79-
if request.post?
80-
body = request.body.read
81-
request.body.rewind
82-
begin
83-
parsed_body = JSON.parse(body)
84-
mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})")
85-
86-
# Log SSE-specific setup
87-
if parsed_body["method"] == "initialize"
88-
sse_logger.info("New client initializing session")
78+
def call(env)
79+
if env["REQUEST_METHOD"] == "POST"
80+
body = env["rack.input"].read
81+
env["rack.input"].rewind
82+
begin
83+
parsed = JSON.parse(body)
84+
@mcp_logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})")
85+
@sse_logger.info("New client initializing session") if parsed["method"] == "initialize"
86+
rescue JSON::ParserError
87+
@mcp_logger.warn("Invalid JSON in request")
8988
end
90-
rescue JSON::ParserError
91-
mcp_logger.warn("Invalid JSON in request")
89+
elsif env["REQUEST_METHOD"] == "GET"
90+
session_id = env["HTTP_MCP_SESSION_ID"] ||
91+
Rack::Utils.parse_query(env["QUERY_STRING"])["sessionId"]
92+
@sse_logger.info("SSE connection request for session: #{session_id}")
9293
end
93-
elsif request.get?
94-
session_id = request.env["HTTP_MCP_SESSION_ID"] ||
95-
Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"]
96-
sse_logger.info("SSE connection request for session: #{session_id}")
97-
end
9894

99-
# Handle the request
100-
response = transport.handle_request(request)
101-
102-
# Log response details
103-
status, headers, body = response
104-
if body.is_a?(Array) && !body.empty? && request.post?
105-
begin
106-
parsed_response = JSON.parse(body.first)
107-
if parsed_response["error"]
108-
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
109-
elsif parsed_response["accepted"]
110-
# Response was sent via SSE
111-
server.notify_log_message(data: { details: "Response accepted and sent via SSE" }, level: "info")
112-
sse_logger.info("Response sent via SSE stream")
113-
else
114-
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
115-
116-
# Log session ID for initialization
117-
if headers["Mcp-Session-Id"]
118-
sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}")
95+
status, headers, response_body = @app.call(env)
96+
97+
if response_body.is_a?(Array) && !response_body.empty? && env["REQUEST_METHOD"] == "POST"
98+
begin
99+
parsed = JSON.parse(response_body.first)
100+
if parsed["error"]
101+
@mcp_logger.error("Response error: #{parsed["error"]["message"]}")
102+
else
103+
@mcp_logger.info("Response: success (id: #{parsed["id"]})")
104+
@sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") if headers["Mcp-Session-Id"]
119105
end
106+
rescue JSON::ParserError
107+
@mcp_logger.warn("Invalid JSON in response")
120108
end
121-
rescue JSON::ParserError
122-
mcp_logger.warn("Invalid JSON in response")
109+
elsif env["REQUEST_METHOD"] == "GET" && status == 200
110+
@sse_logger.info("SSE stream established")
123111
end
124-
elsif request.get? && status == 200
125-
sse_logger.info("SSE stream established")
126-
end
127112

128-
response
113+
[status, headers, response_body]
114+
end
129115
end
130116

131117
# Build the Rack application with middleware
118+
# StreamableHTTPTransport responds to `call(env)`, so it can be used directly as a Rack app
132119
rack_app = Rack::Builder.new do
133120
# Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector)
134121
# WARNING: origins("*") allows all origins. Restrict this in production.
@@ -146,7 +133,8 @@ def call(message:, delay: 0)
146133

147134
use(Rack::CommonLogger, Logger.new($stdout))
148135
use(Rack::ShowExceptions)
149-
run(app)
136+
use(McpSseLogger)
137+
run(transport)
150138
end
151139

152140
# Print usage instructions

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44
require "securerandom"
55
require_relative "../../transport"
66

7+
# This file is autoloaded only when `StreamableHTTPTransport` is referenced,
8+
# so the `rack` dependency does not affect `StdioTransport` users.
9+
begin
10+
require "rack"
11+
rescue LoadError
12+
raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
13+
"Add it to your Gemfile: gem 'rack'"
14+
end
15+
716
module MCP
817
class Server
918
module Transports
@@ -21,6 +30,10 @@ def initialize(server, stateless: false)
2130
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
2231
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
2332

33+
def call(env)
34+
handle_request(Rack::Request.new(env))
35+
end
36+
2437
def handle_request(request)
2538
case request.env["REQUEST_METHOD"]
2639
when "POST"

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,85 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase
12471247
assert_equal(200, response[0])
12481248
end
12491249

1250+
test "call(env) works as a Rack app for POST requests" do
1251+
env = {
1252+
"REQUEST_METHOD" => "POST",
1253+
"PATH_INFO" => "/",
1254+
"rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init-1" }.to_json),
1255+
"CONTENT_TYPE" => "application/json",
1256+
"HTTP_ACCEPT" => "application/json, text/event-stream",
1257+
}
1258+
1259+
response = @transport.call(env)
1260+
assert_equal 200, response[0]
1261+
assert_equal "application/json", response[1]["Content-Type"]
1262+
1263+
body = JSON.parse(response[2][0])
1264+
assert_equal "2.0", body["jsonrpc"]
1265+
assert_equal "init-1", body["id"]
1266+
end
1267+
1268+
test "call(env) returns 405 for unsupported HTTP methods" do
1269+
env = {
1270+
"REQUEST_METHOD" => "PUT",
1271+
"PATH_INFO" => "/",
1272+
"rack.input" => StringIO.new(""),
1273+
}
1274+
1275+
response = @transport.call(env)
1276+
assert_equal 405, response[0]
1277+
end
1278+
1279+
test "call(env) handles GET SSE stream request" do
1280+
init_env = {
1281+
"REQUEST_METHOD" => "POST",
1282+
"PATH_INFO" => "/",
1283+
"rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json),
1284+
"CONTENT_TYPE" => "application/json",
1285+
"HTTP_ACCEPT" => "application/json, text/event-stream",
1286+
}
1287+
init_response = @transport.call(init_env)
1288+
session_id = init_response[1]["Mcp-Session-Id"]
1289+
1290+
get_env = {
1291+
"REQUEST_METHOD" => "GET",
1292+
"PATH_INFO" => "/",
1293+
"rack.input" => StringIO.new(""),
1294+
"HTTP_ACCEPT" => "text/event-stream",
1295+
"HTTP_MCP_SESSION_ID" => session_id,
1296+
}
1297+
1298+
response = @transport.call(get_env)
1299+
assert_equal 200, response[0]
1300+
assert_equal "text/event-stream", response[1]["Content-Type"]
1301+
assert response[2].is_a?(Proc)
1302+
end
1303+
1304+
test "call(env) handles DELETE session request" do
1305+
init_env = {
1306+
"REQUEST_METHOD" => "POST",
1307+
"PATH_INFO" => "/",
1308+
"rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json),
1309+
"CONTENT_TYPE" => "application/json",
1310+
"HTTP_ACCEPT" => "application/json, text/event-stream",
1311+
}
1312+
init_response = @transport.call(init_env)
1313+
session_id = init_response[1]["Mcp-Session-Id"]
1314+
1315+
delete_env = {
1316+
"REQUEST_METHOD" => "DELETE",
1317+
"PATH_INFO" => "/",
1318+
"rack.input" => StringIO.new(""),
1319+
"HTTP_MCP_SESSION_ID" => session_id,
1320+
}
1321+
1322+
response = @transport.call(delete_env)
1323+
assert_equal 200, response[0]
1324+
1325+
body = JSON.parse(response[2][0])
1326+
assert body["success"]
1327+
end
1328+
12501329
private
12511330

12521331
def create_rack_request(method, path, headers, body = nil)

0 commit comments

Comments
 (0)