mcp 0.21.0 → 0.23.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)
@@ -340,7 +440,9 @@ module MCP
340
440
  content_type_error = validate_content_type(request)
341
441
  return content_type_error if content_type_error
342
442
 
343
- body_string = request.body.read
443
+ body_string = read_bounded_body(request)
444
+ return payload_too_large_response if body_string.nil?
445
+
344
446
  session_id = extract_session_id(request)
345
447
 
346
448
  begin
@@ -378,16 +480,25 @@ module MCP
378
480
  return session_not_found_response
379
481
  end
380
482
 
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)
483
+ handle_initialization(request, body_string, body)
389
484
  else
390
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
485
+ # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
486
+ # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
487
+ # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
488
+ if !@stateless && session_id && !validate_session_request(request, session_id)
489
+ return forbidden_response
490
+ end
491
+
492
+ if notification?(body)
493
+ dispatch_notification(body_string, session_id)
494
+ handle_accepted
495
+ elsif response?(body)
496
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
497
+
498
+ handle_response(body, session_id: session_id)
499
+ else
500
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
501
+ end
391
502
  end
392
503
  rescue StandardError => e
393
504
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -412,6 +523,7 @@ module MCP
412
523
 
413
524
  error_response = validate_and_touch_session(session_id)
414
525
  return error_response if error_response
526
+ return forbidden_response unless validate_session_request(request, session_id)
415
527
 
416
528
  protocol_version_error = validate_protocol_version_header(request)
417
529
  return protocol_version_error if protocol_version_error
@@ -434,6 +546,7 @@ module MCP
434
546
 
435
547
  return missing_session_id_response unless (session_id = extract_session_id(request))
436
548
  return session_not_found_response unless session_exists?(session_id)
549
+ return forbidden_response unless validate_session_request(request, session_id)
437
550
 
438
551
  protocol_version_error = validate_protocol_version_header(request)
439
552
  return protocol_version_error if protocol_version_error
@@ -495,6 +608,27 @@ module MCP
495
608
  request.env["HTTP_MCP_SESSION_ID"]
496
609
  end
497
610
 
611
+ # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
612
+ # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
613
+ # Two layers, both returning `false` to trigger a 403:
614
+ #
615
+ # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
616
+ # at `initialize` and this request carries a different one, reject. Both must be present to compare,
617
+ # so non-browser clients that send no `Origin` are unaffected.
618
+ # - The application-supplied `session_request_validator`, which can enforce true ownership when it has
619
+ # an authenticated principal.
620
+ def validate_session_request(request, session_id)
621
+ session = @mutex.synchronize { @sessions[session_id] }
622
+ return true unless session
623
+
624
+ session_origin = session[:origin]
625
+ request_origin = request.env["HTTP_ORIGIN"]
626
+ return false if session_origin && request_origin && session_origin != request_origin
627
+ return @session_request_validator.call(request, session_id) if @session_request_validator
628
+
629
+ true
630
+ end
631
+
498
632
  def validate_accept_header(request, required_types)
499
633
  accept_header = request.env["HTTP_ACCEPT"]
500
634
  return not_acceptable_response(required_types) unless accept_header
@@ -534,8 +668,34 @@ module MCP
534
668
  )
535
669
  end
536
670
 
671
+ # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
672
+ # memory. A declared `Content-Length` over the cap is rejected
673
+ # without reading; the actual read is also bounded to one byte past the cap, so
674
+ # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
675
+ # Returns `nil` when the body exceeds the cap.
676
+ def read_bounded_body(request)
677
+ content_length = request.content_length
678
+ return if content_length && content_length.to_i > @max_request_bytes
679
+
680
+ body = request.body.read(@max_request_bytes + 1)
681
+ return "" if body.nil?
682
+ return if body.bytesize > @max_request_bytes
683
+
684
+ body
685
+ end
686
+
687
+ def payload_too_large_response
688
+ json_rpc_error_response(
689
+ status: 413,
690
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
691
+ message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
692
+ )
693
+ end
694
+
537
695
  def parse_request_body(body_string)
538
- JSON.parse(body_string, symbolize_names: true)
696
+ # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
697
+ # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
698
+ JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
539
699
  rescue JSON::ParserError, TypeError
540
700
  raise InvalidJsonError
541
701
  end
@@ -613,7 +773,7 @@ module MCP
613
773
  handle_accepted
614
774
  end
615
775
 
616
- def handle_initialization(body_string, body)
776
+ def handle_initialization(request, body_string, body)
617
777
  session_id = nil
618
778
 
619
779
  if @stateless
@@ -622,13 +782,33 @@ module MCP
622
782
  session_id = SecureRandom.uuid
623
783
  server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
624
784
 
625
- @mutex.synchronize do
785
+ # Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
786
+ # The check and insert share the mutex so concurrent initializes cannot race past the limit.
787
+ reclaimed = []
788
+ inserted = @mutex.synchronize do
789
+ # When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
790
+ # so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
791
+ if @max_sessions && @sessions.size >= @max_sessions
792
+ @sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
793
+ cleanup_and_collect_stream(id, reclaimed)
794
+ end
795
+ end
796
+
797
+ next false if @max_sessions && @sessions.size >= @max_sessions
798
+
626
799
  @sessions[session_id] = {
627
800
  get_sse_stream: nil,
628
801
  server_session: server_session,
629
802
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
803
+ # Captured for the built-in Origin-consistency defense in `validate_session_request`.
804
+ # Not authentication.
805
+ origin: request.env["HTTP_ORIGIN"],
630
806
  }
807
+ true
631
808
  end
809
+
810
+ reclaimed.each { |stream| close_stream_safely(stream) }
811
+ return too_many_sessions_response unless inserted
632
812
  end
633
813
 
634
814
  response = server_session.handle_json(body_string)
@@ -655,6 +835,14 @@ module MCP
655
835
  [202, {}, []]
656
836
  end
657
837
 
838
+ def too_many_sessions_response
839
+ json_rpc_error_response(
840
+ status: 503,
841
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
842
+ message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
843
+ )
844
+ end
845
+
658
846
  def handle_regular_request(body_string, session_id, related_request_id: nil)
659
847
  server_session = nil
660
848
 
@@ -808,6 +996,74 @@ module MCP
808
996
  active
809
997
  end
810
998
 
999
+ # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
1000
+ # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
1001
+ # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
1002
+ # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
1003
+ # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
1004
+ def validate_dns_rebinding(request)
1005
+ return unless @dns_rebinding_protection
1006
+
1007
+ validate_host(request) || validate_origin(request)
1008
+ end
1009
+
1010
+ # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
1011
+ # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
1012
+ def validate_host(request)
1013
+ host = request.env["HTTP_HOST"]
1014
+ return if host.nil?
1015
+
1016
+ # An `allowed_hosts:` entry matches either the bare host name (any port)
1017
+ # or the full `host:port` value, so both `"app.example.com"` and
1018
+ # `"app.example.com:8443"` can be configured.
1019
+ normalized = host.downcase
1020
+ return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
1021
+
1022
+ forbidden_response("Forbidden: Invalid Host header")
1023
+ end
1024
+
1025
+ # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
1026
+ # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
1027
+ def validate_origin(request)
1028
+ origin = request.env["HTTP_ORIGIN"]
1029
+ return if origin.nil?
1030
+ return if same_origin?(origin, request)
1031
+ return if @allowed_origins.include?(origin.downcase)
1032
+
1033
+ forbidden_response("Forbidden: Invalid Origin header")
1034
+ end
1035
+
1036
+ # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
1037
+ # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
1038
+ def request_hostname(host)
1039
+ return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
1040
+
1041
+ host.split(":").first
1042
+ end
1043
+
1044
+ # Compares the `Origin` authority (host:port) against the request's own `Host`.
1045
+ # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
1046
+ # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
1047
+ # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
1048
+ def same_origin?(origin, request)
1049
+ host = request.env["HTTP_HOST"]
1050
+ return false if host.nil?
1051
+
1052
+ normalized = origin.downcase
1053
+ default_port = normalized.start_with?("https://") ? ":443" : ":80"
1054
+ authority = normalized.sub(%r{\Ahttps?://}, "")
1055
+
1056
+ authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
1057
+ end
1058
+
1059
+ def forbidden_response(message = "Forbidden: session request validation failed")
1060
+ json_rpc_error_response(
1061
+ status: 403,
1062
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1063
+ message: message,
1064
+ )
1065
+ end
1066
+
811
1067
  def method_not_allowed_response
812
1068
  json_rpc_error_response(
813
1069
  status: 405,
data/lib/mcp/server.rb CHANGED
@@ -256,6 +256,9 @@ module MCP
256
256
  report_exception(e, { notification: "resources_list_changed" })
257
257
  end
258
258
 
259
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
260
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
261
+ # Use stderr or OpenTelemetry instead.
259
262
  def notify_log_message(data:, level:, logger: nil)
260
263
  return unless @transport
261
264
  return unless logging_message_notification&.should_notify?(level)
@@ -272,6 +275,10 @@ module MCP
272
275
  # Called when a client notifies the server that its filesystem roots have changed.
273
276
  #
274
277
  # @yield [params] The notification params (typically `nil`).
278
+ # @deprecated MCP Roots (`roots/list` and
279
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
280
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
281
+ # server configuration, or environment variables instead.
275
282
  def roots_list_changed_handler(&block)
276
283
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
277
284
  end
@@ -423,6 +430,13 @@ module MCP
423
430
  end
424
431
 
425
432
  def handle_request(request, method, session: nil, related_request_id: nil)
433
+ # A well-formed notification carries no JSON-RPC id and receives no response.
434
+ # If a client erroneously sends a notification-only method with an id, the message
435
+ # is framed as a request; since notification methods have no request handler,
436
+ # returning `nil` here makes `JsonRpcHandler` report "Method not found", matching
437
+ # the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
438
+ return if Methods.notification?(method) && !related_request_id.nil?
439
+
426
440
  # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
427
441
  # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
428
442
  # and rescue blocks below.
@@ -636,7 +650,7 @@ module MCP
636
650
  tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
637
651
  )
638
652
  validate_tool_call_result!(tool, result)
639
- result
653
+ serialize_structured_content_fallback(result)
640
654
  rescue RequestHandlerError, CancelledError
641
655
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
642
656
  # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
@@ -801,6 +815,18 @@ module MCP
801
815
  tool.output_schema.validate_result(result[:structuredContent])
802
816
  end
803
817
 
818
+ # Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
819
+ # Clients on older protocol versions may only read `content`,
820
+ # so when a tool returns non-object structured content without providing
821
+ # any content blocks, mirror the value into `content` as serialized JSON text.
822
+ def serialize_structured_content_fallback(result)
823
+ structured = result[:structuredContent]
824
+ return result if structured.nil? || structured.is_a?(Hash)
825
+ return result unless result[:content].nil? || result[:content].empty?
826
+
827
+ result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
828
+ end
829
+
804
830
  # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
805
831
  # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
806
832
  # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
@@ -35,6 +35,9 @@ module MCP
35
35
  # @param data [Object] The log data to send.
36
36
  # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
37
37
  # @param logger [String, nil] Logger name.
38
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
39
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
40
+ # Use stderr or OpenTelemetry instead.
38
41
  def notify_log_message(data:, level:, logger: nil)
39
42
  return unless @notification_target
40
43
 
@@ -51,6 +54,10 @@ module MCP
51
54
  end
52
55
 
53
56
  # Delegates to the session so the request is scoped to the originating client.
57
+ # @deprecated MCP Roots (`roots/list` and
58
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
59
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
60
+ # server configuration, or environment variables instead.
54
61
  def list_roots
55
62
  if @notification_target.respond_to?(:list_roots)
56
63
  @notification_target.list_roots(related_request_id: @related_request_id)
@@ -84,6 +91,9 @@ module MCP
84
91
  # Delegates to the session so the request is scoped to the originating client.
85
92
  # Falls back to `@context` (via `method_missing`) when `@notification_target`
86
93
  # does not support sampling.
94
+ # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
95
+ # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
96
+ # APIs instead.
87
97
  def create_sampling_message(**kwargs)
88
98
  if @notification_target.respond_to?(:create_sampling_message)
89
99
  @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
@@ -130,9 +140,14 @@ module MCP
130
140
  end
131
141
  end
132
142
 
133
- def method_missing(name, ...)
143
+ # Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax.
144
+ # The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8,
145
+ # so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it
146
+ # raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps
147
+ # this method loadable on Ruby 2.7.0.
148
+ def method_missing(name, *args, **kwargs, &block)
134
149
  if @context.respond_to?(name)
135
- @context.public_send(name, ...)
150
+ @context.public_send(name, *args, **kwargs, &block)
136
151
  else
137
152
  super
138
153
  end
@@ -98,6 +98,10 @@ module MCP
98
98
  end
99
99
 
100
100
  # Sends a `roots/list` request scoped to this session.
101
+ # @deprecated MCP Roots (`roots/list` and
102
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
103
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
104
+ # server configuration, or environment variables instead.
101
105
  def list_roots(related_request_id: nil)
102
106
  unless client_capabilities&.dig(:roots)
103
107
  raise "Client does not support roots."
@@ -115,6 +119,9 @@ module MCP
115
119
  end
116
120
 
117
121
  # Sends a `sampling/createMessage` request scoped to this session.
122
+ # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
123
+ # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
124
+ # APIs instead.
118
125
  def create_sampling_message(related_request_id: nil, **kwargs)
119
126
  params = @server.build_sampling_params(client_capabilities, **kwargs)
120
127
  send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
@@ -188,6 +195,9 @@ module MCP
188
195
  end
189
196
 
190
197
  # Sends a log message notification to this session only.
198
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
199
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
200
+ # Use stderr or OpenTelemetry instead.
191
201
  def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
192
202
  effective_logging = @logging_message_notification || @server.logging_message_notification
193
203
  return unless effective_logging&.should_notify?(level)
@@ -7,12 +7,29 @@ module MCP
7
7
  class OutputSchema < Schema
8
8
  class ValidationError < StandardError; end
9
9
 
10
+ # Root-level keywords whose presence means the user already chose a root schema shape,
11
+ # so no `type: "object"` default should be merged in.
12
+ ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze
13
+
10
14
  def validate_result(result)
11
15
  errors = fully_validate(result)
12
16
  if errors.any?
13
17
  raise ValidationError, "Invalid result: #{errors.join(", ")}"
14
18
  end
15
19
  end
20
+
21
+ private
22
+
23
+ # Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive,
24
+ # or a root-level composition.
25
+ # Default the root to an object only when no root schema keyword is present, which preserves the wire output
26
+ # of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched
27
+ # (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema).
28
+ def apply_default_root_type!
29
+ return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) }
30
+
31
+ super
32
+ end
16
33
  end
17
34
  end
18
35
  end