mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,9 +33,15 @@ module MCP
33
33
  def run!(server_url:, resource_metadata_url: nil, scope: nil)
34
34
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
35
35
  # first thing we contact in the OAuth flow, so it has to clear the same
36
- # Communication Security bar as the OAuth endpoints downstream.
36
+ # Communication Security bar as the OAuth endpoints downstream, and it has to
37
+ # point back at the server that issued the challenge.
37
38
  if resource_metadata_url
38
39
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
40
+ ensure_same_origin!(
41
+ resource_metadata_url,
42
+ label: "WWW-Authenticate resource_metadata URL",
43
+ server_url: server_url,
44
+ )
39
45
  end
40
46
 
41
47
  prm, authorization_server = locate_authorization_server(
@@ -49,7 +55,11 @@ module MCP
49
55
  # being redirected to credentials minted for a different audience.
50
56
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
51
57
 
52
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
58
+ as_metadata = authorization_server_metadata(
59
+ authorization_server: authorization_server,
60
+ legacy: prm.nil?,
61
+ server_url: server_url,
62
+ )
53
63
 
54
64
  case provider_authorization_flow
55
65
  when :client_credentials
@@ -212,6 +222,11 @@ module MCP
212
222
 
213
223
  if resource_metadata_url
214
224
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
225
+ ensure_same_origin!(
226
+ resource_metadata_url,
227
+ label: "WWW-Authenticate resource_metadata URL",
228
+ server_url: server_url,
229
+ )
215
230
  end
216
231
 
217
232
  prm, authorization_server = locate_authorization_server(
@@ -221,7 +236,11 @@ module MCP
221
236
 
222
237
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
223
238
 
224
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
239
+ as_metadata = authorization_server_metadata(
240
+ authorization_server: authorization_server,
241
+ legacy: prm.nil?,
242
+ server_url: server_url,
243
+ )
225
244
 
226
245
  client_info = if have_stored_client_info
227
246
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
@@ -296,6 +315,11 @@ module MCP
296
315
  if prm
297
316
  authorization_server = first_authorization_server(prm)
298
317
  ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
318
+ ensure_routable_destination!(
319
+ authorization_server,
320
+ label: "PRM `authorization_servers` entry",
321
+ server_url: server_url,
322
+ )
299
323
  [prm, authorization_server]
300
324
  else
301
325
  authorization_base = server_origin!(server_url)
@@ -311,7 +335,7 @@ module MCP
311
335
  # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
312
336
  # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
313
337
  # When even the metadata document is absent, the legacy spec's default endpoints are used.
314
- def authorization_server_metadata(authorization_server:, legacy:)
338
+ def authorization_server_metadata(authorization_server:, legacy:, server_url:)
315
339
  metadata = if legacy
316
340
  begin
317
341
  fetch_authorization_server_metadata(issuer_url: authorization_server)
@@ -324,7 +348,7 @@ module MCP
324
348
  end
325
349
  end
326
350
 
327
- ensure_secure_endpoints!(metadata)
351
+ ensure_secure_endpoints!(metadata, server_url: server_url)
328
352
  metadata
329
353
  end
330
354
 
@@ -445,13 +469,72 @@ module MCP
445
469
  "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
446
470
  end
447
471
 
448
- def ensure_secure_endpoints!(as_metadata)
472
+ # Requires a URL the *server* chose to sit on the origin the *caller* chose.
473
+ #
474
+ # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
475
+ # published on that server's own origin. Without this check a `WWW-Authenticate` challenge
476
+ # can aim the first request of the flow at any host the client can route to: the URL arrives
477
+ # from the network, it is fetched before the user approves anything, and the `resource` check that
478
+ # runs afterwards cannot un-send the request.
479
+ #
480
+ # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
481
+ # the specification. It is enforced unconditionally because no known deployment publishes its PRM
482
+ # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
483
+ # internal hosts that are named rather than addressed.
484
+ # https://www.rfc-editor.org/rfc/rfc9728#section-7.7
485
+ def ensure_same_origin!(url, label:, server_url:)
486
+ return if Discovery.same_origin?(url, server_url)
487
+
488
+ raise AuthorizationError,
489
+ "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
490
+ "#{sanitized_url(server_url).inspect}; refusing to fetch it."
491
+ end
492
+
493
+ # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
494
+ # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
495
+ #
496
+ # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
497
+ # the whole flow is already inside that network and the authorization server legitimately lives there too.
498
+ # That covers `http://localhost` development, the conformance harness (which runs the MCP server and
499
+ # the authorization server on two loopback ports), and deployments that never leave a corporate network.
500
+ # Only a server reachable on the public internet is barred from steering the client inward.
501
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
502
+ def ensure_routable_destination!(url, label:, server_url:)
503
+ return unless private_network_url?(url)
504
+ return if private_network_url?(server_url)
505
+
506
+ raise AuthorizationError,
507
+ "#{label} #{sanitized_url(url).inspect} points into a private network range, " \
508
+ "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
509
+ end
510
+
511
+ def ensure_secure_endpoints!(as_metadata, server_url:)
449
512
  ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
450
513
  endpoint = as_metadata[key]
451
- ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
514
+ next unless endpoint
515
+
516
+ ensure_secure_url!(endpoint, label: "Authorization server #{key}")
517
+ ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
452
518
  end
453
519
  end
454
520
 
521
+ # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
522
+ # so the rescue here is a backstop rather than a leniency.
523
+ def private_network_url?(url)
524
+ Discovery.private_network_host?(URI.parse(url.to_s).host)
525
+ rescue URI::InvalidURIError
526
+ false
527
+ end
528
+
529
+ # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
530
+ # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
531
+ # every log destination the error passes through.
532
+ def sanitized_url(url)
533
+ Discovery.canonicalize_origin_and_path(url)
534
+ rescue URI::Error
535
+ url.to_s
536
+ end
537
+
455
538
  # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
456
539
  # identical (literal byte-for-byte equality, no normalization) to
457
540
  # the issuer URL the client used to discover that document. This guards
@@ -938,32 +1021,71 @@ module MCP
938
1021
  end
939
1022
 
940
1023
  def http_get(url)
941
- http_client.get(url)
1024
+ bounded_request do |on_data|
1025
+ http_client.get(url) do |req|
1026
+ req.options.on_data = on_data
1027
+ end
1028
+ end
942
1029
  end
943
1030
 
944
1031
  def http_post_json(url, body)
945
- http_client.post(url) do |req|
946
- req.headers["Content-Type"] = "application/json"
947
- req.headers["Accept"] = "application/json"
948
- req.body = JSON.generate(body)
1032
+ bounded_request do |on_data|
1033
+ http_client.post(url) do |req|
1034
+ req.headers["Content-Type"] = "application/json"
1035
+ req.headers["Accept"] = "application/json"
1036
+ req.options.on_data = on_data
1037
+ req.body = JSON.generate(body)
1038
+ end
949
1039
  end
950
1040
  end
951
1041
 
952
1042
  def http_post_form(url, form, headers: {})
953
- http_client.post(url) do |req|
954
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
955
- req.headers["Accept"] = "application/json"
956
- headers.each { |key, value| req.headers[key] = value }
957
- req.body = URI.encode_www_form(form)
1043
+ bounded_request do |on_data|
1044
+ http_client.post(url) do |req|
1045
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
1046
+ req.headers["Accept"] = "application/json"
1047
+
1048
+ headers.each do |key, value|
1049
+ req.headers[key] = value
1050
+ end
1051
+
1052
+ req.options.on_data = on_data
1053
+ req.body = URI.encode_www_form(form)
1054
+ end
958
1055
  end
959
1056
  end
960
1057
 
1058
+ # Issues a request with the response body bounded as it arrives, and returns the status paired with
1059
+ # that body. An over-cap response is refused rather than truncated: a partial discovery or token document
1060
+ # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
1061
+ # since the same server would serve the same body.
1062
+ def bounded_request
1063
+ bounded = BoundedBody.new
1064
+
1065
+ response = yield(bounded.on_data)
1066
+
1067
+ bounded.response_for(response)
1068
+ rescue BoundedBody::TooLargeError => e
1069
+ raise AuthorizationError, "#{e.message}."
1070
+ end
1071
+
961
1072
  def http_client
962
1073
  @http_client ||= @http_client_factory.call
963
1074
  end
964
1075
 
1076
+ # Deliberately built without redirect-following middleware. Every destination check in
1077
+ # this class runs against the URL as written, before the request goes out, so a connection
1078
+ # that transparently followed a `3xx` would let a server reach a host the checks just refused.
1079
+ # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
1080
+ # and the guards above only cover the first hop.
1081
+ #
1082
+ # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
1083
+ # the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
1084
+ # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
1085
+ # after the check.
965
1086
  def default_http_client
966
1087
  require "faraday"
1088
+
967
1089
  Faraday.new do |faraday|
968
1090
  faraday.headers["Accept"] = "application/json"
969
1091
  end
@@ -34,10 +34,13 @@ module MCP
34
34
  def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
35
35
  http_client ||= default_http_client
36
36
 
37
+ bounded = BoundedBody.new
38
+
37
39
  response = begin
38
- http_client.post(token_endpoint) do |req|
40
+ raw_response = http_client.post(token_endpoint) do |req|
39
41
  req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40
42
  req.headers["Accept"] = "application/json"
43
+ req.options.on_data = bounded.on_data
41
44
  req.body = URI.encode_www_form(
42
45
  "grant_type" => GRANT_TYPE,
43
46
  "subject_token" => id_token,
@@ -48,6 +51,10 @@ module MCP
48
51
  "client_id" => client_id,
49
52
  )
50
53
  end
54
+
55
+ bounded.response_for(raw_response)
56
+ rescue BoundedBody::TooLargeError => e
57
+ raise ExchangeError, "#{e.message}."
51
58
  rescue Faraday::Error => e
52
59
  raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
53
60
  end
@@ -88,8 +95,12 @@ module MCP
88
95
  assertion
89
96
  end
90
97
 
98
+ # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
99
+ # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
100
+ # onto compressed bytes.
91
101
  def default_http_client
92
102
  require "faraday"
103
+
93
104
  Faraday.new do |faraday|
94
105
  faraday.headers["Accept"] = "application/json"
95
106
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "oauth/bounded_body"
3
4
  require_relative "oauth/discovery"
4
5
  require_relative "oauth/flow"
5
6
  require_relative "oauth/in_memory_storage"
@@ -7,7 +7,9 @@ require "timeout"
7
7
  require_relative "../../json_rpc_handler"
8
8
  require_relative "../configuration"
9
9
  require_relative "../methods"
10
+ require_relative "../protocol_deprecations"
10
11
  require_relative "../version"
12
+ require_relative "modern_envelope"
11
13
 
12
14
  module MCP
13
15
  class Client
@@ -26,6 +28,12 @@ module MCP
26
28
  # realistic JSON-RPC frame, including base64-embedded images.
27
29
  MAX_LINE_BYTES = 4 * 1024 * 1024
28
30
 
31
+ # Seconds the `server/discover` probe may wait when no `read_timeout` was configured.
32
+ # A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one
33
+ # that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever.
34
+ # Matches the C# SDK's `DiscoverProbeTimeout` default.
35
+ DEFAULT_DISCOVER_PROBE_TIMEOUT = 5
36
+
29
37
  attr_reader :command, :args, :env, :server_info
30
38
 
31
39
  def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
@@ -49,6 +57,9 @@ module MCP
49
57
  @started = false
50
58
  @initialized = false
51
59
  @server_info = nil
60
+ @modern_protocol_version = nil
61
+ @modern_client_info = nil
62
+ @modern_capabilities = nil
52
63
  # Serializes writes to `@stdin` so a request line and a notification line emitted from
53
64
  # different threads (e.g. cancellation) cannot interleave on the wire.
54
65
  @write_mutex = Mutex.new
@@ -66,86 +77,57 @@ module MCP
66
77
  #
67
78
  # @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
68
79
  # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
69
- # @param protocol_version [String, nil] Protocol version to offer. Defaults
70
- # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
80
+ # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
81
+ # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
82
+ # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
71
83
  # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
72
84
  # @return [Hash] The server's `InitializeResult`.
73
85
  # @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
74
86
  # a malformed result, or an unsupported protocol version.
87
+ # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
88
+ # the handshake below, `:modern` skips it and probes `server/discover`,
89
+ # and `:auto` probes `server/discover` first, falling back to the legacy handshake
90
+ # when the server does not serve a mutually supported modern version.
75
91
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
76
- def connect(client_info: nil, protocol_version: nil, capabilities: {})
77
- return @server_info if @initialized
92
+ def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
93
+ return @server_info if connected?
94
+
95
+ # Validated before `start` so a pure argument error never spawns the server process.
96
+ MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
78
97
 
79
98
  start unless @started
80
99
 
81
100
  client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
82
- protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
83
-
84
- init_request = {
85
- jsonrpc: JsonRpcHandler::Version::V2_0,
86
- id: SecureRandom.uuid,
87
- method: MCP::Methods::INITIALIZE,
88
- params: {
89
- protocolVersion: protocol_version,
90
- capabilities: capabilities,
91
- clientInfo: client_info,
92
- },
93
- }
94
-
95
- write_message(init_request)
96
- response = read_response(init_request)
97
101
 
98
- if response.key?("error")
99
- error = response["error"]
100
- raise RequestHandlerError.new(
101
- "Server initialization failed: #{error["message"]}",
102
- { method: MCP::Methods::INITIALIZE },
103
- error_type: :internal_error,
104
- )
105
- end
106
-
107
- unless response["result"].is_a?(Hash)
108
- raise RequestHandlerError.new(
109
- "Server initialization failed: missing result in response",
110
- { method: MCP::Methods::INITIALIZE },
111
- error_type: :internal_error,
112
- )
113
- end
114
-
115
- @server_info = response["result"]
116
-
117
- negotiated_protocol_version = @server_info["protocolVersion"]
118
- unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
119
- # Per spec, if the client does not support the server's returned protocol version,
120
- # the client SHOULD disconnect. Roll back the cached `InitializeResult` before
121
- # raising so a retry starts without a stale `server_info`.
122
- # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
123
- @server_info = nil
124
- raise RequestHandlerError.new(
125
- "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
126
- { method: MCP::Methods::INITIALIZE },
127
- error_type: :internal_error,
128
- )
102
+ case mode
103
+ when :legacy
104
+ connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
105
+ when :modern
106
+ connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
107
+ when :auto
108
+ connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
109
+ else
110
+ raise ArgumentError, "mode must be :legacy, :modern, or :auto"
129
111
  end
112
+ end
130
113
 
131
- begin
132
- notification = {
133
- jsonrpc: JsonRpcHandler::Version::V2_0,
134
- method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
135
- }
136
- write_message(notification)
137
- rescue StandardError
138
- @server_info = nil
139
- raise
140
- end
114
+ # Whether the transport operates in the stateless modern lifecycle (SEP-2575):
115
+ # no handshake was performed and every request carries the `_meta` envelope.
116
+ def modern?
117
+ !@modern_client_info.nil?
118
+ end
141
119
 
142
- @initialized = true
143
- @server_info
120
+ # The protocol version in use on this connection, independent of its era:
121
+ # negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
122
+ # Returns `nil` before `connect` and after `close`.
123
+ def protocol_version
124
+ @modern_protocol_version || (@server_info && @server_info["protocolVersion"])
144
125
  end
145
126
 
146
- # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
127
+ # Returns true once `connect` has completed the handshake or adopted the modern lifecycle.
128
+ # Returns false before the handshake and after `close`.
147
129
  def connected?
148
- @initialized
130
+ @initialized || modern?
149
131
  end
150
132
 
151
133
  # Transports may yield once the request line has been written to `@stdin`.
@@ -153,7 +135,16 @@ module MCP
153
135
  # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
154
136
  # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
155
137
  def send_request(request:)
156
- raise "MCP::Client#connect must be called before sending requests." unless @initialized
138
+ method = request[:method] || request["method"]
139
+ if method == MCP::Methods::SERVER_DISCOVER
140
+ # `server/discover` (SEP-2575) is sessionless capability discovery that
141
+ # works before (or instead of) `connect`.
142
+ start unless @started
143
+ elsif !connected?
144
+ raise "MCP::Client#connect must be called before sending requests."
145
+ end
146
+
147
+ request = stamp_modern(request)
157
148
 
158
149
  @write_mutex.synchronize do
159
150
  write_message(request)
@@ -166,7 +157,7 @@ module MCP
166
157
  # `notifications/cancelled` for an in-flight request.
167
158
  def send_notification(notification:)
168
159
  start unless @started
169
- connect unless @initialized
160
+ connect unless connected?
170
161
 
171
162
  @write_mutex.synchronize { write_message(notification) }
172
163
  nil
@@ -228,10 +219,196 @@ module MCP
228
219
  @started = false
229
220
  @initialized = false
230
221
  @server_info = nil
222
+ leave_modern_mode
231
223
  end
232
224
 
233
225
  private
234
226
 
227
+ def connect_legacy(client_info:, protocol_version:, capabilities:)
228
+ protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
229
+
230
+ init_request = {
231
+ jsonrpc: JsonRpcHandler::Version::V2_0,
232
+ id: SecureRandom.uuid,
233
+ method: MCP::Methods::INITIALIZE,
234
+ params: {
235
+ protocolVersion: protocol_version,
236
+ capabilities: capabilities,
237
+ clientInfo: client_info,
238
+ },
239
+ }
240
+
241
+ write_message(init_request)
242
+ response = read_response(init_request)
243
+
244
+ if response.key?("error")
245
+ error = response["error"]
246
+ raise RequestHandlerError.new(
247
+ "Server initialization failed: #{error["message"]}",
248
+ { method: MCP::Methods::INITIALIZE },
249
+ error_type: :internal_error,
250
+ )
251
+ end
252
+
253
+ unless response["result"].is_a?(Hash)
254
+ raise RequestHandlerError.new(
255
+ "Server initialization failed: missing result in response",
256
+ { method: MCP::Methods::INITIALIZE },
257
+ error_type: :internal_error,
258
+ )
259
+ end
260
+
261
+ @server_info = response["result"]
262
+
263
+ negotiated_protocol_version = @server_info["protocolVersion"]
264
+ unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
265
+ # Per spec, if the client does not support the server's returned protocol version,
266
+ # the client SHOULD disconnect. A modern version is rejected along with unknown ones:
267
+ # the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse
268
+ # a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising
269
+ # so a retry starts without a stale `server_info`.
270
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
271
+ @server_info = nil
272
+ raise RequestHandlerError.new(
273
+ "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
274
+ { method: MCP::Methods::INITIALIZE },
275
+ error_type: :internal_error,
276
+ )
277
+ end
278
+
279
+ begin
280
+ notification = {
281
+ jsonrpc: JsonRpcHandler::Version::V2_0,
282
+ method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
283
+ }
284
+ write_message(notification)
285
+ rescue StandardError
286
+ @server_info = nil
287
+ raise
288
+ end
289
+
290
+ @initialized = true
291
+ @server_info
292
+ end
293
+
294
+ # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
295
+ # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
296
+ def connect_modern(client_info:, protocol_version:, capabilities:)
297
+ version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
298
+ unless MCP::Configuration.modern_protocol_version?(version)
299
+ raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
300
+ end
301
+
302
+ @modern_protocol_version = version
303
+ @modern_client_info = client_info
304
+ @modern_capabilities = capabilities || {}
305
+
306
+ begin
307
+ result = with_probe_read_timeout { probe_discover }
308
+ rescue StandardError
309
+ leave_modern_mode
310
+ raise
311
+ end
312
+
313
+ supported = result["supportedVersions"]
314
+ unless supported.is_a?(Array) && supported.include?(version)
315
+ leave_modern_mode
316
+ raise RequestHandlerError.new(
317
+ "Server discovery failed: no mutually supported modern protocol version " \
318
+ "(server supports #{supported.inspect})",
319
+ { method: MCP::Methods::SERVER_DISCOVER },
320
+ error_type: :internal_error,
321
+ )
322
+ end
323
+
324
+ # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
325
+ # so the warning lives here now that the handshake cannot land on one.
326
+ MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
327
+
328
+ @server_info = result
329
+ @server_info
330
+ end
331
+
332
+ # Probes `server/discover` and adopts the modern lifecycle when the server serves
333
+ # a mutually supported modern version; otherwise falls back to the legacy handshake.
334
+ # The fallback intentionally covers a successful discovery without a mutual modern
335
+ # version as well: during the 2026-07-28 rollout a server may answer discovery while
336
+ # only serving legacy versions.
337
+ def connect_auto(client_info:, protocol_version:, capabilities:)
338
+ modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
339
+ connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
340
+ rescue RequestHandlerError
341
+ # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
342
+ # so the probe's failure is the real answer and propagates.
343
+ raise if modern_pin
344
+
345
+ connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
346
+ end
347
+
348
+ def leave_modern_mode
349
+ @modern_protocol_version = nil
350
+ @modern_client_info = nil
351
+ @modern_capabilities = nil
352
+ end
353
+
354
+ # Bounds the probe read when the caller configured no `read_timeout`, and only for the probe:
355
+ # regular requests keep the unbounded default so long-running tools are unaffected.
356
+ # The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence
357
+ # and falls back on.
358
+ def with_probe_read_timeout
359
+ return yield if @read_timeout
360
+
361
+ @read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT
362
+ begin
363
+ yield
364
+ ensure
365
+ @read_timeout = nil
366
+ end
367
+ end
368
+
369
+ def probe_discover
370
+ request = {
371
+ jsonrpc: JsonRpcHandler::Version::V2_0,
372
+ id: SecureRandom.uuid,
373
+ method: MCP::Methods::SERVER_DISCOVER,
374
+ }
375
+
376
+ @write_mutex.synchronize { write_message(stamp_modern(request)) }
377
+ response = read_response(request)
378
+
379
+ if response.key?("error")
380
+ error = response["error"]
381
+ raise RequestHandlerError.new(
382
+ "Server discovery failed: #{error["message"]}",
383
+ { method: MCP::Methods::SERVER_DISCOVER },
384
+ error_type: :internal_error,
385
+ )
386
+ end
387
+
388
+ result = response["result"]
389
+ unless result.is_a?(Hash)
390
+ raise RequestHandlerError.new(
391
+ "Server discovery failed: missing result in response",
392
+ { method: MCP::Methods::SERVER_DISCOVER },
393
+ error_type: :internal_error,
394
+ )
395
+ end
396
+
397
+ result
398
+ end
399
+
400
+ # Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple.
401
+ def stamp_modern(request)
402
+ return request unless modern? && (request[:id] || request["id"])
403
+
404
+ ModernEnvelope.stamp(
405
+ request,
406
+ protocol_version: @modern_protocol_version,
407
+ client_info: @modern_client_info,
408
+ capabilities: @modern_capabilities,
409
+ )
410
+ end
411
+
235
412
  def write_message(message)
236
413
  ensure_running!
237
414
  json = JSON.generate(message)