mcp 1.0.0 → 1.2.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
@@ -299,14 +467,28 @@ module MCP
299
467
  end
300
468
  end
301
469
 
302
- # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
303
- # blocks until the client responds.
470
+ # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
471
+ # the client responds.
472
+ #
473
+ # Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
474
+ # sends the request via SSE stream, then waits on it. When the client POSTs a response,
475
+ # `handle_response` matches it by `request_id` and resolves the pending response,
476
+ # unblocking this thread. A cancellation and session teardown resolve it the same way.
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
+ # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
479
+ # so a client that never answers cannot park the calling thread for good. On expiry the peer is
480
+ # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
481
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
482
+ # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
483
+ # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached
484
+ # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`,
485
+ # but that is incidental: without the rule stated here a modern handler that reaches for elicitation
486
+ # or sampling is told "Session not found: <uuid>", which points at everything except the actual reason.
487
+ # `StdioTransport#send_request` refuses the same way.
488
+ if server_session&.era == :modern
489
+ raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
490
+ end
491
+
310
492
  if @stateless
311
493
  raise "Stateless mode does not support server-to-client requests."
312
494
  end
@@ -320,7 +502,8 @@ module MCP
320
502
  end
321
503
 
322
504
  request_id = generate_request_id
323
- queue = Queue.new
505
+ pending_response = PendingResponse.new
506
+ wait_timeout = timeout || @server_to_client_request_timeout
324
507
  cancel_hook = nil
325
508
 
326
509
  request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -333,7 +516,7 @@ module MCP
333
516
  raise "Session not found: #{session_id}."
334
517
  end
335
518
 
336
- @pending_responses[request_id] = { queue: queue, session_id: session_id }
519
+ @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
337
520
 
338
521
  active_stream(session, related_request_id: related_request_id)
339
522
  end
@@ -369,7 +552,27 @@ module MCP
369
552
  end
370
553
  end
371
554
 
372
- response = queue.pop
555
+ response = pending_response.pop(timeout: wait_timeout) do
556
+ # Expiry cancels as well as stops waiting, so a client that answers late does not act on
557
+ # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
558
+ # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
559
+ # never register the session this method looks up. Those revisions ask the sender to "issue
560
+ # a cancellation notification for that request and stop waiting", letting either side send one.
561
+ # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
562
+ # governs the era that has no such requests to cancel.) Both reference SDKs send this same
563
+ # courtesy cancel on timeout.
564
+ server_session&.send_peer_cancellation(
565
+ nested_request_id: request_id,
566
+ related_request_id: related_request_id,
567
+ reason: "Timed out after #{wait_timeout} seconds",
568
+ )
569
+
570
+ raise RequestTimeoutError.new(
571
+ "#{method} request timed out after #{wait_timeout} seconds",
572
+ request_id: request_id,
573
+ timeout: wait_timeout,
574
+ )
575
+ end
373
576
 
374
577
  if response.is_a?(Hash) && response.key?(:error)
375
578
  raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
@@ -457,7 +660,446 @@ module MCP
457
660
  stream.flush
458
661
  end
459
662
 
460
- def handle_post(request)
663
+ # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
664
+ # a single POST/JSON exchange with no session. The modern path never consults
665
+ # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
666
+ # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
667
+ # and DELETE (session termination) have no modern meaning.
668
+ def handle_modern(request, header_version, body_string: nil)
669
+ return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
670
+
671
+ accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
672
+ return accept_error if accept_error
673
+
674
+ content_type_error = validate_content_type(request)
675
+ return content_type_error if content_type_error
676
+
677
+ if body_string.nil?
678
+ body_string = read_bounded_body(request)
679
+ return payload_too_large_response if body_string.nil?
680
+ end
681
+
682
+ begin
683
+ body = parse_request_body(body_string)
684
+ rescue InvalidJsonError
685
+ return invalid_json_response
686
+ end
687
+
688
+ unless body.is_a?(Hash)
689
+ return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
690
+ end
691
+
692
+ # The version check precedes everything else that depends on request content,
693
+ # so a client probing with an unknown future version always receives the `-32022` signal
694
+ # (with the supported list to select from) rather than an incidental error.
695
+ unless MCP::Configuration.modern_protocol_version?(header_version)
696
+ return json_rpc_error_response(
697
+ status: 400,
698
+ code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
699
+ message: "Unsupported protocol version",
700
+ data: {
701
+ supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
702
+ requested: header_version,
703
+ },
704
+ id: body[:id],
705
+ )
706
+ end
707
+
708
+ if extract_session_id(request)
709
+ return json_rpc_error_response(
710
+ status: 400,
711
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
712
+ message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
713
+ id: body[:id],
714
+ )
715
+ end
716
+
717
+ mismatch_error = validate_modern_headers(request, body, header_version)
718
+ return mismatch_error if mismatch_error
719
+
720
+ # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
721
+ # it never dispatches through `Server#handle`.
722
+ return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
723
+
724
+ session = modern_session
725
+ notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
726
+ begin
727
+ response = @server.handle(body, session: session)
728
+ ensure
729
+ @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
730
+ end
731
+
732
+ # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
733
+ return handle_accepted if response.nil?
734
+
735
+ # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
736
+ # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
737
+ # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
738
+ # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
739
+ # Live streaming can follow without changing the wire shape.
740
+ if notifications.empty?
741
+ [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
742
+ else
743
+ frames = notifications + [response]
744
+ sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
745
+ [200, SSE_HEADERS.dup, [sse_body]]
746
+ end
747
+ rescue StandardError => e
748
+ MCP.configuration.exception_reporter.call(e, { request: body_string })
749
+ json_rpc_error_response(
750
+ status: 500,
751
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
752
+ message: "Internal server error",
753
+ )
754
+ end
755
+
756
+ # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
757
+ # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
758
+ # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
759
+ # converge on enforcement.
760
+ def validate_modern_headers(request, body, header_version)
761
+ params = body[:params]
762
+ meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
763
+ if meta_version && meta_version != header_version
764
+ return header_mismatch_response(
765
+ "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
766
+ body[:id],
767
+ )
768
+ end
769
+
770
+ # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
771
+ # the spec lists a missing required standard header among the validation failures that MUST be rejected,
772
+ # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
773
+ # the headers exist so intermediaries can route without parsing bodies, and a client that omits them
774
+ # defeats that contract silently.
775
+ method_header = request.env["HTTP_MCP_METHOD"]
776
+ if method_header.to_s.empty?
777
+ return header_mismatch_response(
778
+ "Mcp-Method header is required on the modern path",
779
+ body[:id],
780
+ )
781
+ end
782
+ if method_header != body[:method]
783
+ return header_mismatch_response(
784
+ "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
785
+ body[:id],
786
+ )
787
+ end
788
+
789
+ if NAME_BEARING_METHODS.include?(body[:method])
790
+ body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
791
+ name_header = request.env["HTTP_MCP_NAME"]
792
+ if body_name
793
+ if name_header.to_s.empty?
794
+ return header_mismatch_response(
795
+ "Mcp-Name header is required for `#{body[:method]}`",
796
+ body[:id],
797
+ )
798
+ end
799
+
800
+ decoded_name = decode_header_value(name_header)
801
+ if decoded_name != body_name
802
+ return header_mismatch_response(
803
+ "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
804
+ body[:id],
805
+ )
806
+ end
807
+ end
808
+ end
809
+
810
+ nil
811
+ end
812
+
813
+ # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
814
+ # `notifications/subscriptions/acknowledged` with the subset of requested notification types
815
+ # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
816
+ # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
817
+ # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
818
+ # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
819
+ def handle_subscriptions_listen(body)
820
+ request_id = body[:id]
821
+ params = body[:params]
822
+
823
+ # A listen frame without an id could never receive stream teardown correlation.
824
+ unless request_id
825
+ return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
826
+ end
827
+
828
+ begin
829
+ if RequestEnvelope.modern?(params)
830
+ RequestEnvelope.parse!(params, request: params)
831
+ else
832
+ return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
833
+ end
834
+ rescue Server::RequestHandlerError => e
835
+ return json_rpc_error_response(
836
+ status: 400,
837
+ code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST,
838
+ message: e.message,
839
+ data: e.error_data,
840
+ id: request_id,
841
+ )
842
+ end
843
+
844
+ filter = params[:notifications]
845
+ unless filter.is_a?(Hash)
846
+ return json_rpc_error_response(
847
+ status: 400,
848
+ code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
849
+ message: "Invalid params: subscriptions/listen requires a `notifications` filter object",
850
+ id: request_id,
851
+ )
852
+ end
853
+
854
+ # Best-effort cap check before committing to the SSE response; the registration inside
855
+ # `listen_sse_body` re-checks atomically for the race between two concurrent listens
856
+ # crossing the cap together.
857
+ if listen_subscriptions_full?
858
+ return too_many_listen_subscriptions_response(request_id)
859
+ end
860
+
861
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
862
+ end
863
+
864
+ def listen_subscriptions_full?
865
+ return false unless @max_listen_subscriptions
866
+
867
+ @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions }
868
+ end
869
+
870
+ def too_many_listen_subscriptions_response(request_id)
871
+ json_rpc_error_response(
872
+ status: 503,
873
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
874
+ message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached",
875
+ id: request_id,
876
+ )
877
+ end
878
+
879
+ # The proc registers the stream and returns, leaving the response open like
880
+ # the legacy GET stream (`create_sse_body`).
881
+ def listen_sse_body(request_id, honored)
882
+ proc do |stream|
883
+ rejected = false
884
+ @mutex.synchronize do
885
+ if @listen_subscriptions.key?(request_id) ||
886
+ (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
887
+ rejected = true
888
+ else
889
+ @listen_subscriptions[request_id] = { stream: stream, filter: honored }
890
+ end
891
+ end
892
+
893
+ if rejected
894
+ close_stream_safely(stream)
895
+ else
896
+ acknowledgement = {
897
+ jsonrpc: "2.0",
898
+ method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
899
+ params: {
900
+ notifications: honored,
901
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
902
+ },
903
+ }
904
+
905
+ begin
906
+ send_to_stream(stream, acknowledgement)
907
+ start_listen_keepalive_thread(request_id)
908
+ rescue *STREAM_WRITE_ERRORS
909
+ remove_listen_subscription(request_id)
910
+ close_stream_safely(stream)
911
+ end
912
+ end
913
+ end
914
+ end
915
+
916
+ # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
917
+ # connection is detected and its slot freed, rather than held until the next fan-out write.
918
+ # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
919
+ # cannot corrupt an interleaved notification's JSON.
920
+ def start_listen_keepalive_thread(request_id)
921
+ return unless @listen_keepalive_interval
922
+
923
+ Thread.new do
924
+ while listen_subscription_active?(request_id)
925
+ sleep(@listen_keepalive_interval)
926
+ send_listen_keepalive_ping(request_id)
927
+ end
928
+ rescue *STREAM_WRITE_ERRORS
929
+ # The peer went away; the ensure frees the slot. A dropped listen stream is the normal
930
+ # way this loop ends, so it is not reported.
931
+ rescue StandardError => e
932
+ MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
933
+ ensure
934
+ stream = @mutex.synchronize do
935
+ subscription = @listen_subscriptions.delete(request_id)
936
+ subscription && subscription[:stream]
937
+ end
938
+ close_stream_safely(stream) if stream
939
+ end
940
+ end
941
+
942
+ def listen_subscription_active?(request_id)
943
+ @mutex.synchronize { @listen_subscriptions.key?(request_id) }
944
+ end
945
+
946
+ # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
947
+ # every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
948
+ def send_listen_keepalive_ping(request_id)
949
+ stream = @mutex.synchronize do
950
+ subscription = @listen_subscriptions[request_id]
951
+ subscription && subscription[:stream]
952
+ end
953
+ return unless stream
954
+
955
+ send_ping_to_stream(stream)
956
+ end
957
+
958
+ # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
959
+ # and the acknowledgement only includes types the server actually supports
960
+ # (derived from its declared capabilities).
961
+ def honored_filter(filter)
962
+ capabilities = @server.capabilities
963
+ honored = {}
964
+ honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
965
+ honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged)
966
+ honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged)
967
+
968
+ subscriptions = filter[:resourceSubscriptions]
969
+ if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
970
+ honored[:resourceSubscriptions] = subscriptions
971
+ end
972
+
973
+ honored
974
+ end
975
+
976
+ # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
977
+ # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
978
+ # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
979
+ def capability_flag?(capabilities, name, flag)
980
+ value = capabilities[name] || capabilities[name.to_s]
981
+ return false unless value.is_a?(Hash)
982
+
983
+ !!(value[flag] || value[flag.to_s])
984
+ end
985
+
986
+ # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it,
987
+ # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter
988
+ # (not the requested one) enforces the MUST NOT-send-unrequested-types rule.
989
+ def deliver_to_listen_subscriptions(method, params)
990
+ field = LISTEN_FILTER_FIELDS[method]
991
+ return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
992
+
993
+ # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
994
+ # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
995
+ matched = @mutex.synchronize do
996
+ @listen_subscriptions.filter_map do |request_id, subscription|
997
+ hit = if field
998
+ subscription[:filter][field]
999
+ else
1000
+ uris = subscription[:filter][:resourceSubscriptions]
1001
+ uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1002
+ uris.is_a?(Array) && uris.include?(uri)
1003
+ end
1004
+
1005
+ [request_id, subscription[:stream]] if hit
1006
+ end
1007
+ end
1008
+
1009
+ matched.each do |request_id, stream|
1010
+ meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1011
+ notification_params = (params || {}).merge(_meta: meta)
1012
+ notification = { jsonrpc: "2.0", method: method, params: notification_params }
1013
+
1014
+ begin
1015
+ send_to_stream(stream, notification)
1016
+ rescue *STREAM_WRITE_ERRORS => e
1017
+ MCP.configuration.exception_reporter.call(
1018
+ e,
1019
+ { subscription_id: request_id, error: "Failed to send notification" },
1020
+ )
1021
+ remove_listen_subscription(request_id)
1022
+ close_stream_safely(stream)
1023
+ end
1024
+ end
1025
+ end
1026
+
1027
+ def remove_listen_subscription(request_id)
1028
+ @mutex.synchronize { @listen_subscriptions.delete(request_id) }
1029
+ end
1030
+
1031
+ # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1032
+ # before the stream closes.
1033
+ def teardown_listen_subscriptions
1034
+ removed = @mutex.synchronize do
1035
+ subscriptions = @listen_subscriptions.dup
1036
+ @listen_subscriptions.clear
1037
+ subscriptions
1038
+ end
1039
+
1040
+ removed.each do |request_id, subscription|
1041
+ begin
1042
+ send_to_stream(subscription[:stream], {
1043
+ jsonrpc: "2.0",
1044
+ id: request_id,
1045
+ result: {
1046
+ # `SubscriptionsListenResult` is served at the transport layer and never
1047
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1048
+ # stamped at its construction site.
1049
+ resultType: ResultType::COMPLETE,
1050
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1051
+ },
1052
+ })
1053
+ rescue *STREAM_WRITE_ERRORS
1054
+ nil
1055
+ end
1056
+ close_stream_safely(subscription[:stream])
1057
+ end
1058
+ end
1059
+
1060
+ def header_mismatch_response(message, id)
1061
+ json_rpc_error_response(
1062
+ status: 400,
1063
+ code: ErrorCodes::HEADER_MISMATCH,
1064
+ message: "Header mismatch: #{message}",
1065
+ id: id,
1066
+ )
1067
+ end
1068
+
1069
+ # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
1070
+ # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
1071
+ # requires faraday, which servers do not depend on.
1072
+ def decode_header_value(value)
1073
+ match = value.match(/\A=\?base64\?(.*)\?=\z/m)
1074
+ return value unless match
1075
+
1076
+ match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
1077
+ rescue ArgumentError
1078
+ value
1079
+ end
1080
+
1081
+ # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
1082
+ # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
1083
+ # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
1084
+ # via the `session_id.nil?` branch.
1085
+ def modern_session
1086
+ ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
1087
+ end
1088
+
1089
+ def modern_http_status(response)
1090
+ error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
1091
+ if error_code.nil?
1092
+ 200
1093
+ elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
1094
+ 404
1095
+ elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
1096
+ 400
1097
+ else
1098
+ 200
1099
+ end
1100
+ end
1101
+
1102
+ def handle_post(request, body_string: nil)
461
1103
  required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
462
1104
  accept_error = validate_accept_header(request, required_types)
463
1105
  return accept_error if accept_error
@@ -465,8 +1107,10 @@ module MCP
465
1107
  content_type_error = validate_content_type(request)
466
1108
  return content_type_error if content_type_error
467
1109
 
468
- body_string = read_bounded_body(request)
469
- return payload_too_large_response if body_string.nil?
1110
+ if body_string.nil?
1111
+ body_string = read_bounded_body(request)
1112
+ return payload_too_large_response if body_string.nil?
1113
+ end
470
1114
 
471
1115
  session_id = extract_session_id(request)
472
1116
 
@@ -483,6 +1127,28 @@ module MCP
483
1127
  return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
484
1128
  end
485
1129
 
1130
+ # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
1131
+ # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
1132
+ # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
1133
+ # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
1134
+ # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
1135
+ # which violates the header/body match requirement and would fall through the legacy path via
1136
+ # the header default; reject that as a header mismatch.
1137
+ if RequestEnvelope.modern?(body[:params])
1138
+ header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
1139
+ if header_version && MCP::Configuration.modern_protocol_version?(header_version)
1140
+ return invalid_request_response(
1141
+ "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
1142
+ request_id: body[:id],
1143
+ )
1144
+ end
1145
+
1146
+ return header_mismatch_response(
1147
+ "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
1148
+ body[:id],
1149
+ )
1150
+ end
1151
+
486
1152
  # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
487
1153
  # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
488
1154
  # This matches the TypeScript and Python SDKs.
@@ -748,6 +1414,28 @@ module MCP
748
1414
  body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
749
1415
  end
750
1416
 
1417
+ # A version with no modern meaning, whose header can only accompany legacy traffic.
1418
+ # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
1419
+ # but requests of an established legacy session may stamp it - so that value needs further disambiguation.
1420
+ def stable_only_version?(version)
1421
+ MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
1422
+ !MCP::Configuration.modern_protocol_version?(version)
1423
+ end
1424
+
1425
+ # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
1426
+ # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
1427
+ # the envelope comes from a modern client naming a method its lifecycle removed, so it stays
1428
+ # on the modern path and answers with -32601/404 (SEP-2575).
1429
+ # Unparsable or non-object bodies go to the modern path, whose error responses cover them.
1430
+ def legacy_handshake_body?(body_string)
1431
+ body = parse_request_body(body_string)
1432
+ return false unless initialize_request?(body)
1433
+
1434
+ !RequestEnvelope.modern?(body[:params])
1435
+ rescue InvalidJsonError
1436
+ false
1437
+ end
1438
+
751
1439
  def validate_protocol_version_header(request)
752
1440
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
753
1441
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -760,8 +1448,10 @@ module MCP
760
1448
  )
761
1449
  end
762
1450
 
763
- def json_rpc_error_response(status:, code:, message:)
764
- body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
1451
+ def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1452
+ error = { code: code, message: message }
1453
+ error[:data] = data if data
1454
+ body = { jsonrpc: "2.0", id: id, error: error }
765
1455
  [status, { "content-type" => "application/json" }, [body.to_json]]
766
1456
  end
767
1457