Class: MCP::Server::Transports::StreamableHTTPTransport
- Defined in:
- lib/mcp/server/transports/streamable_http_transport.rb
Defined Under Namespace
Classes: InvalidJsonError
Constant Summary collapse
- SSE_HEADERS =
x-accel-buffering: notells reverse proxies (nginx and friends) not to buffer the response, which the spec asks of every SSE stream: a buffering proxy holds events back instead of delivering them as they are written, and on a long-livedsubscriptions/listenstream that also swallows the keepalive frames a dropped peer would otherwise be detected by. The TypeScript and Python SDKs send it on their SSE responses for the same reason. { "content-type" => "text/event-stream", "cache-control" => "no-cache", "connection" => "keep-alive", "x-accel-buffering" => "no", }.freeze
- DEFAULT_SESSION_IDLE_TIMEOUT =
Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, so an unauthenticated
initializeflood retains unboundedServerSessionobjects until memory is exhausted. These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport rejects a newinitializewith 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on an attacker's behalf, at the cost of refusing new sessions while genuinely full. Passsession_idle_timeout: nilto opt out of expiry andmax_sessions: nilto opt out of the cap. 1800- DEFAULT_MAX_SESSIONS =
10_000- DEFAULT_MAX_LISTEN_SUBSCRIPTIONS =
Cap on concurrent
subscriptions/listenstreams (SEP-2575). Each stream holds an open SSE connection for its lifetime, so without a bound an unauthenticated client can retain unbounded connections, like the session-flood caseDEFAULT_MAX_SESSIONSguards. A listen request past the cap is rejected with HTTP 503; passmax_listen_subscriptions: nilto opt out. 1_000- DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT =
Default deadline in seconds for a server-to-client request (sampling, elicitation,
roots/list,ping). The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources; without one, a client that opens a session and simply never replies parks a worker thread for good.Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for the legs of its legacy
input_requiredshim because they are "human-paced, so the 60s protocol default is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler that legitimately waits longer passestimeout:.https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
600- DEFAULT_MAX_REQUEST_BYTES =
Default upper bound on the JSON-RPC request body.
handle_postreads the whole body into memory and parses it, so without a cap a single unauthenticated POST can allocate gigabytes and OOM the worker. 4 MiB comfortably fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 payload); raisemax_request_bytes:for unusually large payloads. Matches the TypeScript SDK's 4 MB default. 4 * 1024 * 1024
- MAX_JSON_NESTING =
Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust the stack or amplify parse cost (complements the byte cap).
64- MAX_MODERN_REQUEST_NOTIFICATIONS =
Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them in memory until the handler returns, so a handler that emits notifications in proportion to client-supplied input would otherwise let one request grow memory without bound. Notifications past the cap are not delivered and the notify helpers report
false, the same non-delivery degradation they have on every other undeliverable path. 1_000- DEFAULT_LISTEN_KEEPALIVE_INTERVAL =
Interval in seconds between SSE keepalive comment frames on a
subscriptions/listenstream. Without them a silently dropped connection holds its slot until the next fan-out write fails, so on a quiet server a dead peer would occupy amax_listen_subscriptionsslot indefinitely. The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's 15-second default; passlisten_keepalive_interval: nilwhen an upstream proxy pings the stream. 15- 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- DEFAULT_LOOPBACK_HOSTS =
Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is protected out of the box; non-loopback deployments widen the list via
allowed_hosts:. ["127.0.0.1", "::1", "localhost"].freeze
- NAME_BEARING_METHODS =
JSON-RPC methods whose target name is mirrored into the
Mcp-Nameheader (SEP-2575). [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
- LISTEN_FILTER_FIELDS =
Maps broadcast notification methods to the
SubscriptionFilterfield that opts in to them on asubscriptions/listenstream (SEP-2575).notifications/resources/updatedis matched by URI againstresourceSubscriptionsinstead. { Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged, Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged, Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged, }.freeze
- MODERN_BAD_REQUEST_CODES =
JSON-RPC error codes that surface as HTTP 400 on the modern path.
-32601maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors, stays 200, matching the Python SDK's status ladder. [ ErrorCodes::HEADER_MISMATCH, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, JsonRpcHandler::ErrorCode::PARSE_ERROR, JsonRpcHandler::ErrorCode::INVALID_REQUEST, JsonRpcHandler::ErrorCode::INVALID_PARAMS, ].freeze
Instance Method Summary collapse
-
#call(env) ⇒ Object
Rack app interface.
-
#cancel_pending_request(request_id, reason: nil) ⇒ Object
Unblocks a
send_requestawaiting a response when the peer is being cancelled. - #close ⇒ Object
- #close_streams(streams) ⇒ Object
- #deliver_broadcast_notification(notification) ⇒ Object
- #deliver_targeted_notification(notification, session_id, related_request_id) ⇒ Object
-
#drop_broken_stream(session_id, stream, related_request_id) ⇒ Object
Removes a stream that failed to accept a write.
- #handle_request(request) ⇒ Object
-
#initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS, allowed_origins: nil, allowed_hosts: nil, dns_rebinding_protection: true, session_request_validator: nil, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT) ⇒ StreamableHTTPTransport
constructor
Creates a Streamable HTTP transport that can be mounted as a Rack app.
- #send_notification(method, params = nil, session_id: nil, related_request_id: nil) ⇒ Object
-
#send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) ⇒ Object
Sends a server-to-client JSON-RPC request (e.g.,
sampling/createMessage) and blocks until the client responds. -
#serves_subscriptions_listen? ⇒ Boolean
The
subscriptions/listennotification stream (SEP-2575) is served on the modern path, soServer#discovermay advertiselistChanged/subscribecapability flags. -
#write_notification(stream, notification, session_id, related_request_id) ⇒ Object
Writes a notification to an SSE stream without holding
@mutex.
Methods inherited from Transport
#handle_json_request, #open, #send_response
Constructor Details
#initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS, allowed_origins: nil, allowed_hosts: nil, dns_rebinding_protection: true, session_request_validator: nil, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT) ⇒ StreamableHTTPTransport
Creates a Streamable HTTP transport that can be mounted as a Rack app.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 135 def initialize( server, stateless: false, enable_json_response: false, session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS, allowed_origins: nil, allowed_hosts: nil, dns_rebinding_protection: true, session_request_validator: nil, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @sessions = {} @mutex = Mutex.new @stateless = stateless @enable_json_response = enable_json_response @session_request_validator = session_request_validator @dns_rebinding_protection = dns_rebinding_protection # Host names are case-insensitive, so the allow lists are compared down-cased. @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575). # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, # which is a follow-up. @listen_subscriptions = {} # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits; # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575). @modern_request_sinks = {} # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, # which does not apply to stateless mode since it retains no sessions. @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT) stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT else session_idle_timeout end 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 unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0) raise ArgumentError, "max_sessions must be a positive Integer or nil" end # The cap guards the stateful session store; stateless mode keeps none. @max_sessions = stateless ? nil : max_sessions unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0 raise ArgumentError, "max_request_bytes must be a positive Integer" end @max_request_bytes = max_request_bytes if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0) raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil" end @max_listen_subscriptions = max_listen_subscriptions if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0) raise ArgumentError, "listen_keepalive_interval must be a positive number or nil" end @listen_keepalive_interval = listen_keepalive_interval unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive? raise ArgumentError, "server_to_client_request_timeout must be a positive number" end @server_to_client_request_timeout = server_to_client_request_timeout start_reaper_thread if @session_idle_timeout end |
Instance Method Details
#call(env) ⇒ Object
Rack app interface. This transport can be mounted as a Rack app.
259 260 261 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 259 def call(env) handle_request(Rack::Request.new(env)) end |
#cancel_pending_request(request_id, reason: nil) ⇒ Object
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.
613 614 615 616 617 618 619 620 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 613 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 |
#close ⇒ Object
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 320 def close @reaper_thread&.kill @reaper_thread = nil teardown_listen_subscriptions 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 |
#close_streams(streams) ⇒ Object
464 465 466 467 468 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 464 def close_streams(streams) streams.each do |stream| close_stream_safely(stream) end end |
#deliver_broadcast_notification(notification) ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 402 def deliver_broadcast_notification(notification) streams_to_close = [] # Snapshot the connected streams under the lock, then write to each outside it. targets = @mutex.synchronize do expired_session_ids = [] collected = @sessions.filter_map do |session_id, session| next unless (stream = session[:get_sse_stream]) if session_expired?(session) expired_session_ids << session_id next end [session_id, stream] end expired_session_ids.each do |session_id| cleanup_and_collect_stream(session_id, streams_to_close) end collected end close_streams(streams_to_close) targets.count do |session_id, stream| write_notification(stream, notification, session_id, nil) end end |
#deliver_targeted_notification(notification, session_id, related_request_id) ⇒ Object
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 374 def deliver_targeted_notification(notification, 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. return false if @enable_json_response && streams_to_close = [] # Resolve the target stream under the lock, then write outside it: a stalled SSE reader # must not block every other session that needs `@mutex`. stream = @mutex.synchronize do next unless (session = @sessions[session_id]) if session_expired?(session) cleanup_and_collect_stream(session_id, streams_to_close) next end active_stream(session, related_request_id: ) end close_streams(streams_to_close) return false unless stream write_notification(stream, notification, session_id, ) end |
#drop_broken_stream(session_id, stream, related_request_id) ⇒ Object
Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
a session-scoped (GET SSE) failure tears down the whole session. The @sessions mutation runs
under @mutex, and the affected streams are closed outside it.
448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 448 def drop_broken_stream(session_id, stream, ) streams_to_close = [] @mutex.synchronize do session = @sessions[session_id] if && session&.dig(:post_request_streams, ) session[:post_request_streams].delete() streams_to_close << stream else cleanup_and_collect_stream(session_id, streams_to_close) end end close_streams(streams_to_close) end |
#handle_request(request) ⇒ Object
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 269 def handle_request(request) rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside # every supported list routes to the sessionless modern path, so an unknown future version receives # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error. # Requests without the header, or with a stable-only version, take the existing paths untouched. # An empty header value is malformed rather than a version claim, so it stays on the legacy path # and fails legacy header validation as before. # # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it, # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it): # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, # and DELETE termination keep working), and a session-less POST whose body is `initialize` is # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] if header_version && !header_version.empty? && !stable_only_version?(header_version) unless MCP::Configuration.modern_protocol_version?(header_version) return handle_modern(request, header_version) end if extract_session_id(request).nil? return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST" # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here, # bounded, and hands the string to whichever path serves the request. body_string = read_bounded_body(request) return payload_too_large_response if body_string.nil? unless legacy_handshake_body?(body_string) return handle_modern(request, header_version, body_string: body_string) end return handle_post(request, body_string: body_string) end end 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 |
#send_notification(method, params = nil, session_id: nil, related_request_id: nil) ⇒ Object
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 336 def send_notification(method, params = nil, session_id: nil, related_request_id: nil) # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below: # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too. # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions, # and a sink capturing the notification for its own response stream must not hide it from other subscriptions. deliver_to_listen_subscriptions(method, params) notification = { jsonrpc: "2.0", method: method, } notification[:params] = params if params # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response. # Checked before the stateless guard, since modern requests are served in stateless deployments too. # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as # non-delivery (`false`), never handed to the legacy paths below. sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] } if sink return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS sink << notification return true end # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. return false if @stateless if session_id deliver_targeted_notification(notification, session_id, ) else deliver_broadcast_notification(notification) end end |
#send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) ⇒ Object
Sends a server-to-client JSON-RPC request (e.g., sampling/createMessage) and blocks until
the client responds.
Uses a PendingResponse for cross-thread synchronization: this method registers one,
sends the request via SSE stream, then waits on it. When the client POSTs a response,
handle_response matches it by request_id and resolves the pending response,
unblocking this thread. A cancellation and session teardown resolve it the same way.
The wait is bounded by timeout (defaulting to the transport's server_to_client_request_timeout),
so a client that never answers cannot park the calling thread for good. On expiry the peer is
sent notifications/cancelled and MCP::Server::RequestTimeoutError is raised.
481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 481 def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`, # but that is incidental: without the rule stated here a modern handler that reaches for elicitation # or sampling is told "Session not found: <uuid>", which points at everything except the actual reason. # `StdioTransport#send_request` refuses the same way. if server_session&.era == :modern raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." end 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 pending_response = PendingResponse.new wait_timeout = timeout || @server_to_client_request_timeout cancel_hook = nil request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params # Register the pending response and resolve the stream under the lock, but perform # the write outside it so a stalled reader cannot block every other session on `@mutex`. stream = @mutex.synchronize do unless (session = @sessions[session_id]) raise "Session not found: #{session_id}." end @pending_responses[request_id] = { queue: pending_response, session_id: session_id } active_stream(session, related_request_id: ) end sent = false if stream begin send_to_stream(stream, request) sent = true rescue *STREAM_WRITE_ERRORS drop_broken_stream(session_id, stream, ) 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: , reason: reason, ) end end response = pending_response.pop(timeout: wait_timeout) do # Expiry cancels as well as stops waiting, so a client that answers late does not act on # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here: # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests # never register the session this method looks up. Those revisions ask the sender to "issue # a cancellation notification for that request and stop waiting", letting either side send one. # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown # governs the era that has no such requests to cancel.) Both reference SDKs send this same # courtesy cancel on timeout. server_session&.send_peer_cancellation( nested_request_id: request_id, related_request_id: , reason: "Timed out after #{wait_timeout} seconds", ) raise RequestTimeoutError.new( "#{method} request timed out after #{wait_timeout} seconds", request_id: request_id, timeout: wait_timeout, ) end 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 |
#serves_subscriptions_listen? ⇒ Boolean
The subscriptions/listen notification stream (SEP-2575) is served on the modern path,
so Server#discover may advertise listChanged/subscribe capability flags.
265 266 267 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 265 def serves_subscriptions_listen? true end |
#write_notification(stream, notification, session_id, related_request_id) ⇒ Object
Writes a notification to an SSE stream without holding @mutex. On a write error,
drops the broken stream and returns false; on success returns true.
436 437 438 439 440 441 442 443 |
# File 'lib/mcp/server/transports/streamable_http_transport.rb', line 436 def write_notification(stream, notification, session_id, ) 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" }) drop_broken_stream(session_id, stream, ) false end |