## Motivation and Context The Ruby SDK uses a 1-Server-to-many-sessions model where session-specific state (`@client`, `@logging_message_notification`) is stored on the shared `Server` instance. This causes the last client to connect to overwrite state for all sessions, and requires `session_id` to be threaded through method signatures for session-scoped notifications. The Python SDK avoids this by creating a `ServerSession` per connection that wraps a shared `Server`. Each session holds its own state and naturally scopes notifications through its own stream. ### Changes - Added `MCP::ServerSession` class holding per-session state: client info, logging configuration, and a `transport` reference for session-scoped notification delivery. - `StreamableHTTPTransport#handle_initialization` creates a `ServerSession` per `initialize` request and stores it in the session hash. - `handle_regular_request` routes requests through the session's `ServerSession` instead of the shared `Server` directly. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword (`ServerSession` instance). - `Server#init` stores client info on the session when available. - `Server#configure_logging_level` stores logging config on the session. - `ServerContext` and `Progress` accept a `notification_target:` which can be either a `ServerSession` (session-scoped) or a `Server` (broadcast). No `session_id:` parameter needed. - `ServerContext#notify_progress` and `#notify_log_message` delegate to the `notification_target` without `session_id` threading. - `StdioTransport` creates a single `ServerSession` on `open`, making the session model transparent across both transports. ### Design Decision This follows the Python SDK's "shared `Server` + per-connection `Session`" pattern. The `Server` holds configuration (tools, prompts, resources, handlers). Each `ServerSession` holds per-connection state (client info, logging level, stream writer). Notifications from `ServerSession` go only to that session's stream. ## How Has This Been Tested? All existing tests pass. All conformance tests pass. Added tests verifying: - Session-scoped log notification is sent only to the originating session. - Session-scoped progress notification is sent only to the originating session. - Each session stores its own client info independently. - Each session stores its own logging level independently. - `StdioTransport#open` creates a `ServerSession` and stores client info on it. ## Breaking Change None for end users. The public API (`Server.new`, `define_tool`,`server_context.report_progress`, `server_context.notify_log_message`, etc.) is unchanged. The following internal API changes affect only SDK internals: - `ServerContext.new` now requires `notification_target:` instead of just`progress:`. - `Progress.new` now takes `notification_target:` instead of `server:`. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword.
399 lines
12 KiB
Ruby
399 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "securerandom"
|
|
require_relative "../../transport"
|
|
|
|
module MCP
|
|
class Server
|
|
module Transports
|
|
class StreamableHTTPTransport < Transport
|
|
def initialize(server, stateless: false)
|
|
super(server)
|
|
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`.
|
|
@sessions = {}
|
|
@mutex = Mutex.new
|
|
|
|
@stateless = stateless
|
|
end
|
|
|
|
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
|
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
|
|
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
|
|
@mutex.synchronize do
|
|
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
|
end
|
|
end
|
|
|
|
def send_notification(method, params = nil, session_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
|
|
|
|
@mutex.synchronize do
|
|
if session_id
|
|
# Send to specific session
|
|
session = @sessions[session_id]
|
|
return false unless session && session[:stream]
|
|
|
|
begin
|
|
send_to_stream(session[:stream], notification)
|
|
true
|
|
rescue *STREAM_WRITE_ERRORS => e
|
|
MCP.configuration.exception_reporter.call(
|
|
e,
|
|
{ session_id: session_id, error: "Failed to send notification" },
|
|
)
|
|
cleanup_session_unsafe(session_id)
|
|
false
|
|
end
|
|
else
|
|
# Broadcast to all connected SSE sessions
|
|
sent_count = 0
|
|
failed_sessions = []
|
|
|
|
@sessions.each do |sid, session|
|
|
next unless session[:stream]
|
|
|
|
begin
|
|
send_to_stream(session[: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_session_unsafe(sid) }
|
|
|
|
sent_count
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def send_to_stream(stream, data)
|
|
message = data.is_a?(String) ? data : data.to_json
|
|
stream.write("data: #{message}\n\n")
|
|
stream.flush if stream.respond_to?(:flush)
|
|
end
|
|
|
|
def send_ping_to_stream(stream)
|
|
stream.write(": ping #{Time.now.iso8601}\n\n")
|
|
stream.flush if stream.respond_to?(:flush)
|
|
end
|
|
|
|
def handle_post(request)
|
|
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
|
|
return accept_error if accept_error
|
|
|
|
body_string = request.body.read
|
|
session_id = extract_session_id(request)
|
|
|
|
body = parse_request_body(body_string)
|
|
return body unless body.is_a?(Hash) # Error response
|
|
|
|
if body["method"] == "initialize"
|
|
handle_initialization(body_string, body)
|
|
elsif notification?(body) || response?(body)
|
|
handle_accepted
|
|
else
|
|
handle_regular_request(body_string, session_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
|
|
return session_not_found_response unless session_exists?(session_id)
|
|
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
|
|
# 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 = request.env["HTTP_MCP_SESSION_ID"])
|
|
return session_not_found_response unless session_exists?(session_id)
|
|
|
|
cleanup_session(session_id)
|
|
|
|
success_response
|
|
end
|
|
|
|
def cleanup_session(session_id)
|
|
@mutex.synchronize do
|
|
cleanup_session_unsafe(session_id)
|
|
end
|
|
end
|
|
|
|
def cleanup_session_unsafe(session_id)
|
|
session = @sessions[session_id]
|
|
return unless session
|
|
|
|
begin
|
|
session[:stream]&.close
|
|
rescue
|
|
nil
|
|
end
|
|
@sessions.delete(session_id)
|
|
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
|
|
end
|
|
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)
|
|
rescue JSON::ParserError, TypeError
|
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
end
|
|
|
|
def notification?(body)
|
|
!body["id"] && !!body["method"]
|
|
end
|
|
|
|
def response?(body)
|
|
!!body["id"] && !body["method"]
|
|
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] = {
|
|
stream: nil,
|
|
server_session: server_session,
|
|
}
|
|
end
|
|
end
|
|
|
|
response = if server_session
|
|
server_session.handle_json(body_string)
|
|
else
|
|
@server.handle_json(body_string)
|
|
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)
|
|
server_session = nil
|
|
stream = nil
|
|
|
|
if session_id && !@stateless
|
|
@mutex.synchronize do
|
|
session = @sessions[session_id]
|
|
return session_not_found_response unless session
|
|
|
|
server_session = session[:server_session]
|
|
stream = session[:stream]
|
|
end
|
|
end
|
|
|
|
response = if server_session
|
|
server_session.handle_json(body_string)
|
|
else
|
|
@server.handle_json(body_string)
|
|
end
|
|
|
|
if stream
|
|
send_response_to_stream(stream, response, session_id)
|
|
else
|
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
end
|
|
end
|
|
|
|
def get_session_stream(session_id)
|
|
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
|
|
end
|
|
|
|
def send_response_to_stream(stream, response, session_id)
|
|
message = JSON.parse(response)
|
|
send_to_stream(stream, message)
|
|
handle_accepted
|
|
rescue *STREAM_WRITE_ERRORS => e
|
|
MCP.configuration.exception_reporter.call(
|
|
e,
|
|
{ session_id: session_id, error: "Stream closed during response" },
|
|
)
|
|
cleanup_session(session_id)
|
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
end
|
|
|
|
def session_exists?(session_id)
|
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
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 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)
|
|
|
|
headers = {
|
|
"Content-Type" => "text/event-stream",
|
|
"Cache-Control" => "no-cache",
|
|
"Connection" => "keep-alive",
|
|
}
|
|
|
|
[200, headers, 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[:stream]
|
|
session[: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][:stream] }
|
|
end
|
|
|
|
def send_keepalive_ping(session_id)
|
|
@mutex.synchronize do
|
|
if @sessions[session_id] && @sessions[session_id][:stream]
|
|
send_ping_to_stream(@sessions[session_id][: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
|
|
end
|
|
end
|
|
end
|
|
end
|