mcp 1.1.0 → 1.3.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,16 @@ 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 server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
133
+ # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
134
+ # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
83
135
  def initialize(
84
136
  server,
85
137
  stateless: false,
@@ -90,7 +142,10 @@ module MCP
90
142
  allowed_hosts: nil,
91
143
  dns_rebinding_protection: true,
92
144
  session_request_validator: nil,
93
- max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
145
+ max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
146
+ max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
147
+ listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
148
+ server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
94
149
  )
95
150
  super(server)
96
151
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -107,6 +162,15 @@ module MCP
107
162
  @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
108
163
  @pending_responses = {}
109
164
 
165
+ # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575).
166
+ # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
167
+ # which is a follow-up.
168
+ @listen_subscriptions = {}
169
+
170
+ # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits;
171
+ # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575).
172
+ @modern_request_sinks = {}
173
+
110
174
  # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
111
175
  # which does not apply to stateless mode since it retains no sessions.
112
176
  @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
@@ -136,6 +200,24 @@ module MCP
136
200
 
137
201
  @max_request_bytes = max_request_bytes
138
202
 
203
+ if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
204
+ raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
205
+ end
206
+
207
+ @max_listen_subscriptions = max_listen_subscriptions
208
+
209
+ if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
210
+ raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
211
+ end
212
+
213
+ @listen_keepalive_interval = listen_keepalive_interval
214
+
215
+ unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
216
+ raise ArgumentError, "server_to_client_request_timeout must be a positive number"
217
+ end
218
+
219
+ @server_to_client_request_timeout = server_to_client_request_timeout
220
+
139
221
  start_reaper_thread if @session_idle_timeout
140
222
  end
141
223
 
@@ -149,15 +231,80 @@ module MCP
149
231
  # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
150
232
  DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
151
233
 
234
+ # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
235
+ NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
236
+
237
+ # Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on
238
+ # a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI
239
+ # against `resourceSubscriptions` instead.
240
+ LISTEN_FILTER_FIELDS = {
241
+ Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged,
242
+ Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged,
243
+ Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged,
244
+ }.freeze
245
+
246
+ # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
247
+ # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
248
+ # stays 200, matching the Python SDK's status ladder.
249
+ MODERN_BAD_REQUEST_CODES = [
250
+ ErrorCodes::HEADER_MISMATCH,
251
+ ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
252
+ ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
253
+ JsonRpcHandler::ErrorCode::PARSE_ERROR,
254
+ JsonRpcHandler::ErrorCode::INVALID_REQUEST,
255
+ JsonRpcHandler::ErrorCode::INVALID_PARAMS,
256
+ ].freeze
257
+
152
258
  # Rack app interface. This transport can be mounted as a Rack app.
153
259
  def call(env)
154
260
  handle_request(Rack::Request.new(env))
155
261
  end
156
262
 
263
+ # The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path,
264
+ # so `Server#discover` may advertise `listChanged`/`subscribe` capability flags.
265
+ def serves_subscriptions_listen?
266
+ true
267
+ end
268
+
157
269
  def handle_request(request)
158
270
  rebinding_error = validate_dns_rebinding(request)
159
271
  return rebinding_error if rebinding_error
160
272
 
273
+ # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
274
+ # every supported list routes to the sessionless modern path, so an unknown future version receives
275
+ # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
276
+ # Requests without the header, or with a stable-only version, take the existing paths untouched.
277
+ # An empty header value is malformed rather than a version claim, so it stays on the legacy path
278
+ # and fails legacy header validation as before.
279
+ #
280
+ # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
281
+ # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
282
+ # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
283
+ # and DELETE termination keep working), and a session-less POST whose body is `initialize` is
284
+ # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
285
+ # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
286
+ header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
287
+ if header_version && !header_version.empty? && !stable_only_version?(header_version)
288
+ unless MCP::Configuration.modern_protocol_version?(header_version)
289
+ return handle_modern(request, header_version)
290
+ end
291
+
292
+ if extract_session_id(request).nil?
293
+ return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
294
+
295
+ # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
296
+ # bounded, and hands the string to whichever path serves the request.
297
+ body_string = read_bounded_body(request)
298
+ return payload_too_large_response if body_string.nil?
299
+
300
+ unless legacy_handshake_body?(body_string)
301
+ return handle_modern(request, header_version, body_string: body_string)
302
+ end
303
+
304
+ return handle_post(request, body_string: body_string)
305
+ end
306
+ end
307
+
161
308
  case request.env["REQUEST_METHOD"]
162
309
  when "POST"
163
310
  handle_post(request)
@@ -174,6 +321,8 @@ module MCP
174
321
  @reaper_thread&.kill
175
322
  @reaper_thread = nil
176
323
 
324
+ teardown_listen_subscriptions
325
+
177
326
  removed_sessions = @mutex.synchronize do
178
327
  @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
179
328
  end
@@ -185,10 +334,11 @@ module MCP
185
334
  end
186
335
 
187
336
  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
337
+ # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
338
+ # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
339
+ # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions,
340
+ # and a sink capturing the notification for its own response stream must not hide it from other subscriptions.
341
+ deliver_to_listen_subscriptions(method, params)
192
342
 
193
343
  notification = {
194
344
  jsonrpc: "2.0",
@@ -196,6 +346,24 @@ module MCP
196
346
  }
197
347
  notification[:params] = params if params
198
348
 
349
+ # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers
350
+ # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
351
+ # Checked before the stateless guard, since modern requests are served in stateless deployments too.
352
+ # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
353
+ # non-delivery (`false`), never handed to the legacy paths below.
354
+ sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
355
+ if sink
356
+ return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
357
+
358
+ sink << notification
359
+ return true
360
+ end
361
+
362
+ # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
363
+ # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
364
+ # a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
365
+ return false if @stateless
366
+
199
367
  if session_id
200
368
  deliver_targeted_notification(notification, session_id, related_request_id)
201
369
  else
@@ -282,8 +450,13 @@ module MCP
282
450
 
283
451
  @mutex.synchronize do
284
452
  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)
453
+ if related_request_id
454
+ # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it,
455
+ # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped
456
+ # failure never reaches the session teardown below.
457
+ registered = session&.dig(:post_request_streams, related_request_id)
458
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
459
+
287
460
  streams_to_close << stream
288
461
  else
289
462
  cleanup_and_collect_stream(session_id, streams_to_close)
@@ -299,14 +472,28 @@ module MCP
299
472
  end
300
473
  end
301
474
 
302
- # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
303
- # blocks until the client responds.
475
+ # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
476
+ # the client responds.
304
477
  #
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)
478
+ # Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
479
+ # sends the request via SSE stream, then waits on it. When the client POSTs a response,
480
+ # `handle_response` matches it by `request_id` and resolves the pending response,
481
+ # unblocking this thread. A cancellation and session teardown resolve it the same way.
482
+ #
483
+ # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
484
+ # so a client that never answers cannot park the calling thread for good. On expiry the peer is
485
+ # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
486
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
487
+ # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
488
+ # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached
489
+ # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`,
490
+ # but that is incidental: without the rule stated here a modern handler that reaches for elicitation
491
+ # or sampling is told "Session not found: <uuid>", which points at everything except the actual reason.
492
+ # `StdioTransport#send_request` refuses the same way.
493
+ if server_session&.era == :modern
494
+ raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
495
+ end
496
+
310
497
  if @stateless
311
498
  raise "Stateless mode does not support server-to-client requests."
312
499
  end
@@ -320,7 +507,8 @@ module MCP
320
507
  end
321
508
 
322
509
  request_id = generate_request_id
323
- queue = Queue.new
510
+ pending_response = PendingResponse.new
511
+ wait_timeout = timeout || @server_to_client_request_timeout
324
512
  cancel_hook = nil
325
513
 
326
514
  request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -333,7 +521,7 @@ module MCP
333
521
  raise "Session not found: #{session_id}."
334
522
  end
335
523
 
336
- @pending_responses[request_id] = { queue: queue, session_id: session_id }
524
+ @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
337
525
 
338
526
  active_stream(session, related_request_id: related_request_id)
339
527
  end
@@ -369,7 +557,27 @@ module MCP
369
557
  end
370
558
  end
371
559
 
372
- response = queue.pop
560
+ response = pending_response.pop(timeout: wait_timeout) do
561
+ # Expiry cancels as well as stops waiting, so a client that answers late does not act on
562
+ # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
563
+ # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
564
+ # never register the session this method looks up. Those revisions ask the sender to "issue
565
+ # a cancellation notification for that request and stop waiting", letting either side send one.
566
+ # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
567
+ # governs the era that has no such requests to cancel.) Both reference SDKs send this same
568
+ # courtesy cancel on timeout.
569
+ server_session&.send_peer_cancellation(
570
+ nested_request_id: request_id,
571
+ related_request_id: related_request_id,
572
+ reason: "Timed out after #{wait_timeout} seconds",
573
+ )
574
+
575
+ raise RequestTimeoutError.new(
576
+ "#{method} request timed out after #{wait_timeout} seconds",
577
+ request_id: request_id,
578
+ timeout: wait_timeout,
579
+ )
580
+ end
373
581
 
374
582
  if response.is_a?(Hash) && response.key?(:error)
375
583
  raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
@@ -457,7 +665,446 @@ module MCP
457
665
  stream.flush
458
666
  end
459
667
 
460
- def handle_post(request)
668
+ # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
669
+ # a single POST/JSON exchange with no session. The modern path never consults
670
+ # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
671
+ # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
672
+ # and DELETE (session termination) have no modern meaning.
673
+ def handle_modern(request, header_version, body_string: nil)
674
+ return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
675
+
676
+ accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
677
+ return accept_error if accept_error
678
+
679
+ content_type_error = validate_content_type(request)
680
+ return content_type_error if content_type_error
681
+
682
+ if body_string.nil?
683
+ body_string = read_bounded_body(request)
684
+ return payload_too_large_response if body_string.nil?
685
+ end
686
+
687
+ begin
688
+ body = parse_request_body(body_string)
689
+ rescue InvalidJsonError
690
+ return invalid_json_response
691
+ end
692
+
693
+ unless body.is_a?(Hash)
694
+ return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
695
+ end
696
+
697
+ # The version check precedes everything else that depends on request content,
698
+ # so a client probing with an unknown future version always receives the `-32022` signal
699
+ # (with the supported list to select from) rather than an incidental error.
700
+ unless MCP::Configuration.modern_protocol_version?(header_version)
701
+ return json_rpc_error_response(
702
+ status: 400,
703
+ code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
704
+ message: "Unsupported protocol version",
705
+ data: {
706
+ supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
707
+ requested: header_version,
708
+ },
709
+ id: body[:id],
710
+ )
711
+ end
712
+
713
+ if extract_session_id(request)
714
+ return json_rpc_error_response(
715
+ status: 400,
716
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
717
+ message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
718
+ id: body[:id],
719
+ )
720
+ end
721
+
722
+ mismatch_error = validate_modern_headers(request, body, header_version)
723
+ return mismatch_error if mismatch_error
724
+
725
+ # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
726
+ # it never dispatches through `Server#handle`.
727
+ return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
728
+
729
+ session = modern_session
730
+ notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
731
+ begin
732
+ response = @server.handle(body, session: session)
733
+ ensure
734
+ @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
735
+ end
736
+
737
+ # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
738
+ return handle_accepted if response.nil?
739
+
740
+ # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
741
+ # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
742
+ # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
743
+ # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
744
+ # Live streaming can follow without changing the wire shape.
745
+ if notifications.empty?
746
+ [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
747
+ else
748
+ frames = notifications + [response]
749
+ sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
750
+ [200, SSE_HEADERS.dup, [sse_body]]
751
+ end
752
+ rescue StandardError => e
753
+ MCP.configuration.exception_reporter.call(e, { request: body_string })
754
+ json_rpc_error_response(
755
+ status: 500,
756
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
757
+ message: "Internal server error",
758
+ )
759
+ end
760
+
761
+ # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
762
+ # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
763
+ # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
764
+ # converge on enforcement.
765
+ def validate_modern_headers(request, body, header_version)
766
+ params = body[:params]
767
+ meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
768
+ if meta_version && meta_version != header_version
769
+ return header_mismatch_response(
770
+ "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
771
+ body[:id],
772
+ )
773
+ end
774
+
775
+ # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
776
+ # the spec lists a missing required standard header among the validation failures that MUST be rejected,
777
+ # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
778
+ # the headers exist so intermediaries can route without parsing bodies, and a client that omits them
779
+ # defeats that contract silently.
780
+ method_header = request.env["HTTP_MCP_METHOD"]
781
+ if method_header.to_s.empty?
782
+ return header_mismatch_response(
783
+ "Mcp-Method header is required on the modern path",
784
+ body[:id],
785
+ )
786
+ end
787
+ if method_header != body[:method]
788
+ return header_mismatch_response(
789
+ "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
790
+ body[:id],
791
+ )
792
+ end
793
+
794
+ if NAME_BEARING_METHODS.include?(body[:method])
795
+ body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
796
+ name_header = request.env["HTTP_MCP_NAME"]
797
+ if body_name
798
+ if name_header.to_s.empty?
799
+ return header_mismatch_response(
800
+ "Mcp-Name header is required for `#{body[:method]}`",
801
+ body[:id],
802
+ )
803
+ end
804
+
805
+ decoded_name = decode_header_value(name_header)
806
+ if decoded_name != body_name
807
+ return header_mismatch_response(
808
+ "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
809
+ body[:id],
810
+ )
811
+ end
812
+ end
813
+ end
814
+
815
+ nil
816
+ end
817
+
818
+ # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
819
+ # `notifications/subscriptions/acknowledged` with the subset of requested notification types
820
+ # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
821
+ # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
822
+ # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
823
+ # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
824
+ def handle_subscriptions_listen(body)
825
+ request_id = body[:id]
826
+ params = body[:params]
827
+
828
+ # A listen frame without an id could never receive stream teardown correlation.
829
+ unless request_id
830
+ return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
831
+ end
832
+
833
+ begin
834
+ if RequestEnvelope.modern?(params)
835
+ RequestEnvelope.parse!(params, request: params)
836
+ else
837
+ return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
838
+ end
839
+ rescue Server::RequestHandlerError => e
840
+ return json_rpc_error_response(
841
+ status: 400,
842
+ code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST,
843
+ message: e.message,
844
+ data: e.error_data,
845
+ id: request_id,
846
+ )
847
+ end
848
+
849
+ filter = params[:notifications]
850
+ unless filter.is_a?(Hash)
851
+ return json_rpc_error_response(
852
+ status: 400,
853
+ code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
854
+ message: "Invalid params: subscriptions/listen requires a `notifications` filter object",
855
+ id: request_id,
856
+ )
857
+ end
858
+
859
+ # Best-effort cap check before committing to the SSE response; the registration inside
860
+ # `listen_sse_body` re-checks atomically for the race between two concurrent listens
861
+ # crossing the cap together.
862
+ if listen_subscriptions_full?
863
+ return too_many_listen_subscriptions_response(request_id)
864
+ end
865
+
866
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
867
+ end
868
+
869
+ def listen_subscriptions_full?
870
+ return false unless @max_listen_subscriptions
871
+
872
+ @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions }
873
+ end
874
+
875
+ def too_many_listen_subscriptions_response(request_id)
876
+ json_rpc_error_response(
877
+ status: 503,
878
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
879
+ message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached",
880
+ id: request_id,
881
+ )
882
+ end
883
+
884
+ # The proc registers the stream and returns, leaving the response open like
885
+ # the legacy GET stream (`create_sse_body`).
886
+ def listen_sse_body(request_id, honored)
887
+ proc do |stream|
888
+ rejected = false
889
+ @mutex.synchronize do
890
+ if @listen_subscriptions.key?(request_id) ||
891
+ (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
892
+ rejected = true
893
+ else
894
+ @listen_subscriptions[request_id] = { stream: stream, filter: honored }
895
+ end
896
+ end
897
+
898
+ if rejected
899
+ close_stream_safely(stream)
900
+ else
901
+ acknowledgement = {
902
+ jsonrpc: "2.0",
903
+ method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
904
+ params: {
905
+ notifications: honored,
906
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
907
+ },
908
+ }
909
+
910
+ begin
911
+ send_to_stream(stream, acknowledgement)
912
+ start_listen_keepalive_thread(request_id)
913
+ rescue *STREAM_WRITE_ERRORS
914
+ remove_listen_subscription(request_id)
915
+ close_stream_safely(stream)
916
+ end
917
+ end
918
+ end
919
+ end
920
+
921
+ # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
922
+ # connection is detected and its slot freed, rather than held until the next fan-out write.
923
+ # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
924
+ # cannot corrupt an interleaved notification's JSON.
925
+ def start_listen_keepalive_thread(request_id)
926
+ return unless @listen_keepalive_interval
927
+
928
+ Thread.new do
929
+ while listen_subscription_active?(request_id)
930
+ sleep(@listen_keepalive_interval)
931
+ send_listen_keepalive_ping(request_id)
932
+ end
933
+ rescue *STREAM_WRITE_ERRORS
934
+ # The peer went away; the ensure frees the slot. A dropped listen stream is the normal
935
+ # way this loop ends, so it is not reported.
936
+ rescue StandardError => e
937
+ MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
938
+ ensure
939
+ stream = @mutex.synchronize do
940
+ subscription = @listen_subscriptions.delete(request_id)
941
+ subscription && subscription[:stream]
942
+ end
943
+ close_stream_safely(stream) if stream
944
+ end
945
+ end
946
+
947
+ def listen_subscription_active?(request_id)
948
+ @mutex.synchronize { @listen_subscriptions.key?(request_id) }
949
+ end
950
+
951
+ # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
952
+ # every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
953
+ def send_listen_keepalive_ping(request_id)
954
+ stream = @mutex.synchronize do
955
+ subscription = @listen_subscriptions[request_id]
956
+ subscription && subscription[:stream]
957
+ end
958
+ return unless stream
959
+
960
+ send_ping_to_stream(stream)
961
+ end
962
+
963
+ # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
964
+ # and the acknowledgement only includes types the server actually supports
965
+ # (derived from its declared capabilities).
966
+ def honored_filter(filter)
967
+ capabilities = @server.capabilities
968
+ honored = {}
969
+ honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
970
+ honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged)
971
+ honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged)
972
+
973
+ subscriptions = filter[:resourceSubscriptions]
974
+ if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
975
+ honored[:resourceSubscriptions] = subscriptions
976
+ end
977
+
978
+ honored
979
+ end
980
+
981
+ # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
982
+ # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
983
+ # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
984
+ def capability_flag?(capabilities, name, flag)
985
+ value = capabilities[name] || capabilities[name.to_s]
986
+ return false unless value.is_a?(Hash)
987
+
988
+ !!(value[flag] || value[flag.to_s])
989
+ end
990
+
991
+ # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it,
992
+ # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter
993
+ # (not the requested one) enforces the MUST NOT-send-unrequested-types rule.
994
+ def deliver_to_listen_subscriptions(method, params)
995
+ field = LISTEN_FILTER_FIELDS[method]
996
+ return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
997
+
998
+ # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
999
+ # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1000
+ matched = @mutex.synchronize do
1001
+ @listen_subscriptions.filter_map do |request_id, subscription|
1002
+ hit = if field
1003
+ subscription[:filter][field]
1004
+ else
1005
+ uris = subscription[:filter][:resourceSubscriptions]
1006
+ uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1007
+ uris.is_a?(Array) && uris.include?(uri)
1008
+ end
1009
+
1010
+ [request_id, subscription[:stream]] if hit
1011
+ end
1012
+ end
1013
+
1014
+ matched.each do |request_id, stream|
1015
+ meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1016
+ notification_params = (params || {}).merge(_meta: meta)
1017
+ notification = { jsonrpc: "2.0", method: method, params: notification_params }
1018
+
1019
+ begin
1020
+ send_to_stream(stream, notification)
1021
+ rescue *STREAM_WRITE_ERRORS => e
1022
+ MCP.configuration.exception_reporter.call(
1023
+ e,
1024
+ { subscription_id: request_id, error: "Failed to send notification" },
1025
+ )
1026
+ remove_listen_subscription(request_id)
1027
+ close_stream_safely(stream)
1028
+ end
1029
+ end
1030
+ end
1031
+
1032
+ def remove_listen_subscription(request_id)
1033
+ @mutex.synchronize { @listen_subscriptions.delete(request_id) }
1034
+ end
1035
+
1036
+ # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1037
+ # before the stream closes.
1038
+ def teardown_listen_subscriptions
1039
+ removed = @mutex.synchronize do
1040
+ subscriptions = @listen_subscriptions.dup
1041
+ @listen_subscriptions.clear
1042
+ subscriptions
1043
+ end
1044
+
1045
+ removed.each do |request_id, subscription|
1046
+ begin
1047
+ send_to_stream(subscription[:stream], {
1048
+ jsonrpc: "2.0",
1049
+ id: request_id,
1050
+ result: {
1051
+ # `SubscriptionsListenResult` is served at the transport layer and never
1052
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1053
+ # stamped at its construction site.
1054
+ resultType: ResultType::COMPLETE,
1055
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1056
+ },
1057
+ })
1058
+ rescue *STREAM_WRITE_ERRORS
1059
+ nil
1060
+ end
1061
+ close_stream_safely(subscription[:stream])
1062
+ end
1063
+ end
1064
+
1065
+ def header_mismatch_response(message, id)
1066
+ json_rpc_error_response(
1067
+ status: 400,
1068
+ code: ErrorCodes::HEADER_MISMATCH,
1069
+ message: "Header mismatch: #{message}",
1070
+ id: id,
1071
+ )
1072
+ end
1073
+
1074
+ # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
1075
+ # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
1076
+ # requires faraday, which servers do not depend on.
1077
+ def decode_header_value(value)
1078
+ match = value.match(/\A=\?base64\?(.*)\?=\z/m)
1079
+ return value unless match
1080
+
1081
+ match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
1082
+ rescue ArgumentError
1083
+ value
1084
+ end
1085
+
1086
+ # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
1087
+ # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
1088
+ # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
1089
+ # via the `session_id.nil?` branch.
1090
+ def modern_session
1091
+ ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
1092
+ end
1093
+
1094
+ def modern_http_status(response)
1095
+ error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
1096
+ if error_code.nil?
1097
+ 200
1098
+ elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
1099
+ 404
1100
+ elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
1101
+ 400
1102
+ else
1103
+ 200
1104
+ end
1105
+ end
1106
+
1107
+ def handle_post(request, body_string: nil)
461
1108
  required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
462
1109
  accept_error = validate_accept_header(request, required_types)
463
1110
  return accept_error if accept_error
@@ -465,8 +1112,10 @@ module MCP
465
1112
  content_type_error = validate_content_type(request)
466
1113
  return content_type_error if content_type_error
467
1114
 
468
- body_string = read_bounded_body(request)
469
- return payload_too_large_response if body_string.nil?
1115
+ if body_string.nil?
1116
+ body_string = read_bounded_body(request)
1117
+ return payload_too_large_response if body_string.nil?
1118
+ end
470
1119
 
471
1120
  session_id = extract_session_id(request)
472
1121
 
@@ -483,6 +1132,28 @@ module MCP
483
1132
  return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
484
1133
  end
485
1134
 
1135
+ # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
1136
+ # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
1137
+ # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
1138
+ # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
1139
+ # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
1140
+ # which violates the header/body match requirement and would fall through the legacy path via
1141
+ # the header default; reject that as a header mismatch.
1142
+ if RequestEnvelope.modern?(body[:params])
1143
+ header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
1144
+ if header_version && MCP::Configuration.modern_protocol_version?(header_version)
1145
+ return invalid_request_response(
1146
+ "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
1147
+ request_id: body[:id],
1148
+ )
1149
+ end
1150
+
1151
+ return header_mismatch_response(
1152
+ "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
1153
+ body[:id],
1154
+ )
1155
+ end
1156
+
486
1157
  # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
487
1158
  # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
488
1159
  # This matches the TypeScript and Python SDKs.
@@ -748,6 +1419,28 @@ module MCP
748
1419
  body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
749
1420
  end
750
1421
 
1422
+ # A version with no modern meaning, whose header can only accompany legacy traffic.
1423
+ # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
1424
+ # but requests of an established legacy session may stamp it - so that value needs further disambiguation.
1425
+ def stable_only_version?(version)
1426
+ MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
1427
+ !MCP::Configuration.modern_protocol_version?(version)
1428
+ end
1429
+
1430
+ # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
1431
+ # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
1432
+ # the envelope comes from a modern client naming a method its lifecycle removed, so it stays
1433
+ # on the modern path and answers with -32601/404 (SEP-2575).
1434
+ # Unparsable or non-object bodies go to the modern path, whose error responses cover them.
1435
+ def legacy_handshake_body?(body_string)
1436
+ body = parse_request_body(body_string)
1437
+ return false unless initialize_request?(body)
1438
+
1439
+ !RequestEnvelope.modern?(body[:params])
1440
+ rescue InvalidJsonError
1441
+ false
1442
+ end
1443
+
751
1444
  def validate_protocol_version_header(request)
752
1445
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
753
1446
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -760,8 +1453,10 @@ module MCP
760
1453
  )
761
1454
  end
762
1455
 
763
- def json_rpc_error_response(status:, code:, message:)
764
- body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
1456
+ def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1457
+ error = { code: code, message: message }
1458
+ error[:data] = data if data
1459
+ body = { jsonrpc: "2.0", id: id, error: error }
765
1460
  [status, { "content-type" => "application/json" }, [body.to_json]]
766
1461
  end
767
1462
 
@@ -905,6 +1600,14 @@ module MCP
905
1600
  end
906
1601
  end
907
1602
 
1603
+ # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below
1604
+ # registers this request's stream under that id first, so without this check the colliding request
1605
+ # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then
1606
+ # clear the entry the original request still needs.
1607
+ if related_request_id && server_session&.in_flight?(related_request_id)
1608
+ return request_id_conflict_response
1609
+ end
1610
+
908
1611
  if session_id && !@stateless && !@enable_json_response
909
1612
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
910
1613
  else
@@ -928,7 +1631,11 @@ module MCP
928
1631
  session = @sessions[session_id]
929
1632
  if session && related_request_id
930
1633
  session[:post_request_streams] ||= {}
931
- session[:post_request_streams][related_request_id] = stream
1634
+
1635
+ # Claim the id only while it is free. `handle_regular_request` already refused the colliding request,
1636
+ # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps
1637
+ # its messages going where they belong.
1638
+ session[:post_request_streams][related_request_id] ||= stream
932
1639
  end
933
1640
  end
934
1641
 
@@ -940,7 +1647,11 @@ module MCP
940
1647
  if related_request_id
941
1648
  @mutex.synchronize do
942
1649
  session = @sessions[session_id]
943
- session[:post_request_streams]&.delete(related_request_id) if session
1650
+ # Only retire our own registration: a request that never claimed the id, or one whose claim has
1651
+ # already been replaced, must not unregister the stream that owns it.
1652
+ registered = session&.dig(:post_request_streams, related_request_id)
1653
+
1654
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
944
1655
  end
945
1656
  end
946
1657
 
@@ -1159,6 +1870,16 @@ module MCP
1159
1870
  )
1160
1871
  end
1161
1872
 
1873
+ # The POST counterpart of the GET conflict above. A request id already in flight cannot be given
1874
+ # a stream of its own, because the id is what routes request-scoped messages back.
1875
+ def request_id_conflict_response
1876
+ json_rpc_error_response(
1877
+ status: 409,
1878
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1879
+ message: "Conflict: Request id is already in flight for this session",
1880
+ )
1881
+ end
1882
+
1162
1883
  def setup_sse_stream(session_id)
1163
1884
  body = create_sse_body(session_id)
1164
1885