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.
@@ -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)
@@ -3,13 +3,14 @@
3
3
  module MCP
4
4
  class Client
5
5
  class Tool
6
- attr_reader :name, :description, :input_schema, :output_schema
6
+ attr_reader :name, :description, :input_schema, :output_schema, :annotations
7
7
 
8
- def initialize(name:, description:, input_schema:, output_schema: nil)
8
+ def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil)
9
9
  @name = name
10
10
  @description = description
11
11
  @input_schema = input_schema
12
12
  @output_schema = output_schema
13
+ @annotations = annotations
13
14
  end
14
15
  end
15
16
  end