Skip to content

Commit af99a30

Browse files
committed
Make StreamableHTTPTransport a Rack application
## Motivation and Context 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 modelcontextprotocol#59, modelcontextprotocol#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 89281ca commit af99a30

File tree

6 files changed

+234
-106
lines changed

6 files changed

+234
-106
lines changed

AGENTS.md

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

117117
### Integration patterns
118118

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

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,33 @@ $ ruby examples/stdio_server.rb
103103
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}
104104
```
105105

106-
#### Rails Controller
106+
#### Rails (mount)
107107

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

112-
You can use `StreamableHTTPTransport#handle_request` to handle requests with proper HTTP
113-
status codes (e.g., 202 Accepted for notifications).
110+
```ruby
111+
# config/routes.rb
112+
server = MCP::Server.new(
113+
name: "my_server",
114+
title: "Example Server Display Name",
115+
version: "1.0.0",
116+
instructions: "Use the tools of this server as a last resort",
117+
tools: [SomeTool, AnotherTool],
118+
prompts: [MyPrompt],
119+
)
120+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
121+
122+
Rails.application.routes.draw do
123+
mount transport => "/mcp"
124+
end
125+
```
126+
127+
#### Rails (controller)
128+
129+
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.
130+
This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route).
131+
132+
`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications):
114133

115134
```ruby
116135
class McpController < ActionController::API

examples/http_server.rb

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -95,52 +95,52 @@ def template(args, server_context:)
9595
# Create the Streamable HTTP transport
9696
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
9797

98-
# Create a logger for MCP-specific logging
99-
mcp_logger = Logger.new($stdout)
100-
mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
101-
"[MCP] #{msg}\n"
102-
end
98+
# Rack middleware for MCP-specific request/response logging.
99+
class McpRequestLogger
100+
def initialize(app)
101+
@app = app
102+
@logger = Logger.new($stdout)
103+
@logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" }
104+
end
105+
106+
def call(env)
107+
if env["REQUEST_METHOD"] == "POST"
108+
body = env["rack.input"].read
109+
env["rack.input"].rewind
103110

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

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

140-
response
138+
[status, headers, response_body]
139+
end
141140
end
142141

143-
# Wrap the app with Rack middleware
142+
# Build the Rack application with middleware.
143+
# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app.
144144
rack_app = Rack::Builder.new do
145145
# Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector)
146146
# WARNING: origins("*") allows all origins. Restrict this in production.
@@ -158,11 +158,10 @@ def template(args, server_context:)
158158

159159
# Use CommonLogger for standard HTTP request logging
160160
use(Rack::CommonLogger, Logger.new($stdout))
161-
162-
# Add other useful middleware
163161
use(Rack::ShowExceptions)
162+
use(McpRequestLogger)
164163

165-
run(app)
164+
run(transport)
166165
end
167166

168167
# Start the server

examples/streamable_http_server.rb

Lines changed: 49 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -64,70 +64,62 @@ def call(message:, delay: 0)
6464
# Create the Streamable HTTP transport
6565
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
6666

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

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

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

127-
response
117+
[status, headers, response_body]
118+
end
128119
end
129120

130-
# Build the Rack application with middleware
121+
# Build the Rack application with middleware.
122+
# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app.
131123
rack_app = Rack::Builder.new do
132124
# Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector)
133125
# WARNING: origins("*") allows all origins. Restrict this in production.
@@ -145,7 +137,9 @@ def call(message:, delay: 0)
145137

146138
use(Rack::CommonLogger, Logger.new($stdout))
147139
use(Rack::ShowExceptions)
148-
run(app)
140+
use(McpSseLogger)
141+
142+
run(transport)
149143
end
150144

151145
# Print usage instructions

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
require "json"
44
require_relative "../../transport"
55

6+
# This file is autoloaded only when `StreamableHTTPTransport` is referenced,
7+
# so the `rack` dependency does not affect `StdioTransport` users.
8+
begin
9+
require "rack"
10+
rescue LoadError
11+
raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
12+
"Add it to your Gemfile: gem 'rack'"
13+
end
14+
615
module MCP
716
class Server
817
module Transports
@@ -39,6 +48,11 @@ def initialize(server, stateless: false, session_idle_timeout: nil)
3948
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
4049
SESSION_REAP_INTERVAL = 60
4150

51+
# Rack app interface. This transport can be mounted as a Rack app.
52+
def call(env)
53+
handle_request(Rack::Request.new(env))
54+
end
55+
4256
def handle_request(request)
4357
case request.env["REQUEST_METHOD"]
4458
when "POST"
@@ -531,7 +545,7 @@ def handle_request_with_sse_response(body_string, session_id, server_session, re
531545
end
532546
end
533547

534-
[200, SSE_HEADERS, body]
548+
[200, SSE_HEADERS.dup, body]
535549
end
536550

537551
# Returns the SSE stream available for server-to-client messages.
@@ -613,7 +627,7 @@ def session_already_connected_response
613627
def setup_sse_stream(session_id)
614628
body = create_sse_body(session_id)
615629

616-
[200, SSE_HEADERS, body]
630+
[200, SSE_HEADERS.dup, body]
617631
end
618632

619633
def create_sse_body(session_id)

0 commit comments

Comments
 (0)