mcp 0.22.0 → 0.24.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.
@@ -24,17 +24,97 @@ module MCP
24
24
  "Connection" => "keep-alive",
25
25
  }.freeze
26
26
 
27
- def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
27
+ # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
28
+ # so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted.
29
+ # These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that
30
+ # hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport
31
+ # rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts
32
+ # the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on
33
+ # an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to
34
+ # opt out of expiry and `max_sessions: nil` to opt out of the cap.
35
+ DEFAULT_SESSION_IDLE_TIMEOUT = 1800
36
+ DEFAULT_MAX_SESSIONS = 10_000
37
+
38
+ # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
39
+ UNSET_IDLE_TIMEOUT = Object.new.freeze
40
+ private_constant :UNSET_IDLE_TIMEOUT
41
+
42
+ # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
43
+ # body into memory and parses it, so without a cap a single unauthenticated POST
44
+ # can allocate gigabytes and OOM the worker. 4 MiB comfortably
45
+ # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
46
+ # payload); raise `max_request_bytes:` for unusually large payloads. Matches the
47
+ # TypeScript SDK's 4 MB default.
48
+ DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
49
+
50
+ # Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
51
+ # the stack or amplify parse cost (complements the byte cap).
52
+ MAX_JSON_NESTING = 64
53
+
54
+ # Creates a Streamable HTTP transport that can be mounted as a Rack app.
55
+ #
56
+ # @param server [MCP::Server] the server whose requests this transport dispatches.
57
+ # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
58
+ # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
59
+ # object instead of an SSE stream.
60
+ # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults
61
+ # to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables
62
+ # expiry. Not supported in stateless mode.
63
+ # @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new
64
+ # `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap.
65
+ # @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
66
+ # same-origin requests, for DNS rebinding protection.
67
+ # @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
68
+ # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
69
+ # or a full `host:port`.
70
+ # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
71
+ # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
72
+ # validates them.
73
+ # @param session_request_validator [#call, nil] An optional
74
+ # `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE
75
+ # against an existing session (regular requests, notifications, and client responses alike).
76
+ # Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid`
77
+ # session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is
78
+ # the deploying application's responsibility (the transport never receives the authenticated identity
79
+ # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
80
+ # ownership is not enforced.
81
+ # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
82
+ # requests are rejected with HTTP 413. Defaults to 4 MiB.
83
+ def initialize(
84
+ server,
85
+ stateless: false,
86
+ enable_json_response: false,
87
+ session_idle_timeout: UNSET_IDLE_TIMEOUT,
88
+ max_sessions: DEFAULT_MAX_SESSIONS,
89
+ allowed_origins: nil,
90
+ allowed_hosts: nil,
91
+ dns_rebinding_protection: true,
92
+ session_request_validator: nil,
93
+ max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
94
+ )
28
95
  super(server)
29
- # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
96
+ # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
30
97
  @sessions = {}
31
98
  @mutex = Mutex.new
32
99
 
33
100
  @stateless = stateless
34
101
  @enable_json_response = enable_json_response
35
- @session_idle_timeout = session_idle_timeout
102
+ @session_request_validator = session_request_validator
103
+ @dns_rebinding_protection = dns_rebinding_protection
104
+
105
+ # Host names are case-insensitive, so the allow lists are compared down-cased.
106
+ @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
107
+ @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
36
108
  @pending_responses = {}
37
109
 
110
+ # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
111
+ # which does not apply to stateless mode since it retains no sessions.
112
+ @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
113
+ stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT
114
+ else
115
+ session_idle_timeout
116
+ end
117
+
38
118
  if @session_idle_timeout
39
119
  if @stateless
40
120
  raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
@@ -43,6 +123,19 @@ module MCP
43
123
  end
44
124
  end
45
125
 
126
+ unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0)
127
+ raise ArgumentError, "max_sessions must be a positive Integer or nil"
128
+ end
129
+
130
+ # The cap guards the stateful session store; stateless mode keeps none.
131
+ @max_sessions = stateless ? nil : max_sessions
132
+
133
+ unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0
134
+ raise ArgumentError, "max_request_bytes must be a positive Integer"
135
+ end
136
+
137
+ @max_request_bytes = max_request_bytes
138
+
46
139
  start_reaper_thread if @session_idle_timeout
47
140
  end
48
141
 
@@ -52,12 +145,19 @@ module MCP
52
145
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
53
146
  SESSION_REAP_INTERVAL = 60
54
147
 
148
+ # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
149
+ # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
150
+ DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
151
+
55
152
  # Rack app interface. This transport can be mounted as a Rack app.
56
153
  def call(env)
57
154
  handle_request(Rack::Request.new(env))
58
155
  end
59
156
 
60
157
  def handle_request(request)
158
+ rebinding_error = validate_dns_rebinding(request)
159
+ return rebinding_error if rebinding_error
160
+
61
161
  case request.env["REQUEST_METHOD"]
62
162
  when "POST"
63
163
  handle_post(request)
@@ -96,80 +196,107 @@ module MCP
96
196
  }
97
197
  notification[:params] = params if params
98
198
 
199
+ if session_id
200
+ deliver_targeted_notification(notification, session_id, related_request_id)
201
+ else
202
+ deliver_broadcast_notification(notification)
203
+ end
204
+ end
205
+
206
+ def deliver_targeted_notification(notification, session_id, related_request_id)
207
+ # JSON response mode returns a single JSON object as the POST response,
208
+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
209
+ # alongside it. Session-scoped standalone notifications
210
+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
211
+ return false if @enable_json_response && related_request_id
212
+
99
213
  streams_to_close = []
100
214
 
101
- result = @mutex.synchronize do
102
- if session_id
103
- # JSON response mode returns a single JSON object as the POST response,
104
- # so request-scoped notifications (e.g. progress, log) cannot be delivered
105
- # alongside it. Session-scoped standalone notifications
106
- # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
107
- next false if @enable_json_response && related_request_id
108
-
109
- # Send to specific session
110
- if (session = @sessions[session_id])
111
- stream = active_stream(session, related_request_id: related_request_id)
112
- end
113
- next false unless stream
215
+ # Resolve the target stream under the lock, then write outside it: a stalled SSE reader
216
+ # must not block every other session that needs `@mutex`.
217
+ stream = @mutex.synchronize do
218
+ next unless (session = @sessions[session_id])
219
+
220
+ if session_expired?(session)
221
+ cleanup_and_collect_stream(session_id, streams_to_close)
222
+ next
223
+ end
224
+
225
+ active_stream(session, related_request_id: related_request_id)
226
+ end
227
+
228
+ close_streams(streams_to_close)
229
+ return false unless stream
230
+
231
+ write_notification(stream, notification, session_id, related_request_id)
232
+ end
233
+
234
+ def deliver_broadcast_notification(notification)
235
+ streams_to_close = []
236
+
237
+ # Snapshot the connected streams under the lock, then write to each outside it.
238
+ targets = @mutex.synchronize do
239
+ expired_session_ids = []
240
+
241
+ collected = @sessions.filter_map do |session_id, session|
242
+ next unless (stream = session[:get_sse_stream])
114
243
 
115
244
  if session_expired?(session)
116
- cleanup_and_collect_stream(session_id, streams_to_close)
117
- next false
245
+ expired_session_ids << session_id
246
+ next
118
247
  end
119
248
 
120
- begin
121
- send_to_stream(stream, notification)
122
- true
123
- rescue *STREAM_WRITE_ERRORS => e
124
- MCP.configuration.exception_reporter.call(
125
- e,
126
- { session_id: session_id, error: "Failed to send notification" },
127
- )
128
- if related_request_id && session[:post_request_streams]&.key?(related_request_id)
129
- session[:post_request_streams].delete(related_request_id)
130
- streams_to_close << stream
131
- else
132
- cleanup_and_collect_stream(session_id, streams_to_close)
133
- end
134
- false
135
- end
136
- else
137
- # Broadcast to all connected SSE sessions
138
- sent_count = 0
139
- failed_sessions = []
249
+ [session_id, stream]
250
+ end
140
251
 
141
- @sessions.each do |sid, session|
142
- next unless (stream = session[:get_sse_stream])
252
+ expired_session_ids.each do |session_id|
253
+ cleanup_and_collect_stream(session_id, streams_to_close)
254
+ end
143
255
 
144
- if session_expired?(session)
145
- failed_sessions << sid
146
- next
147
- end
256
+ collected
257
+ end
148
258
 
149
- begin
150
- send_to_stream(stream, notification)
151
- sent_count += 1
152
- rescue *STREAM_WRITE_ERRORS => e
153
- MCP.configuration.exception_reporter.call(
154
- e,
155
- { session_id: sid, error: "Failed to send notification" },
156
- )
157
- failed_sessions << sid
158
- end
159
- end
259
+ close_streams(streams_to_close)
260
+
261
+ targets.count do |session_id, stream|
262
+ write_notification(stream, notification, session_id, nil)
263
+ end
264
+ end
265
+
266
+ # Writes a notification to an SSE stream without holding `@mutex`. On a write error,
267
+ # drops the broken stream and returns false; on success returns true.
268
+ def write_notification(stream, notification, session_id, related_request_id)
269
+ send_to_stream(stream, notification)
270
+ true
271
+ rescue *STREAM_WRITE_ERRORS => e
272
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
273
+ drop_broken_stream(session_id, stream, related_request_id)
274
+ false
275
+ end
160
276
 
161
- # Clean up failed sessions
162
- failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
277
+ # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
278
+ # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
279
+ # under `@mutex`, and the affected streams are closed outside it.
280
+ def drop_broken_stream(session_id, stream, related_request_id)
281
+ streams_to_close = []
163
282
 
164
- sent_count
283
+ @mutex.synchronize do
284
+ 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)
287
+ streams_to_close << stream
288
+ else
289
+ cleanup_and_collect_stream(session_id, streams_to_close)
165
290
  end
166
291
  end
167
292
 
168
- streams_to_close.each do |stream|
293
+ close_streams(streams_to_close)
294
+ end
295
+
296
+ def close_streams(streams)
297
+ streams.each do |stream|
169
298
  close_stream_safely(stream)
170
299
  end
171
-
172
- result
173
300
  end
174
301
 
175
302
  # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
@@ -199,27 +326,25 @@ module MCP
199
326
  request = { jsonrpc: "2.0", id: request_id, method: method }
200
327
  request[:params] = params if params
201
328
 
202
- sent = false
203
-
204
- @mutex.synchronize do
329
+ # Register the pending response and resolve the stream under the lock, but perform
330
+ # the write outside it so a stalled reader cannot block every other session on `@mutex`.
331
+ stream = @mutex.synchronize do
205
332
  unless (session = @sessions[session_id])
206
333
  raise "Session not found: #{session_id}."
207
334
  end
208
335
 
209
336
  @pending_responses[request_id] = { queue: queue, session_id: session_id }
210
337
 
211
- if (stream = active_stream(session, related_request_id: related_request_id))
212
- begin
213
- send_to_stream(stream, request)
214
- sent = true
215
- rescue *STREAM_WRITE_ERRORS
216
- if related_request_id && session[:post_request_streams]&.key?(related_request_id)
217
- session[:post_request_streams].delete(related_request_id)
218
- close_stream_safely(stream)
219
- else
220
- cleanup_session_unsafe(session_id)
221
- end
222
- end
338
+ active_stream(session, related_request_id: related_request_id)
339
+ end
340
+
341
+ sent = false
342
+ if stream
343
+ begin
344
+ send_to_stream(stream, request)
345
+ sent = true
346
+ rescue *STREAM_WRITE_ERRORS
347
+ drop_broken_stream(session_id, stream, related_request_id)
223
348
  end
224
349
  end
225
350
 
@@ -340,7 +465,9 @@ module MCP
340
465
  content_type_error = validate_content_type(request)
341
466
  return content_type_error if content_type_error
342
467
 
343
- body_string = request.body.read
468
+ body_string = read_bounded_body(request)
469
+ return payload_too_large_response if body_string.nil?
470
+
344
471
  session_id = extract_session_id(request)
345
472
 
346
473
  begin
@@ -359,7 +486,9 @@ module MCP
359
486
  # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
360
487
  # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
361
488
  # This matches the TypeScript and Python SDKs.
362
- unless initialize_request?(body)
489
+ # `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that
490
+ # happens before (or instead of) negotiation.
491
+ unless initialize_request?(body) || discover_request?(body)
363
492
  return missing_session_id_response if !@stateless && !session_id
364
493
 
365
494
  protocol_version_error = validate_protocol_version_header(request)
@@ -378,16 +507,25 @@ module MCP
378
507
  return session_not_found_response
379
508
  end
380
509
 
381
- handle_initialization(body_string, body)
382
- elsif notification?(body)
383
- dispatch_notification(body_string, session_id)
384
- handle_accepted
385
- elsif response?(body)
386
- return session_not_found_response if !@stateless && !session_exists?(session_id)
387
-
388
- handle_response(body, session_id: session_id)
510
+ handle_initialization(request, body_string, body)
389
511
  else
390
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
512
+ # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
513
+ # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
514
+ # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
515
+ if !@stateless && session_id && !validate_session_request(request, session_id)
516
+ return forbidden_response
517
+ end
518
+
519
+ if notification?(body)
520
+ dispatch_notification(body_string, session_id)
521
+ handle_accepted
522
+ elsif response?(body)
523
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
524
+
525
+ handle_response(body, session_id: session_id)
526
+ else
527
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
528
+ end
391
529
  end
392
530
  rescue StandardError => e
393
531
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -412,6 +550,7 @@ module MCP
412
550
 
413
551
  error_response = validate_and_touch_session(session_id)
414
552
  return error_response if error_response
553
+ return forbidden_response unless validate_session_request(request, session_id)
415
554
 
416
555
  protocol_version_error = validate_protocol_version_header(request)
417
556
  return protocol_version_error if protocol_version_error
@@ -434,6 +573,7 @@ module MCP
434
573
 
435
574
  return missing_session_id_response unless (session_id = extract_session_id(request))
436
575
  return session_not_found_response unless session_exists?(session_id)
576
+ return forbidden_response unless validate_session_request(request, session_id)
437
577
 
438
578
  protocol_version_error = validate_protocol_version_header(request)
439
579
  return protocol_version_error if protocol_version_error
@@ -495,6 +635,27 @@ module MCP
495
635
  request.env["HTTP_MCP_SESSION_ID"]
496
636
  end
497
637
 
638
+ # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
639
+ # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
640
+ # Two layers, both returning `false` to trigger a 403:
641
+ #
642
+ # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
643
+ # at `initialize` and this request carries a different one, reject. Both must be present to compare,
644
+ # so non-browser clients that send no `Origin` are unaffected.
645
+ # - The application-supplied `session_request_validator`, which can enforce true ownership when it has
646
+ # an authenticated principal.
647
+ def validate_session_request(request, session_id)
648
+ session = @mutex.synchronize { @sessions[session_id] }
649
+ return true unless session
650
+
651
+ session_origin = session[:origin]
652
+ request_origin = request.env["HTTP_ORIGIN"]
653
+ return false if session_origin && request_origin && session_origin != request_origin
654
+ return @session_request_validator.call(request, session_id) if @session_request_validator
655
+
656
+ true
657
+ end
658
+
498
659
  def validate_accept_header(request, required_types)
499
660
  accept_header = request.env["HTTP_ACCEPT"]
500
661
  return not_acceptable_response(required_types) unless accept_header
@@ -534,8 +695,34 @@ module MCP
534
695
  )
535
696
  end
536
697
 
698
+ # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
699
+ # memory. A declared `Content-Length` over the cap is rejected
700
+ # without reading; the actual read is also bounded to one byte past the cap, so
701
+ # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
702
+ # Returns `nil` when the body exceeds the cap.
703
+ def read_bounded_body(request)
704
+ content_length = request.content_length
705
+ return if content_length && content_length.to_i > @max_request_bytes
706
+
707
+ body = request.body.read(@max_request_bytes + 1)
708
+ return "" if body.nil?
709
+ return if body.bytesize > @max_request_bytes
710
+
711
+ body
712
+ end
713
+
714
+ def payload_too_large_response
715
+ json_rpc_error_response(
716
+ status: 413,
717
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
718
+ message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
719
+ )
720
+ end
721
+
537
722
  def parse_request_body(body_string)
538
- JSON.parse(body_string, symbolize_names: true)
723
+ # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
724
+ # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
725
+ JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
539
726
  rescue JSON::ParserError, TypeError
540
727
  raise InvalidJsonError
541
728
  end
@@ -552,6 +739,10 @@ module MCP
552
739
  body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
553
740
  end
554
741
 
742
+ def discover_request?(body)
743
+ body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
744
+ end
745
+
555
746
  def validate_protocol_version_header(request)
556
747
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
557
748
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -613,7 +804,7 @@ module MCP
613
804
  handle_accepted
614
805
  end
615
806
 
616
- def handle_initialization(body_string, body)
807
+ def handle_initialization(request, body_string, body)
617
808
  session_id = nil
618
809
 
619
810
  if @stateless
@@ -622,13 +813,33 @@ module MCP
622
813
  session_id = SecureRandom.uuid
623
814
  server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
624
815
 
625
- @mutex.synchronize do
816
+ # Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
817
+ # The check and insert share the mutex so concurrent initializes cannot race past the limit.
818
+ reclaimed = []
819
+ inserted = @mutex.synchronize do
820
+ # When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
821
+ # so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
822
+ if @max_sessions && @sessions.size >= @max_sessions
823
+ @sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
824
+ cleanup_and_collect_stream(id, reclaimed)
825
+ end
826
+ end
827
+
828
+ next false if @max_sessions && @sessions.size >= @max_sessions
829
+
626
830
  @sessions[session_id] = {
627
831
  get_sse_stream: nil,
628
832
  server_session: server_session,
629
833
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
834
+ # Captured for the built-in Origin-consistency defense in `validate_session_request`.
835
+ # Not authentication.
836
+ origin: request.env["HTTP_ORIGIN"],
630
837
  }
838
+ true
631
839
  end
840
+
841
+ reclaimed.each { |stream| close_stream_safely(stream) }
842
+ return too_many_sessions_response unless inserted
632
843
  end
633
844
 
634
845
  response = server_session.handle_json(body_string)
@@ -655,6 +866,14 @@ module MCP
655
866
  [202, {}, []]
656
867
  end
657
868
 
869
+ def too_many_sessions_response
870
+ json_rpc_error_response(
871
+ status: 503,
872
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
873
+ message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
874
+ )
875
+ end
876
+
658
877
  def handle_regular_request(body_string, session_id, related_request_id: nil)
659
878
  server_session = nil
660
879
 
@@ -808,6 +1027,74 @@ module MCP
808
1027
  active
809
1028
  end
810
1029
 
1030
+ # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
1031
+ # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
1032
+ # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
1033
+ # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
1034
+ # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
1035
+ def validate_dns_rebinding(request)
1036
+ return unless @dns_rebinding_protection
1037
+
1038
+ validate_host(request) || validate_origin(request)
1039
+ end
1040
+
1041
+ # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
1042
+ # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
1043
+ def validate_host(request)
1044
+ host = request.env["HTTP_HOST"]
1045
+ return if host.nil?
1046
+
1047
+ # An `allowed_hosts:` entry matches either the bare host name (any port)
1048
+ # or the full `host:port` value, so both `"app.example.com"` and
1049
+ # `"app.example.com:8443"` can be configured.
1050
+ normalized = host.downcase
1051
+ return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
1052
+
1053
+ forbidden_response("Forbidden: Invalid Host header")
1054
+ end
1055
+
1056
+ # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
1057
+ # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
1058
+ def validate_origin(request)
1059
+ origin = request.env["HTTP_ORIGIN"]
1060
+ return if origin.nil?
1061
+ return if same_origin?(origin, request)
1062
+ return if @allowed_origins.include?(origin.downcase)
1063
+
1064
+ forbidden_response("Forbidden: Invalid Origin header")
1065
+ end
1066
+
1067
+ # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
1068
+ # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
1069
+ def request_hostname(host)
1070
+ return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
1071
+
1072
+ host.split(":").first
1073
+ end
1074
+
1075
+ # Compares the `Origin` authority (host:port) against the request's own `Host`.
1076
+ # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
1077
+ # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
1078
+ # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
1079
+ def same_origin?(origin, request)
1080
+ host = request.env["HTTP_HOST"]
1081
+ return false if host.nil?
1082
+
1083
+ normalized = origin.downcase
1084
+ default_port = normalized.start_with?("https://") ? ":443" : ":80"
1085
+ authority = normalized.sub(%r{\Ahttps?://}, "")
1086
+
1087
+ authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
1088
+ end
1089
+
1090
+ def forbidden_response(message = "Forbidden: session request validation failed")
1091
+ json_rpc_error_response(
1092
+ status: 403,
1093
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1094
+ message: message,
1095
+ )
1096
+ end
1097
+
811
1098
  def method_not_allowed_response
812
1099
  json_rpc_error_response(
813
1100
  status: 405,
@@ -902,11 +1189,15 @@ module MCP
902
1189
  end
903
1190
 
904
1191
  def send_keepalive_ping(session_id)
905
- @mutex.synchronize do
906
- if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
907
- send_ping_to_stream(@sessions[session_id][:get_sse_stream])
908
- end
1192
+ # Resolve the stream under the lock, then write outside it so a stalled reader
1193
+ # cannot block every other session on `@mutex`.
1194
+ stream = @mutex.synchronize do
1195
+ session = @sessions[session_id]
1196
+ session && session[:get_sse_stream]
909
1197
  end
1198
+ return unless stream
1199
+
1200
+ send_ping_to_stream(stream)
910
1201
  rescue *STREAM_WRITE_ERRORS => e
911
1202
  MCP.configuration.exception_reporter.call(
912
1203
  e,