ruby-sdk/lib/mcp/server/transports/streamable_http_transport.rb
Koichi ITO dd7dacae47
Merge pull request #359 from koic/case_insensitive_accept_header
Fix case-sensitive Accept header comparison
2026-05-24 00:00:43 +09:00

898 lines
32 KiB
Ruby

# frozen_string_literal: true
require "json"
require_relative "../../transport"
# This file is autoloaded only when `StreamableHTTPTransport` is referenced,
# so the `rack` dependency does not affect `StdioTransport` users.
begin
require "rack"
rescue LoadError
raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
"Add it to your Gemfile: gem 'rack'"
end
module MCP
class Server
module Transports
class StreamableHTTPTransport < Transport
class InvalidJsonError < StandardError; end
SSE_HEADERS = {
"Content-Type" => "text/event-stream",
"Cache-Control" => "no-cache",
"Connection" => "keep-alive",
}.freeze
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
super(server)
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
@sessions = {}
@mutex = Mutex.new
@stateless = stateless
@enable_json_response = enable_json_response
@session_idle_timeout = session_idle_timeout
@pending_responses = {}
if @session_idle_timeout
if @stateless
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
elsif @session_idle_timeout <= 0
raise ArgumentError, "session_idle_timeout must be a positive number."
end
end
start_reaper_thread if @session_idle_timeout
end
REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
SESSION_REAP_INTERVAL = 60
# Rack app interface. This transport can be mounted as a Rack app.
def call(env)
handle_request(Rack::Request.new(env))
end
def handle_request(request)
case request.env["REQUEST_METHOD"]
when "POST"
handle_post(request)
when "GET"
handle_get(request)
when "DELETE"
handle_delete(request)
else
method_not_allowed_response
end
end
def close
@reaper_thread&.kill
@reaper_thread = nil
removed_sessions = @mutex.synchronize do
@sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
end
removed_sessions.each do |session|
close_stream_safely(session[:get_sse_stream])
close_post_request_streams(session)
end
end
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
# Stateless mode doesn't support notifications
raise "Stateless mode does not support notifications" if @stateless
notification = {
jsonrpc: "2.0",
method: method,
}
notification[:params] = params if params
streams_to_close = []
result = @mutex.synchronize do
if session_id
# JSON response mode returns a single JSON object as the POST response,
# so request-scoped notifications (e.g. progress, log) cannot be delivered
# alongside it. Session-scoped standalone notifications
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
next false if @enable_json_response && related_request_id
# Send to specific session
if (session = @sessions[session_id])
stream = active_stream(session, related_request_id: related_request_id)
end
next false unless stream
if session_expired?(session)
cleanup_and_collect_stream(session_id, streams_to_close)
next false
end
begin
send_to_stream(stream, notification)
true
rescue *STREAM_WRITE_ERRORS => e
MCP.configuration.exception_reporter.call(
e,
{ session_id: session_id, error: "Failed to send notification" },
)
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
session[:post_request_streams].delete(related_request_id)
streams_to_close << stream
else
cleanup_and_collect_stream(session_id, streams_to_close)
end
false
end
else
# Broadcast to all connected SSE sessions
sent_count = 0
failed_sessions = []
@sessions.each do |sid, session|
next unless (stream = session[:get_sse_stream])
if session_expired?(session)
failed_sessions << sid
next
end
begin
send_to_stream(stream, notification)
sent_count += 1
rescue *STREAM_WRITE_ERRORS => e
MCP.configuration.exception_reporter.call(
e,
{ session_id: sid, error: "Failed to send notification" },
)
failed_sessions << sid
end
end
# Clean up failed sessions
failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
sent_count
end
end
streams_to_close.each do |stream|
close_stream_safely(stream)
end
result
end
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
# blocks until the client responds.
#
# Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
# sends the request via SSE stream, then blocks on `queue.pop`.
# When the client POSTs a response, `handle_response` matches it by `request_id`
# and pushes the result onto the queue, unblocking this thread.
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
if @stateless
raise "Stateless mode does not support server-to-client requests."
end
if @enable_json_response
raise "JSON response mode does not support server-to-client requests."
end
unless session_id
raise "session_id is required for server-to-client requests."
end
request_id = generate_request_id
queue = Queue.new
cancel_hook = nil
request = { jsonrpc: "2.0", id: request_id, method: method }
request[:params] = params if params
sent = false
@mutex.synchronize do
unless (session = @sessions[session_id])
raise "Session not found: #{session_id}."
end
@pending_responses[request_id] = { queue: queue, session_id: session_id }
if (stream = active_stream(session, related_request_id: related_request_id))
begin
send_to_stream(stream, request)
sent = true
rescue *STREAM_WRITE_ERRORS
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
session[:post_request_streams].delete(related_request_id)
close_stream_safely(stream)
else
cleanup_session_unsafe(session_id)
end
end
end
end
# TODO: Replace with event store + replay when resumability is implemented.
# Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
# event store management) independent of sampling.
# See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
#
# The TypeScript and Python SDKs buffer messages and replay on reconnect.
# Until then, raise to prevent queue.pop from blocking indefinitely.
unless sent
raise "No active stream for #{method} request."
end
if parent_cancellation && server_session
cancel_hook = parent_cancellation.on_cancel do |reason|
server_session.send_peer_cancellation(
nested_request_id: request_id,
related_request_id: related_request_id,
reason: reason,
)
end
end
response = queue.pop
if response.is_a?(Hash) && response.key?(:error)
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
end
if response == :session_closed
raise "SSE session closed while waiting for #{method} response."
end
if response == :cancelled
reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
raise MCP::CancelledError.new(
"#{method} request was cancelled",
request_id: request_id,
reason: reason,
)
end
response
ensure
parent_cancellation.off_cancel(cancel_hook) if cancel_hook
if request_id
@mutex.synchronize do
@pending_responses.delete(request_id)
end
end
end
# Unblocks a `send_request` awaiting a response when the peer is being cancelled.
# The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
#
# Race note: this is first-writer-wins on the pending-response queue. If a real response
# has already been pushed (client responded before the cancel hook fired), that response
# wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
# the real response and deletes the pending entry in its `ensure` block. Conversely,
# if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
# because the pending entry has been removed.
def cancel_pending_request(request_id, reason: nil)
@mutex.synchronize do
if (pending = @pending_responses[request_id])
pending[:cancel_reason] = reason
pending[:queue].push(:cancelled)
end
end
end
private
def start_reaper_thread
@reaper_thread = Thread.new do
loop do
sleep(SESSION_REAP_INTERVAL)
reap_expired_sessions
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
end
end
end
def reap_expired_sessions
return unless @session_idle_timeout
removed_sessions = @mutex.synchronize do
@sessions.each_key.filter_map do |session_id|
next unless session_expired?(@sessions[session_id])
cleanup_session_unsafe(session_id)
end
end
removed_sessions.each do |session|
close_stream_safely(session[:get_sse_stream])
close_post_request_streams(session)
end
end
def send_to_stream(stream, data)
message = data.is_a?(String) ? data : data.to_json
stream.write("data: #{message}\n\n")
stream.flush
end
def send_ping_to_stream(stream)
stream.write(": ping #{Time.now.iso8601}\n\n")
stream.flush
end
def handle_post(request)
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
accept_error = validate_accept_header(request, required_types)
return accept_error if accept_error
content_type_error = validate_content_type(request)
return content_type_error if content_type_error
body_string = request.body.read
session_id = extract_session_id(request)
begin
body = parse_request_body(body_string)
rescue InvalidJsonError
return invalid_json_response
end
# Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
# Batched/array bodies are not supported; reject with `-32600` instead of falling through to
# a malformed Rack response.
unless body.is_a?(Hash)
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
end
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
# This matches the TypeScript and Python SDKs.
unless initialize_request?(body)
return missing_session_id_response if !@stateless && !session_id
protocol_version_error = validate_protocol_version_header(request)
return protocol_version_error if protocol_version_error
end
if initialize_request?(body)
if !@stateless && session_id
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
# initialization attempt against a live session, or a retry against an unknown/expired
# one. In the live case, reject with `-32600` so the original session is not abandoned.
# In the unknown/expired case, return 404 so the client retries from scratch instead
# of silently inheriting a fresh session under the old ID.
return already_initialized_response(body[:id]) if session_active?(session_id)
return session_not_found_response
end
handle_initialization(body_string, body)
elsif notification?(body)
dispatch_notification(body_string, session_id)
handle_accepted
elsif response?(body)
return session_not_found_response if !@stateless && !session_exists?(session_id)
handle_response(body, session_id: session_id)
else
handle_regular_request(body_string, session_id, related_request_id: body[:id])
end
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { request: body_string })
[500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
end
def handle_get(request)
if @stateless
return method_not_allowed_response
end
accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
return accept_error if accept_error
session_id = extract_session_id(request)
return missing_session_id_response unless session_id
error_response = validate_and_touch_session(session_id)
return error_response if error_response
protocol_version_error = validate_protocol_version_header(request)
return protocol_version_error if protocol_version_error
return session_already_connected_response if get_session_stream(session_id)
setup_sse_stream(session_id)
end
def handle_delete(request)
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
if @stateless
protocol_version_error = validate_protocol_version_header(request)
return protocol_version_error if protocol_version_error
# Stateless mode doesn't support sessions, so we can just return a success response
return success_response
end
return missing_session_id_response unless (session_id = extract_session_id(request))
return session_not_found_response unless session_exists?(session_id)
protocol_version_error = validate_protocol_version_header(request)
return protocol_version_error if protocol_version_error
cleanup_session(session_id)
success_response
end
def cleanup_session(session_id)
session = @mutex.synchronize do
cleanup_session_unsafe(session_id)
end
if session
close_stream_safely(session[:get_sse_stream])
close_post_request_streams(session)
end
end
# Removes a session from `@sessions` and returns it. Does not close the stream.
# Callers must close the stream outside the mutex to avoid holding the lock during
# potentially blocking I/O.
def cleanup_session_unsafe(session_id)
session = @sessions.delete(session_id)
# Unblock threads waiting on pending responses for this session.
@pending_responses.each_value do |pending_response|
if pending_response[:session_id] == session_id
pending_response[:queue].push(:session_closed)
end
end
session
end
def cleanup_and_collect_stream(session_id, streams_to_close)
return unless (removed = cleanup_session_unsafe(session_id))
streams_to_close << removed[:get_sse_stream]
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
end
def close_stream_safely(stream)
stream&.close
rescue StandardError
# Ignore close-related errors from already closed/broken streams.
end
def close_post_request_streams(session)
return unless (post_request_streams = session[:post_request_streams])
post_request_streams.each_value do |stream|
close_stream_safely(stream)
end
end
def extract_session_id(request)
request.env["HTTP_MCP_SESSION_ID"]
end
def validate_accept_header(request, required_types)
accept_header = request.env["HTTP_ACCEPT"]
return not_acceptable_response(required_types) unless accept_header
accepted_types = parse_accept_header(accept_header)
return if accepted_types.include?("*/*")
missing_types = required_types - accepted_types
return not_acceptable_response(required_types) unless missing_types.empty?
nil
end
def parse_accept_header(header)
header.split(",").map do |part|
part.split(";").first.strip.downcase
end
end
def validate_content_type(request)
content_type = request.env["CONTENT_TYPE"]
media_type = content_type&.split(";")&.first&.strip&.downcase
return if media_type == "application/json"
[
415,
{ "Content-Type" => "application/json" },
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
]
end
def not_acceptable_response(required_types)
[
406,
{ "Content-Type" => "application/json" },
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
]
end
def parse_request_body(body_string)
JSON.parse(body_string, symbolize_names: true)
rescue JSON::ParserError, TypeError
raise InvalidJsonError
end
def invalid_json_response
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
end
def initialize_request?(body)
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
end
def validate_protocol_version_header(request)
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
return if header_value.nil?
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
body = {
jsonrpc: "2.0",
id: nil,
error: {
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
},
}
[400, { "Content-Type" => "application/json" }, [body.to_json]]
end
def notification?(body)
!body[:id] && !!body[:method]
end
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
# `notifications/initialized`) through the server so it can update session state.
def dispatch_notification(body_string, session_id)
server_session = nil
if session_id && !@stateless
@mutex.synchronize do
session = @sessions[session_id]
server_session = session[:server_session] if session
end
end
dispatch_handle_json(body_string, server_session)
rescue => e
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
end
def response?(body)
!!body[:id] && !body[:method]
end
# Verifies that the response came from the expected session to prevent
# cross-session response injection if request IDs are ever leaked.
def handle_response(body, session_id:)
request_id = body[:id]
@mutex.synchronize do
if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
if body.key?(:error)
error = body[:error]
pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
else
pending_response[:queue].push(body[:result])
end
end
end
handle_accepted
end
def handle_initialization(body_string, body)
session_id = nil
server_session = nil
unless @stateless
session_id = SecureRandom.uuid
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
@mutex.synchronize do
@sessions[session_id] = {
get_sse_stream: nil,
server_session: server_session,
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
}
end
end
response = if server_session
server_session.handle_json(body_string)
else
@server.handle_json(body_string)
end
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
# `mark_initialized!` was never called. Discard the orphaned session and omit
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
if server_session && !server_session.initialized?
cleanup_session(session_id)
session_id = nil
end
headers = {
"Content-Type" => "application/json",
}
headers["Mcp-Session-Id"] = session_id if session_id
[200, headers, [response]]
end
def handle_accepted
[202, {}, []]
end
def handle_regular_request(body_string, session_id, related_request_id: nil)
server_session = nil
unless @stateless
if session_id
error_response = validate_and_touch_session(session_id)
return error_response if error_response
@mutex.synchronize do
session = @sessions[session_id]
server_session = session[:server_session] if session
end
end
end
if session_id && !@stateless && !@enable_json_response
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
else
response = dispatch_handle_json(body_string, server_session)
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
# which would produce an empty body the client cannot parse as JSON.
return handle_accepted if response.nil?
[200, { "Content-Type" => "application/json" }, [response]]
end
end
# Returns the POST response as an SSE stream so the server can send
# JSON-RPC requests and notifications during request processing.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
body = proc do |stream|
@mutex.synchronize do
session = @sessions[session_id]
if session && related_request_id
session[:post_request_streams] ||= {}
session[:post_request_streams][related_request_id] = stream
end
end
begin
response = dispatch_handle_json(body_string, server_session)
send_to_stream(stream, response) if response
ensure
if related_request_id
@mutex.synchronize do
session = @sessions[session_id]
session[:post_request_streams]&.delete(related_request_id) if session
end
end
begin
stream.close
rescue StandardError
# Ignore close-related errors from already closed/broken streams.
end
end
end
[200, SSE_HEADERS.dup, body]
end
# Returns the SSE stream available for server-to-client messages.
# When `related_request_id` is given, returns only the POST response
# stream for that request (no fallback to GET SSE). This prevents
# request-scoped messages from leaking to the wrong stream.
# When `related_request_id` is nil, returns the GET SSE stream.
def active_stream(session, related_request_id: nil)
if related_request_id
session.dig(:post_request_streams, related_request_id)
else
session[:get_sse_stream]
end
end
def dispatch_handle_json(body_string, server_session)
if server_session
server_session.handle_json(body_string)
else
@server.handle_json(body_string)
end
end
def validate_and_touch_session(session_id)
removed = nil
response = @mutex.synchronize do
next session_not_found_response unless (session = @sessions[session_id])
next unless @session_idle_timeout
if session_expired?(session)
removed = cleanup_session_unsafe(session_id)
next session_not_found_response
end
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
nil
end
if removed
close_stream_safely(removed[:get_sse_stream])
removed[:post_request_streams]&.each_value do |stream|
close_stream_safely(stream)
end
end
response
end
def get_session_stream(session_id)
@mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
end
def session_exists?(session_id)
@mutex.synchronize { @sessions.key?(session_id) }
end
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
# are evicted as a side effect so a live request never observes a zombie session that
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
# rejecting a request must not extend the session's lifetime.
def session_active?(session_id)
removed = nil
active = @mutex.synchronize do
next false unless (session = @sessions[session_id])
if session_expired?(session)
removed = cleanup_session_unsafe(session_id)
next false
end
true
end
if removed
close_stream_safely(removed[:get_sse_stream])
close_post_request_streams(removed)
end
active
end
def method_not_allowed_response
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
end
def missing_session_id_response
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
end
def session_not_found_response
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
end
def already_initialized_response(request_id)
invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
end
def invalid_request_response(message, request_id: nil)
body = {
jsonrpc: "2.0",
id: request_id,
error: {
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: message,
},
}
[400, { "Content-Type" => "application/json" }, [body.to_json]]
end
def session_already_connected_response
[
409,
{ "Content-Type" => "application/json" },
[{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
]
end
def setup_sse_stream(session_id)
body = create_sse_body(session_id)
[200, SSE_HEADERS.dup, body]
end
def create_sse_body(session_id)
proc do |stream|
stored = store_stream_for_session(session_id, stream)
start_keepalive_thread(session_id) if stored
end
end
def store_stream_for_session(session_id, stream)
@mutex.synchronize do
session = @sessions[session_id]
if session && !session[:get_sse_stream]
session[:get_sse_stream] = stream
else
# Either session was removed, or another request already established a stream.
stream.close
# `stream.close` may return a truthy value depending on the stream class.
# Explicitly return nil to guarantee a falsy return for callers.
nil
end
end
end
def start_keepalive_thread(session_id)
Thread.new do
while session_active_with_stream?(session_id)
sleep(30)
send_keepalive_ping(session_id)
end
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
ensure
cleanup_session(session_id)
end
end
def session_active_with_stream?(session_id)
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
end
def send_keepalive_ping(session_id)
@mutex.synchronize do
if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
send_ping_to_stream(@sessions[session_id][:get_sse_stream])
end
end
rescue *STREAM_WRITE_ERRORS => e
MCP.configuration.exception_reporter.call(
e,
{ session_id: session_id, error: "Stream closed" },
)
raise # Re-raise to exit the keepalive loop
end
def session_expired?(session)
return false unless @session_idle_timeout
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
end
end
end
end
end