mcp 0.25.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../result_type"
4
5
  require_relative "../../transport"
5
6
 
6
7
  # This file is autoloaded only when `StreamableHTTPTransport` is referenced,
@@ -18,10 +19,16 @@ module MCP
18
19
  class StreamableHTTPTransport < Transport
19
20
  class InvalidJsonError < StandardError; end
20
21
 
22
+ # `x-accel-buffering: no` tells reverse proxies (nginx and friends) not to buffer the response,
23
+ # which the spec asks of every SSE stream: a buffering proxy holds events back instead of
24
+ # delivering them as they are written, and on a long-lived `subscriptions/listen` stream that
25
+ # also swallows the keepalive frames a dropped peer would otherwise be detected by.
26
+ # The TypeScript and Python SDKs send it on their SSE responses for the same reason.
21
27
  SSE_HEADERS = {
22
28
  "content-type" => "text/event-stream",
23
29
  "cache-control" => "no-cache",
24
30
  "connection" => "keep-alive",
31
+ "x-accel-buffering" => "no",
25
32
  }.freeze
26
33
 
27
34
  # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
@@ -35,10 +42,31 @@ module MCP
35
42
  DEFAULT_SESSION_IDLE_TIMEOUT = 1800
36
43
  DEFAULT_MAX_SESSIONS = 10_000
37
44
 
45
+ # Cap on concurrent `subscriptions/listen` streams (SEP-2575). Each stream holds an open SSE connection
46
+ # for its lifetime, so without a bound an unauthenticated client can retain unbounded connections,
47
+ # like the session-flood case `DEFAULT_MAX_SESSIONS` guards. A listen request past the cap is rejected with HTTP 503;
48
+ # pass `max_listen_subscriptions: nil` to opt out.
49
+ DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
50
+
38
51
  # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
39
52
  UNSET_IDLE_TIMEOUT = Object.new.freeze
40
53
  private_constant :UNSET_IDLE_TIMEOUT
41
54
 
55
+ # Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`).
56
+ # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust
57
+ # the sender's resources; without one, a client that opens a session and simply never replies parks
58
+ # a worker thread for good.
59
+ #
60
+ # Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds
61
+ # for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default
62
+ # is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation
63
+ # prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds
64
+ # nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler
65
+ # that legitimately waits longer passes `timeout:`.
66
+ #
67
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
68
+ DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
69
+
42
70
  # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
43
71
  # body into memory and parses it, so without a cap a single unauthenticated POST
44
72
  # can allocate gigabytes and OOM the worker. 4 MiB comfortably
@@ -51,6 +79,20 @@ module MCP
51
79
  # the stack or amplify parse cost (complements the byte cap).
52
80
  MAX_JSON_NESTING = 64
53
81
 
82
+ # Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them
83
+ # in memory until the handler returns, so a handler that emits notifications in proportion to
84
+ # client-supplied input would otherwise let one request grow memory without bound.
85
+ # Notifications past the cap are not delivered and the notify helpers report `false`,
86
+ # the same non-delivery degradation they have on every other undeliverable path.
87
+ MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000
88
+
89
+ # Interval in seconds between SSE keepalive comment frames on a `subscriptions/listen` stream.
90
+ # Without them a silently dropped connection holds its slot until the next fan-out write fails,
91
+ # so on a quiet server a dead peer would occupy a `max_listen_subscriptions` slot indefinitely.
92
+ # The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's
93
+ # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream.
94
+ DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15
95
+
54
96
  # Creates a Streamable HTTP transport that can be mounted as a Rack app.
55
97
  #
56
98
  # @param server [MCP::Server] the server whose requests this transport dispatches.
@@ -80,6 +122,22 @@ module MCP
80
122
  # ownership is not enforced.
81
123
  # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
82
124
  # requests are rejected with HTTP 413. Defaults to 4 MiB.
125
+ # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
126
+ # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
127
+ # the cap.
128
+ # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
129
+ # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
130
+ # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
131
+ # when an upstream proxy already keeps the stream alive.
132
+ # @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
133
+ # A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
134
+ # which builds a fresh transport per request and renders the body) passes `false`:
135
+ # the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
136
+ # and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
137
+ # keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
138
+ # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
139
+ # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
140
+ # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
83
141
  def initialize(
84
142
  server,
85
143
  stateless: false,
@@ -90,7 +148,11 @@ module MCP
90
148
  allowed_hosts: nil,
91
149
  dns_rebinding_protection: true,
92
150
  session_request_validator: nil,
93
- max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
151
+ max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
152
+ max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
153
+ listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
154
+ serve_subscriptions_listen: true,
155
+ server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
94
156
  )
95
157
  super(server)
96
158
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -107,6 +169,16 @@ module MCP
107
169
  @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
108
170
  @pending_responses = {}
109
171
 
172
+ # Maps a `subscriptions/listen` request id to
173
+ # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
174
+ # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
175
+ # which is a follow-up.
176
+ @listen_subscriptions = {}
177
+
178
+ # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits;
179
+ # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575).
180
+ @modern_request_sinks = {}
181
+
110
182
  # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
111
183
  # which does not apply to stateless mode since it retains no sessions.
112
184
  @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
@@ -136,6 +208,25 @@ module MCP
136
208
 
137
209
  @max_request_bytes = max_request_bytes
138
210
 
211
+ if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
212
+ raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
213
+ end
214
+
215
+ @max_listen_subscriptions = max_listen_subscriptions
216
+
217
+ if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
218
+ raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
219
+ end
220
+
221
+ @listen_keepalive_interval = listen_keepalive_interval
222
+ @serve_subscriptions_listen = serve_subscriptions_listen
223
+
224
+ unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
225
+ raise ArgumentError, "server_to_client_request_timeout must be a positive number"
226
+ end
227
+
228
+ @server_to_client_request_timeout = server_to_client_request_timeout
229
+
139
230
  start_reaper_thread if @session_idle_timeout
140
231
  end
141
232
 
@@ -149,15 +240,82 @@ module MCP
149
240
  # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
150
241
  DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
151
242
 
243
+ # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
244
+ NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
245
+
246
+ # Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on
247
+ # a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI
248
+ # against `resourceSubscriptions` instead.
249
+ LISTEN_FILTER_FIELDS = {
250
+ Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged,
251
+ Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged,
252
+ Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged,
253
+ }.freeze
254
+
255
+ # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
256
+ # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
257
+ # stays 200, matching the Python SDK's status ladder.
258
+ MODERN_BAD_REQUEST_CODES = [
259
+ ErrorCodes::HEADER_MISMATCH,
260
+ ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
261
+ ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
262
+ JsonRpcHandler::ErrorCode::PARSE_ERROR,
263
+ JsonRpcHandler::ErrorCode::INVALID_REQUEST,
264
+ JsonRpcHandler::ErrorCode::INVALID_PARAMS,
265
+ ].freeze
266
+
152
267
  # Rack app interface. This transport can be mounted as a Rack app.
153
268
  def call(env)
154
269
  handle_request(Rack::Request.new(env))
155
270
  end
156
271
 
272
+ # Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
273
+ # Gates both the route (a refusing transport answers the method as unimplemented) and
274
+ # the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
275
+ # so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
276
+ def serves_subscriptions_listen?
277
+ @serve_subscriptions_listen
278
+ end
279
+
157
280
  def handle_request(request)
158
281
  rebinding_error = validate_dns_rebinding(request)
159
282
  return rebinding_error if rebinding_error
160
283
 
284
+ # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
285
+ # every supported list routes to the sessionless modern path, so an unknown future version receives
286
+ # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
287
+ # Requests without the header, or with a stable-only version, take the existing paths untouched.
288
+ # An empty header value is malformed rather than a version claim, so it stays on the legacy path
289
+ # and fails legacy header validation as before.
290
+ #
291
+ # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
292
+ # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
293
+ # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
294
+ # and DELETE termination keep working), and a session-less POST whose body is `initialize` is
295
+ # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
296
+ # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
297
+ header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
298
+ if header_version && !header_version.empty? && !stable_only_version?(header_version)
299
+ unless MCP::Configuration.modern_protocol_version?(header_version)
300
+ return handle_modern(request, header_version)
301
+ end
302
+
303
+ if extract_session_id(request).nil?
304
+ return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
305
+
306
+ # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
307
+ # bounded, and hands the string to whichever path serves the request.
308
+ body_string = read_bounded_body(request)
309
+ return payload_too_large_response if body_string.nil?
310
+
311
+ unless legacy_handshake_body?(body_string)
312
+ return handle_modern(request, header_version, body_string: body_string)
313
+ end
314
+
315
+ return handle_post(request, body_string: body_string)
316
+ end
317
+ end
318
+
161
319
  case request.env["REQUEST_METHOD"]
162
320
  when "POST"
163
321
  handle_post(request)
@@ -174,6 +332,8 @@ module MCP
174
332
  @reaper_thread&.kill
175
333
  @reaper_thread = nil
176
334
 
335
+ teardown_listen_subscriptions
336
+
177
337
  removed_sessions = @mutex.synchronize do
178
338
  @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
179
339
  end
@@ -185,10 +345,11 @@ module MCP
185
345
  end
186
346
 
187
347
  def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
188
- # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
189
- # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
190
- # a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
191
- return false if @stateless
348
+ # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
349
+ # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
350
+ # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions,
351
+ # and a sink capturing the notification for its own response stream must not hide it from other subscriptions.
352
+ deliver_to_listen_subscriptions(method, params)
192
353
 
193
354
  notification = {
194
355
  jsonrpc: "2.0",
@@ -196,6 +357,24 @@ module MCP
196
357
  }
197
358
  notification[:params] = params if params
198
359
 
360
+ # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers
361
+ # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
362
+ # Checked before the stateless guard, since modern requests are served in stateless deployments too.
363
+ # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
364
+ # non-delivery (`false`), never handed to the legacy paths below.
365
+ sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
366
+ if sink
367
+ return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
368
+
369
+ sink << notification
370
+ return true
371
+ end
372
+
373
+ # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
374
+ # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
375
+ # a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
376
+ return false if @stateless
377
+
199
378
  if session_id
200
379
  deliver_targeted_notification(notification, session_id, related_request_id)
201
380
  else
@@ -282,8 +461,13 @@ module MCP
282
461
 
283
462
  @mutex.synchronize do
284
463
  session = @sessions[session_id]
285
- if related_request_id && session&.dig(:post_request_streams, related_request_id)
286
- session[:post_request_streams].delete(related_request_id)
464
+ if related_request_id
465
+ # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it,
466
+ # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped
467
+ # failure never reaches the session teardown below.
468
+ registered = session&.dig(:post_request_streams, related_request_id)
469
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
470
+
287
471
  streams_to_close << stream
288
472
  else
289
473
  cleanup_and_collect_stream(session_id, streams_to_close)
@@ -299,14 +483,28 @@ module MCP
299
483
  end
300
484
  end
301
485
 
302
- # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
303
- # blocks until the client responds.
486
+ # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
487
+ # the client responds.
488
+ #
489
+ # Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
490
+ # sends the request via SSE stream, then waits on it. When the client POSTs a response,
491
+ # `handle_response` matches it by `request_id` and resolves the pending response,
492
+ # unblocking this thread. A cancellation and session teardown resolve it the same way.
304
493
  #
305
- # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
306
- # sends the request via SSE stream, then blocks on `queue.pop`.
307
- # When the client POSTs a response, `handle_response` matches it by `request_id`
308
- # and pushes the result onto the queue, unblocking this thread.
309
- def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
494
+ # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
495
+ # so a client that never answers cannot park the calling thread for good. On expiry the peer is
496
+ # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
497
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
498
+ # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
499
+ # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached
500
+ # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`,
501
+ # but that is incidental: without the rule stated here a modern handler that reaches for elicitation
502
+ # or sampling is told "Session not found: <uuid>", which points at everything except the actual reason.
503
+ # `StdioTransport#send_request` refuses the same way.
504
+ if server_session&.era == :modern
505
+ raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
506
+ end
507
+
310
508
  if @stateless
311
509
  raise "Stateless mode does not support server-to-client requests."
312
510
  end
@@ -320,7 +518,8 @@ module MCP
320
518
  end
321
519
 
322
520
  request_id = generate_request_id
323
- queue = Queue.new
521
+ pending_response = PendingResponse.new
522
+ wait_timeout = timeout || @server_to_client_request_timeout
324
523
  cancel_hook = nil
325
524
 
326
525
  request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -333,7 +532,7 @@ module MCP
333
532
  raise "Session not found: #{session_id}."
334
533
  end
335
534
 
336
- @pending_responses[request_id] = { queue: queue, session_id: session_id }
535
+ @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
337
536
 
338
537
  active_stream(session, related_request_id: related_request_id)
339
538
  end
@@ -369,7 +568,27 @@ module MCP
369
568
  end
370
569
  end
371
570
 
372
- response = queue.pop
571
+ response = pending_response.pop(timeout: wait_timeout) do
572
+ # Expiry cancels as well as stops waiting, so a client that answers late does not act on
573
+ # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
574
+ # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
575
+ # never register the session this method looks up. Those revisions ask the sender to "issue
576
+ # a cancellation notification for that request and stop waiting", letting either side send one.
577
+ # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
578
+ # governs the era that has no such requests to cancel.) Both reference SDKs send this same
579
+ # courtesy cancel on timeout.
580
+ server_session&.send_peer_cancellation(
581
+ nested_request_id: request_id,
582
+ related_request_id: related_request_id,
583
+ reason: "Timed out after #{wait_timeout} seconds",
584
+ )
585
+
586
+ raise RequestTimeoutError.new(
587
+ "#{method} request timed out after #{wait_timeout} seconds",
588
+ request_id: request_id,
589
+ timeout: wait_timeout,
590
+ )
591
+ end
373
592
 
374
593
  if response.is_a?(Hash) && response.key?(:error)
375
594
  raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
@@ -457,7 +676,510 @@ module MCP
457
676
  stream.flush
458
677
  end
459
678
 
460
- def handle_post(request)
679
+ # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
680
+ # a single POST/JSON exchange with no session. The modern path never consults
681
+ # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
682
+ # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
683
+ # and DELETE (session termination) have no modern meaning.
684
+ def handle_modern(request, header_version, body_string: nil)
685
+ return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
686
+
687
+ accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
688
+ return accept_error if accept_error
689
+
690
+ content_type_error = validate_content_type(request)
691
+ return content_type_error if content_type_error
692
+
693
+ if body_string.nil?
694
+ body_string = read_bounded_body(request)
695
+ return payload_too_large_response if body_string.nil?
696
+ end
697
+
698
+ begin
699
+ body = parse_request_body(body_string)
700
+ rescue InvalidJsonError
701
+ return invalid_json_response
702
+ end
703
+
704
+ unless body.is_a?(Hash)
705
+ return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
706
+ end
707
+
708
+ # The version check precedes everything else that depends on request content,
709
+ # so a client probing with an unknown future version always receives the `-32022` signal
710
+ # (with the supported list to select from) rather than an incidental error.
711
+ unless MCP::Configuration.modern_protocol_version?(header_version)
712
+ return json_rpc_error_response(
713
+ status: 400,
714
+ code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
715
+ message: "Unsupported protocol version",
716
+ data: {
717
+ supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
718
+ requested: header_version,
719
+ },
720
+ id: body[:id],
721
+ )
722
+ end
723
+
724
+ if extract_session_id(request)
725
+ return json_rpc_error_response(
726
+ status: 400,
727
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
728
+ message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
729
+ id: body[:id],
730
+ )
731
+ end
732
+
733
+ mismatch_error = validate_modern_headers(request, body, header_version)
734
+ return mismatch_error if mismatch_error
735
+
736
+ # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
737
+ # it never dispatches through `Server#handle`. A transport constructed with
738
+ # `serve_subscriptions_listen: false` skips the interception, so the method falls through
739
+ # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
740
+ # serve an open SSE stream needs, instead of a `Proc` body it can never call.
741
+ if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
742
+ return handle_subscriptions_listen(body)
743
+ end
744
+
745
+ session = modern_session
746
+ notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
747
+ begin
748
+ response = @server.handle(body, session: session)
749
+ ensure
750
+ @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
751
+ end
752
+
753
+ # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
754
+ return handle_accepted if response.nil?
755
+
756
+ # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
757
+ # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
758
+ # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
759
+ # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
760
+ # Live streaming can follow without changing the wire shape.
761
+ if notifications.empty?
762
+ [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
763
+ else
764
+ frames = notifications + [response]
765
+ sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
766
+ [200, SSE_HEADERS.dup, [sse_body]]
767
+ end
768
+ rescue StandardError => e
769
+ MCP.configuration.exception_reporter.call(e, { request: body_string })
770
+ json_rpc_error_response(
771
+ status: 500,
772
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
773
+ message: "Internal server error",
774
+ )
775
+ end
776
+
777
+ # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
778
+ # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
779
+ # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
780
+ # converge on enforcement.
781
+ def validate_modern_headers(request, body, header_version)
782
+ params = body[:params]
783
+ meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
784
+ if meta_version && meta_version != header_version
785
+ return header_mismatch_response(
786
+ "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
787
+ body[:id],
788
+ )
789
+ end
790
+
791
+ # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
792
+ # the spec lists a missing required standard header among the validation failures that MUST be rejected,
793
+ # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
794
+ # the headers exist so intermediaries can route without parsing bodies, and a client that omits them
795
+ # defeats that contract silently.
796
+ method_header = request.env["HTTP_MCP_METHOD"]
797
+ if method_header.to_s.empty?
798
+ return header_mismatch_response(
799
+ "Mcp-Method header is required on the modern path",
800
+ body[:id],
801
+ )
802
+ end
803
+ if method_header != body[:method]
804
+ return header_mismatch_response(
805
+ "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
806
+ body[:id],
807
+ )
808
+ end
809
+
810
+ if NAME_BEARING_METHODS.include?(body[:method])
811
+ body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
812
+ name_header = request.env["HTTP_MCP_NAME"]
813
+ if body_name
814
+ if name_header.to_s.empty?
815
+ return header_mismatch_response(
816
+ "Mcp-Name header is required for `#{body[:method]}`",
817
+ body[:id],
818
+ )
819
+ end
820
+
821
+ decoded_name = decode_header_value(name_header)
822
+ if decoded_name != body_name
823
+ return header_mismatch_response(
824
+ "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
825
+ body[:id],
826
+ )
827
+ end
828
+ end
829
+ end
830
+
831
+ nil
832
+ end
833
+
834
+ # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
835
+ # `notifications/subscriptions/acknowledged` with the subset of requested notification types
836
+ # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
837
+ # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
838
+ # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
839
+ # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
840
+ def handle_subscriptions_listen(body)
841
+ request_id = body[:id]
842
+ params = body[:params]
843
+
844
+ # A listen frame without an id could never receive stream teardown correlation.
845
+ unless request_id
846
+ return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
847
+ end
848
+
849
+ begin
850
+ if RequestEnvelope.modern?(params)
851
+ RequestEnvelope.parse!(params, request: params)
852
+ else
853
+ return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
854
+ end
855
+ rescue Server::RequestHandlerError => e
856
+ return json_rpc_error_response(
857
+ status: 400,
858
+ code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST,
859
+ message: e.message,
860
+ data: e.error_data,
861
+ id: request_id,
862
+ )
863
+ end
864
+
865
+ filter = params[:notifications]
866
+ unless filter.is_a?(Hash)
867
+ return json_rpc_error_response(
868
+ status: 400,
869
+ code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
870
+ message: "Invalid params: subscriptions/listen requires a `notifications` filter object",
871
+ id: request_id,
872
+ )
873
+ end
874
+
875
+ # Best-effort cap check before committing to the SSE response; the registration inside
876
+ # `listen_sse_body` re-checks atomically for the race between two concurrent listens
877
+ # crossing the cap together.
878
+ if listen_subscriptions_full?
879
+ return too_many_listen_subscriptions_response(request_id)
880
+ end
881
+
882
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
883
+ end
884
+
885
+ def listen_subscriptions_full?
886
+ return false unless @max_listen_subscriptions
887
+
888
+ @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions }
889
+ end
890
+
891
+ def too_many_listen_subscriptions_response(request_id)
892
+ json_rpc_error_response(
893
+ status: 503,
894
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
895
+ message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached",
896
+ id: request_id,
897
+ )
898
+ end
899
+
900
+ # The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
901
+ # and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
902
+ # `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
903
+ # in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
904
+ class ListenStreamBody
905
+ def initialize(&block)
906
+ @block = block
907
+ end
908
+
909
+ def call(stream)
910
+ @block.call(stream)
911
+ end
912
+
913
+ def first
914
+ raise <<~MESSAGE
915
+ subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
916
+ A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
917
+ so the method is answered as unimplemented instead.
918
+ See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
919
+ MESSAGE
920
+ end
921
+ end
922
+ private_constant :ListenStreamBody
923
+
924
+ # The body registers the stream and returns, leaving the response open like
925
+ # the legacy GET stream (`create_sse_body`).
926
+ #
927
+ # Registration and activation are split on purpose: the entry is inserted inactive
928
+ # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
929
+ # and only then does the entry become eligible for delivery. A concurrent notification between
930
+ # the insert and the acknowledgement write skips the inactive entry,
931
+ # enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
932
+ def listen_sse_body(request_id, honored)
933
+ ListenStreamBody.new do |stream|
934
+ rejected = false
935
+ @mutex.synchronize do
936
+ if @listen_subscriptions.key?(request_id) ||
937
+ (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
938
+ rejected = true
939
+ else
940
+ @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
941
+ end
942
+ end
943
+
944
+ if rejected
945
+ close_stream_safely(stream)
946
+ else
947
+ acknowledgement = {
948
+ jsonrpc: "2.0",
949
+ method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
950
+ params: {
951
+ notifications: honored,
952
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
953
+ },
954
+ }
955
+
956
+ begin
957
+ send_to_stream(stream, acknowledgement)
958
+ activate_listen_subscription(request_id)
959
+ start_listen_keepalive_thread(request_id)
960
+ rescue *STREAM_WRITE_ERRORS
961
+ remove_listen_subscription(request_id)
962
+ close_stream_safely(stream)
963
+ end
964
+ end
965
+ end
966
+ end
967
+
968
+ # Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
969
+ # The entry may already be gone when the transport closed concurrently.
970
+ def activate_listen_subscription(request_id)
971
+ @mutex.synchronize do
972
+ subscription = @listen_subscriptions[request_id]
973
+ subscription[:active] = true if subscription
974
+ end
975
+ end
976
+
977
+ # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
978
+ # connection is detected and its slot freed, rather than held until the next fan-out write.
979
+ # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
980
+ # cannot corrupt an interleaved notification's JSON.
981
+ def start_listen_keepalive_thread(request_id)
982
+ return unless @listen_keepalive_interval
983
+
984
+ Thread.new do
985
+ while listen_subscription_active?(request_id)
986
+ sleep(@listen_keepalive_interval)
987
+ send_listen_keepalive_ping(request_id)
988
+ end
989
+ rescue *STREAM_WRITE_ERRORS
990
+ # The peer went away; the ensure frees the slot. A dropped listen stream is the normal
991
+ # way this loop ends, so it is not reported.
992
+ rescue StandardError => e
993
+ MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
994
+ ensure
995
+ stream = @mutex.synchronize do
996
+ subscription = @listen_subscriptions.delete(request_id)
997
+ subscription && subscription[:stream]
998
+ end
999
+ close_stream_safely(stream) if stream
1000
+ end
1001
+ end
1002
+
1003
+ def listen_subscription_active?(request_id)
1004
+ @mutex.synchronize { @listen_subscriptions.key?(request_id) }
1005
+ end
1006
+
1007
+ # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
1008
+ # every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
1009
+ def send_listen_keepalive_ping(request_id)
1010
+ stream = @mutex.synchronize do
1011
+ subscription = @listen_subscriptions[request_id]
1012
+ subscription && subscription[:stream]
1013
+ end
1014
+ return unless stream
1015
+
1016
+ send_ping_to_stream(stream)
1017
+ end
1018
+
1019
+ # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1020
+ # and the acknowledgement only includes types the server actually supports
1021
+ # (derived from its declared capabilities).
1022
+ def honored_filter(filter)
1023
+ capabilities = @server.capabilities
1024
+ honored = {}
1025
+ honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
1026
+ honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged)
1027
+ honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged)
1028
+
1029
+ subscriptions = filter[:resourceSubscriptions]
1030
+ if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1031
+ honored[:resourceSubscriptions] = subscriptions
1032
+ end
1033
+
1034
+ honored
1035
+ end
1036
+
1037
+ # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
1038
+ # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
1039
+ # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
1040
+ def capability_flag?(capabilities, name, flag)
1041
+ value = capabilities[name] || capabilities[name.to_s]
1042
+ return false unless value.is_a?(Hash)
1043
+
1044
+ !!(value[flag] || value[flag.to_s])
1045
+ end
1046
+
1047
+ # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it,
1048
+ # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter
1049
+ # (not the requested one) enforces the MUST NOT-send-unrequested-types rule.
1050
+ def deliver_to_listen_subscriptions(method, params)
1051
+ field = LISTEN_FILTER_FIELDS[method]
1052
+ return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
1053
+
1054
+ # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
1055
+ # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1056
+ matched = @mutex.synchronize do
1057
+ @listen_subscriptions.filter_map do |request_id, subscription|
1058
+ # An inactive entry has not finished writing its acknowledgement yet;
1059
+ # delivering to it would put a notification ahead of the acknowledgement.
1060
+ next unless subscription[:active]
1061
+
1062
+ hit = if field
1063
+ subscription[:filter][field]
1064
+ else
1065
+ uris = subscription[:filter][:resourceSubscriptions]
1066
+ uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1067
+ uris.is_a?(Array) && uris.include?(uri)
1068
+ end
1069
+
1070
+ [request_id, subscription] if hit
1071
+ end
1072
+ end
1073
+
1074
+ matched.each do |request_id, subscription|
1075
+ meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1076
+ notification_params = (params || {}).merge(_meta: meta)
1077
+ notification = { jsonrpc: "2.0", method: method, params: notification_params }
1078
+
1079
+ begin
1080
+ # The per-stream write mutex orders this write against a concurrent graceful teardown:
1081
+ # once teardown has marked the entry closed and written its `SubscriptionsListenResult`,
1082
+ # a delivery that snapshotted the entry before the registry was cleared skips it instead
1083
+ # of writing after the final message.
1084
+ subscription[:write_mutex].synchronize do
1085
+ next if subscription[:closed]
1086
+
1087
+ send_to_stream(subscription[:stream], notification)
1088
+ end
1089
+ rescue *STREAM_WRITE_ERRORS => e
1090
+ MCP.configuration.exception_reporter.call(
1091
+ e,
1092
+ { subscription_id: request_id, error: "Failed to send notification" },
1093
+ )
1094
+ remove_listen_subscription(request_id)
1095
+ close_stream_safely(subscription[:stream])
1096
+ end
1097
+ end
1098
+ end
1099
+
1100
+ def remove_listen_subscription(request_id)
1101
+ @mutex.synchronize { @listen_subscriptions.delete(request_id) }
1102
+ end
1103
+
1104
+ # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1105
+ # before the stream closes.
1106
+ def teardown_listen_subscriptions
1107
+ removed = @mutex.synchronize do
1108
+ subscriptions = @listen_subscriptions.dup
1109
+ @listen_subscriptions.clear
1110
+ subscriptions
1111
+ end
1112
+
1113
+ removed.each do |request_id, subscription|
1114
+ # Marking the entry closed and writing the result under the stream's write mutex orders
1115
+ # this against in-flight deliveries: each one either lands before the result or observes
1116
+ # `closed` and skips, keeping the graceful result the stream's final message.
1117
+ subscription[:write_mutex].synchronize do
1118
+ subscription[:closed] = true
1119
+
1120
+ begin
1121
+ send_to_stream(subscription[:stream], {
1122
+ jsonrpc: "2.0",
1123
+ id: request_id,
1124
+ result: {
1125
+ # `SubscriptionsListenResult` is served at the transport layer and never
1126
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1127
+ # stamped at its construction site.
1128
+ resultType: ResultType::COMPLETE,
1129
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1130
+ },
1131
+ })
1132
+ rescue *STREAM_WRITE_ERRORS
1133
+ nil
1134
+ end
1135
+ end
1136
+ close_stream_safely(subscription[:stream])
1137
+ end
1138
+ end
1139
+
1140
+ def header_mismatch_response(message, id)
1141
+ json_rpc_error_response(
1142
+ status: 400,
1143
+ code: ErrorCodes::HEADER_MISMATCH,
1144
+ message: "Header mismatch: #{message}",
1145
+ id: id,
1146
+ )
1147
+ end
1148
+
1149
+ # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
1150
+ # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
1151
+ # requires faraday, which servers do not depend on.
1152
+ def decode_header_value(value)
1153
+ match = value.match(/\A=\?base64\?(.*)\?=\z/m)
1154
+ return value unless match
1155
+
1156
+ match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
1157
+ rescue ArgumentError
1158
+ value
1159
+ end
1160
+
1161
+ # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
1162
+ # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
1163
+ # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
1164
+ # via the `session_id.nil?` branch.
1165
+ def modern_session
1166
+ ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
1167
+ end
1168
+
1169
+ def modern_http_status(response)
1170
+ error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
1171
+ if error_code.nil?
1172
+ 200
1173
+ elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
1174
+ 404
1175
+ elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
1176
+ 400
1177
+ else
1178
+ 200
1179
+ end
1180
+ end
1181
+
1182
+ def handle_post(request, body_string: nil)
461
1183
  required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
462
1184
  accept_error = validate_accept_header(request, required_types)
463
1185
  return accept_error if accept_error
@@ -465,8 +1187,10 @@ module MCP
465
1187
  content_type_error = validate_content_type(request)
466
1188
  return content_type_error if content_type_error
467
1189
 
468
- body_string = read_bounded_body(request)
469
- return payload_too_large_response if body_string.nil?
1190
+ if body_string.nil?
1191
+ body_string = read_bounded_body(request)
1192
+ return payload_too_large_response if body_string.nil?
1193
+ end
470
1194
 
471
1195
  session_id = extract_session_id(request)
472
1196
 
@@ -483,6 +1207,28 @@ module MCP
483
1207
  return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
484
1208
  end
485
1209
 
1210
+ # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
1211
+ # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
1212
+ # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
1213
+ # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
1214
+ # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
1215
+ # which violates the header/body match requirement and would fall through the legacy path via
1216
+ # the header default; reject that as a header mismatch.
1217
+ if RequestEnvelope.modern?(body[:params])
1218
+ header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
1219
+ if header_version && MCP::Configuration.modern_protocol_version?(header_version)
1220
+ return invalid_request_response(
1221
+ "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
1222
+ request_id: body[:id],
1223
+ )
1224
+ end
1225
+
1226
+ return header_mismatch_response(
1227
+ "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
1228
+ body[:id],
1229
+ )
1230
+ end
1231
+
486
1232
  # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
487
1233
  # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
488
1234
  # This matches the TypeScript and Python SDKs.
@@ -748,6 +1494,28 @@ module MCP
748
1494
  body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
749
1495
  end
750
1496
 
1497
+ # A version with no modern meaning, whose header can only accompany legacy traffic.
1498
+ # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
1499
+ # but requests of an established legacy session may stamp it - so that value needs further disambiguation.
1500
+ def stable_only_version?(version)
1501
+ MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
1502
+ !MCP::Configuration.modern_protocol_version?(version)
1503
+ end
1504
+
1505
+ # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
1506
+ # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
1507
+ # the envelope comes from a modern client naming a method its lifecycle removed, so it stays
1508
+ # on the modern path and answers with -32601/404 (SEP-2575).
1509
+ # Unparsable or non-object bodies go to the modern path, whose error responses cover them.
1510
+ def legacy_handshake_body?(body_string)
1511
+ body = parse_request_body(body_string)
1512
+ return false unless initialize_request?(body)
1513
+
1514
+ !RequestEnvelope.modern?(body[:params])
1515
+ rescue InvalidJsonError
1516
+ false
1517
+ end
1518
+
751
1519
  def validate_protocol_version_header(request)
752
1520
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
753
1521
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -760,8 +1528,10 @@ module MCP
760
1528
  )
761
1529
  end
762
1530
 
763
- def json_rpc_error_response(status:, code:, message:)
764
- body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
1531
+ def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1532
+ error = { code: code, message: message }
1533
+ error[:data] = data if data
1534
+ body = { jsonrpc: "2.0", id: id, error: error }
765
1535
  [status, { "content-type" => "application/json" }, [body.to_json]]
766
1536
  end
767
1537
 
@@ -905,6 +1675,14 @@ module MCP
905
1675
  end
906
1676
  end
907
1677
 
1678
+ # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below
1679
+ # registers this request's stream under that id first, so without this check the colliding request
1680
+ # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then
1681
+ # clear the entry the original request still needs.
1682
+ if related_request_id && server_session&.in_flight?(related_request_id)
1683
+ return request_id_conflict_response
1684
+ end
1685
+
908
1686
  if session_id && !@stateless && !@enable_json_response
909
1687
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
910
1688
  else
@@ -928,7 +1706,11 @@ module MCP
928
1706
  session = @sessions[session_id]
929
1707
  if session && related_request_id
930
1708
  session[:post_request_streams] ||= {}
931
- session[:post_request_streams][related_request_id] = stream
1709
+
1710
+ # Claim the id only while it is free. `handle_regular_request` already refused the colliding request,
1711
+ # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps
1712
+ # its messages going where they belong.
1713
+ session[:post_request_streams][related_request_id] ||= stream
932
1714
  end
933
1715
  end
934
1716
 
@@ -940,7 +1722,11 @@ module MCP
940
1722
  if related_request_id
941
1723
  @mutex.synchronize do
942
1724
  session = @sessions[session_id]
943
- session[:post_request_streams]&.delete(related_request_id) if session
1725
+ # Only retire our own registration: a request that never claimed the id, or one whose claim has
1726
+ # already been replaced, must not unregister the stream that owns it.
1727
+ registered = session&.dig(:post_request_streams, related_request_id)
1728
+
1729
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
944
1730
  end
945
1731
  end
946
1732
 
@@ -1159,6 +1945,16 @@ module MCP
1159
1945
  )
1160
1946
  end
1161
1947
 
1948
+ # The POST counterpart of the GET conflict above. A request id already in flight cannot be given
1949
+ # a stream of its own, because the id is what routes request-scoped messages back.
1950
+ def request_id_conflict_response
1951
+ json_rpc_error_response(
1952
+ status: 409,
1953
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1954
+ message: "Conflict: Request id is already in flight for this session",
1955
+ )
1956
+ end
1957
+
1162
1958
  def setup_sse_stream(session_id)
1163
1959
  body = create_sse_body(session_id)
1164
1960