ruby-mcp-client 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+ require 'date'
5
+ require 'time'
6
+
3
7
  module MCPClient
4
8
  # Validates elicitation schemas and content per MCP 2025-11-25 spec.
5
9
  # Schemas are restricted to flat objects with primitive property types:
@@ -197,9 +201,48 @@ module MCPClient
197
201
  errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
198
202
  end
199
203
 
204
+ errors.concat(validate_string_format(field, value, prop['format']))
205
+
200
206
  errors
201
207
  end
202
208
 
209
+ # Validate a string value against the schema's format constraint.
210
+ # The MCP elicitation schema supports email, uri, date, and date-time.
211
+ # @param field [String] field name
212
+ # @param value [String] the value
213
+ # @param format [String, nil] declared format
214
+ # @return [Array<String>] validation errors
215
+ def self.validate_string_format(field, value, format)
216
+ valid = case format
217
+ when 'email' then value.match?(URI::MailTo::EMAIL_REGEXP)
218
+ when 'uri' then valid_uri?(value)
219
+ when 'date' then parseable?(Date, value)
220
+ when 'date-time' then parseable?(Time, value)
221
+ else true # No format declared, or an unknown format: not validated
222
+ end
223
+
224
+ valid ? [] : ["Field '#{field}' must be a valid #{format}"]
225
+ end
226
+
227
+ # @param value [String] candidate URI
228
+ # @return [Boolean] whether the value is an absolute URI
229
+ def self.valid_uri?(value)
230
+ uri = URI.parse(value)
231
+ !uri.scheme.nil?
232
+ rescue URI::InvalidURIError
233
+ false
234
+ end
235
+
236
+ # @param klass [Class] Date or Time
237
+ # @param value [String] candidate ISO 8601 value
238
+ # @return [Boolean] whether the value parses
239
+ def self.parseable?(klass, value)
240
+ klass.iso8601(value)
241
+ true
242
+ rescue ArgumentError
243
+ false
244
+ end
245
+
203
246
  # Validate a number value against its property schema.
204
247
  # @param field [String] field name
205
248
  # @param value [Object] the value
@@ -30,6 +30,30 @@ module MCPClient
30
30
  # Raised when there's a connection error with an MCP server
31
31
  class ConnectionError < MCPError; end
32
32
 
33
+ # Raised when a request requires a server capability that was not
34
+ # negotiated during initialization (MCP lifecycle: "Only use capabilities
35
+ # that were successfully negotiated")
36
+ class CapabilityError < MCPError; end
37
+
38
+ # Raised for an HTTP 403 with a WWW-Authenticate insufficient_scope
39
+ # challenge (MCP 2025-11-25 / SEP-835). Exposes the challenge parameters
40
+ # so hosts can run a step-up authorization flow with the required scopes.
41
+ class InsufficientScopeError < ConnectionError
42
+ # @return [String, nil] the scopes required by the server's challenge
43
+ attr_reader :scope
44
+ # @return [String, nil] the challenge's human-readable error description
45
+ attr_reader :error_description
46
+
47
+ # @param message [String] error message
48
+ # @param scope [String, nil] scopes from the challenge's scope parameter
49
+ # @param error_description [String, nil] challenge error_description
50
+ def initialize(message, scope: nil, error_description: nil)
51
+ super(message)
52
+ @scope = scope
53
+ @error_description = error_description
54
+ end
55
+ end
56
+
33
57
  # Raised when the MCP server returns an error response
34
58
  class ServerError < MCPError; end
35
59
 
@@ -45,7 +69,17 @@ module MCPClient
45
69
  # Raised when there's an error in the MCP server transport
46
70
  class TransportError < MCPError; end
47
71
 
48
- # Raised when tool parameters fail validation against JSON schema
72
+ # Raised when a request exceeded its timeout without receiving a
73
+ # response. A subclass of TransportError so existing rescues keep
74
+ # working, but deliberately excluded from automatic retries: the
75
+ # request may still be executing server-side, so a blind re-send could
76
+ # run a non-idempotent operation twice (MCP lifecycle: on timeout the
77
+ # sender SHOULD cancel and stop waiting, not re-send).
78
+ class RequestTimeoutError < TransportError; end
79
+
80
+ # Raised when tool parameters fail validation against the tool's input
81
+ # schema, or (in strict mode) when a tool result's structuredContent fails
82
+ # validation against the tool's output schema
49
83
  class ValidationError < MCPError; end
50
84
 
51
85
  # Raised when multiple tools with the same name exist across different servers
@@ -9,6 +9,19 @@ module MCPClient
9
9
  module HttpTransportBase
10
10
  include JsonRpcCommon
11
11
 
12
+ # Lightweight response wrapper for Faraday exception payloads (Hashes),
13
+ # so the exception path and the default path share one challenge pipeline.
14
+ NormalizedResponse = Struct.new(:status, :headers)
15
+
16
+ # One auth-param (name = token / quoted-string) as it appears in a
17
+ # WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around '=').
18
+ AUTH_PARAM = /[A-Za-z0-9._~+-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|[^,\s]*)/
19
+ # A run of comma/space separated auth-params anchored at the start of a
20
+ # string. The run ends before a token that is NOT followed by '=' — the
21
+ # auth-scheme introducing the next challenge — while commas inside quoted
22
+ # values are consumed by the quoted-string branch, not treated as boundaries.
23
+ AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
24
+
12
25
  # Generic JSON-RPC request: send method with params and return result
13
26
  # @param method [String] JSON-RPC method name
14
27
  # @param params [Hash] parameters for the request
@@ -17,16 +30,35 @@ module MCPClient
17
30
  # @raise [MCPClient::Errors::ServerError] if server returns an error
18
31
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
19
32
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
20
- def rpc_request(method, params = {})
33
+ def rpc_request(method, params = {}, timeout: nil)
21
34
  ensure_connected
22
35
 
23
36
  with_retry do
24
37
  request_id = @mutex.synchronize { @request_id += 1 }
25
38
  request = build_jsonrpc_request(method, params, request_id)
26
- send_jsonrpc_request(request)
39
+ begin
40
+ send_jsonrpc_request(request, timeout: timeout)
41
+ rescue MCPClient::Errors::RequestTimeoutError
42
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
43
+ # notification for the abandoned request and stop waiting.
44
+ send_cancellation_notification(request_id) if cancellable_request?(method, params)
45
+ raise
46
+ end
27
47
  end
28
48
  end
29
49
 
50
+ # Best-effort notifications/cancelled for a request the client stopped
51
+ # waiting on. Failures are swallowed.
52
+ # @param request_id [Integer] id of the abandoned request
53
+ # @return [void]
54
+ def send_cancellation_notification(request_id)
55
+ notif = build_jsonrpc_notification('notifications/cancelled',
56
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
57
+ send_http_request(notif)
58
+ rescue StandardError => e
59
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
60
+ end
61
+
30
62
  # Send a JSON-RPC notification (no response expected)
31
63
  # @param method [String] JSON-RPC method name
32
64
  # @param params [Hash] parameters for the notification
@@ -59,6 +91,8 @@ module MCPClient
59
91
  @headers.each { |k, v| req.headers[k] = v }
60
92
  req.headers['Mcp-Session-Id'] = @session_id
61
93
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
94
+ # MCP: authorization MUST be included in every HTTP request
95
+ @oauth_provider&.apply_authorization(req)
62
96
  end
63
97
 
64
98
  if response.success?
@@ -78,16 +112,19 @@ module MCPClient
78
112
  end
79
113
  end
80
114
 
81
- # Validate session ID format for security
115
+ # Validate session ID format
116
+ # Per MCP 2025-11-25, the server-assigned session ID "MUST only contain
117
+ # visible ASCII characters (ranging from 0x21 to 0x7E)" — e.g. a UUID, a
118
+ # JWT, or a cryptographic hash — and the client MUST echo whatever the
119
+ # server assigned. A generous length cap guards against abuse.
82
120
  # @param session_id [String] the session ID to validate
83
121
  # @return [Boolean] true if session ID is valid
84
122
  def valid_session_id?(session_id)
85
123
  return false unless session_id.is_a?(String)
86
- return false if session_id.empty?
87
124
 
88
- # Session ID should be alphanumeric with optional hyphens and underscores
89
- # Length should be reasonable (8-128 characters)
90
- session_id.match?(/\A[a-zA-Z0-9\-_]{8,128}\z/)
125
+ # The 4096-char cap is header-size hygiene, not MCP grammar — the spec
126
+ # imposes no length limit on session IDs.
127
+ session_id.match?(/\A[\x21-\x7E]{1,4096}\z/)
91
128
  end
92
129
 
93
130
  # Validate the server's base URL for security
@@ -116,16 +153,6 @@ module MCPClient
116
153
 
117
154
  private
118
155
 
119
- # Generate initialization parameters for HTTP MCP protocol
120
- # @return [Hash] the initialization parameters
121
- def initialization_params
122
- {
123
- 'protocolVersion' => MCPClient::PROTOCOL_VERSION,
124
- 'capabilities' => {},
125
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
126
- }
127
- end
128
-
129
156
  # Perform JSON-RPC initialize handshake with the MCP server
130
157
  # @return [void]
131
158
  # @raise [MCPClient::Errors::ConnectionError] if initialization fails
@@ -135,11 +162,16 @@ module MCPClient
135
162
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
136
163
 
137
164
  result = send_jsonrpc_request(json_rpc_request)
138
- return unless result.is_a?(Hash)
165
+ unless result.is_a?(Hash)
166
+ raise MCPClient::Errors::ConnectionError,
167
+ "Server returned invalid initialize result: #{result.inspect}"
168
+ end
139
169
 
170
+ # Disconnects if the server negotiated a version we cannot speak.
171
+ @protocol_version = validate_protocol_version!(result)
140
172
  @server_info = result['serverInfo']
141
173
  @capabilities = result['capabilities']
142
- @protocol_version = result['protocolVersion']
174
+ @instructions = result['instructions']
143
175
  end
144
176
 
145
177
  # Send a JSON-RPC request to the server and wait for result
@@ -148,12 +180,12 @@ module MCPClient
148
180
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
149
181
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
150
182
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
151
- def send_jsonrpc_request(request)
183
+ def send_jsonrpc_request(request, timeout: nil)
152
184
  @logger.debug("Sending JSON-RPC request: #{request.to_json}")
153
185
 
154
186
  begin
155
- response = send_http_request(request)
156
- parse_response(response)
187
+ response = send_http_request(request, timeout: timeout)
188
+ parse_response(response, request)
157
189
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
158
190
  raise
159
191
  rescue JSON::ParserError => e
@@ -170,15 +202,42 @@ module MCPClient
170
202
  # @param request [Hash] the JSON-RPC request
171
203
  # @return [Faraday::Response] the HTTP response
172
204
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
173
- def send_http_request(request)
205
+ def send_http_request(request, timeout: nil)
174
206
  conn = http_connection
207
+ # Capture the session id this request goes out with — the value
208
+ # apply_request_headers attaches — so a later 404 is attributed to the
209
+ # id that actually accompanied the request, not to whatever @session_id
210
+ # holds by 404-handling time (another caller may have completed a
211
+ # restart in between, and its fresh session must not be re-initialized).
212
+ sent_session_id = @mutex.synchronize { @session_id }
175
213
 
176
214
  begin
177
215
  response = conn.post(@endpoint) do |req|
178
216
  apply_request_headers(req, request)
217
+ # Per-request timeout override (MCP lifecycle: timeouts SHOULD be
218
+ # configurable on a per-request basis)
219
+ req.options.timeout = timeout if timeout
220
+ # The wire header must match the captured id exactly: a restart
221
+ # completing between capture and header attachment would otherwise
222
+ # attach a different (or fresh) session than the one attributed to
223
+ # this request at 404-handling time.
224
+ if req.headers.key?('Mcp-Session-Id')
225
+ if sent_session_id
226
+ req.headers['Mcp-Session-Id'] = sent_session_id
227
+ else
228
+ req.headers.delete('Mcp-Session-Id')
229
+ end
230
+ end
179
231
  req.body = request.to_json
180
232
  end
181
233
 
234
+ # MCP 2025-11-25 session management: HTTP 404 for a request carrying
235
+ # Mcp-Session-Id means the session expired — the client MUST start a
236
+ # new session with a fresh InitializeRequest (without a session ID).
237
+ if response.status == 404 && session_restart_applicable?(sent_session_id)
238
+ return restart_session_and_resend(request, sent_session_id)
239
+ end
240
+
182
241
  handle_http_error_response(response) unless response.success?
183
242
  handle_successful_response(response, request)
184
243
 
@@ -186,13 +245,59 @@ module MCPClient
186
245
  response
187
246
  rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
188
247
  handle_auth_error(e)
248
+ rescue Faraday::ResourceNotFound => e
249
+ # User-configured raise_error middleware surfaces 404 as an exception;
250
+ # apply the same session-expiry recovery as the response path.
251
+ return restart_session_and_resend(request, sent_session_id) if session_restart_applicable?(sent_session_id)
252
+
253
+ raise MCPClient::Errors::ServerError, "Client error: HTTP 404 #{e.message}"
189
254
  rescue Faraday::ConnectionFailed => e
190
255
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
256
+ rescue Faraday::TimeoutError => e
257
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
191
258
  rescue Faraday::Error => e
192
259
  raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
193
260
  end
194
261
  end
195
262
 
263
+ # Start a new session after the server invalidated the current one, then
264
+ # resend the original request once. The @restarting_session flag prevents
265
+ # a second restart if the fresh session also answers 404.
266
+ # @param request [Hash] the JSON-RPC request that hit the expired session
267
+ # @param expired_session_id [String] the session id the 404'd request was sent with
268
+ # @return [Faraday::Response] the response to the resent request
269
+ def restart_session_and_resend(request, expired_session_id)
270
+ # Serialized on the transport monitor so concurrent 404s trigger a
271
+ # single restart; the monitor is reentrant, so the nested
272
+ # perform_initialize/id generation inside is safe.
273
+ @mutex.synchronize do
274
+ # Recheck now that the monitor is held: another caller may already
275
+ # have restarted the session while this one waited. If so, skip the
276
+ # extra initialize and just resend against the fresh session.
277
+ return send_http_request(request) if @session_id != expired_session_id
278
+
279
+ @logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
280
+ @restarting_session = true
281
+ @session_id = nil
282
+ @last_event_id = nil if instance_variable_defined?(:@last_event_id)
283
+ perform_initialize
284
+ send_http_request(request)
285
+ ensure
286
+ @restarting_session = false
287
+ end
288
+ end
289
+
290
+ # Whether a 404 should trigger a session restart: only when the 404'd
291
+ # request was actually sent with a session id and no restart is already
292
+ # in flight (a restart's own resend answering 404 must not loop).
293
+ # @param sent_session_id [String, nil] session id captured when the request was sent
294
+ # @return [Boolean] true if session restart recovery applies
295
+ def session_restart_applicable?(sent_session_id)
296
+ return false if sent_session_id.nil?
297
+
298
+ @mutex.synchronize { !@restarting_session }
299
+ end
300
+
196
301
  # Apply headers to the HTTP request (can be overridden by subclasses)
197
302
  # @param req [Faraday::Request] HTTP request
198
303
  # @param _request [Hash] JSON-RPC request
@@ -212,22 +317,30 @@ module MCPClient
212
317
  # Default: no additional handling
213
318
  end
214
319
 
215
- # Handle authentication errors
320
+ # Handle authentication errors raised by user-configured raise_error
321
+ # middleware; routes through the same challenge pipeline as the default
322
+ # response path.
216
323
  # @param error [Faraday::UnauthorizedError, Faraday::ForbiddenError] Auth error
217
- # @raise [MCPClient::Errors::ConnectionError] Connection error
324
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
218
325
  def handle_auth_error(error)
219
- # Handle OAuth authorization challenges
220
- if error.response && @oauth_provider
221
- resource_metadata = @oauth_provider.handle_unauthorized_response(error.response)
222
- if resource_metadata
223
- @logger.debug('Received OAuth challenge, discovered resource metadata')
224
- # Re-raise the error to trigger OAuth flow in calling code
225
- raise MCPClient::Errors::ConnectionError, "OAuth authorization required: HTTP #{error.response[:status]}"
226
- end
326
+ response = normalize_error_response(error.response)
327
+ if response
328
+ process_authorization_challenge(response)
329
+ raise_authorization_error(response)
227
330
  end
228
331
 
229
- error_status = error.response ? error.response[:status] : 'unknown'
230
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
332
+ raise MCPClient::Errors::ConnectionError, 'Authorization failed: HTTP unknown'
333
+ end
334
+
335
+ # @param raw [Faraday::Response, Hash, nil] an exception's response payload
336
+ # @return [#status, nil] a response-like object with #status and #headers
337
+ def normalize_error_response(raw)
338
+ return nil unless raw
339
+ return raw if raw.respond_to?(:status) && raw.respond_to?(:headers)
340
+
341
+ status = raw[:status] || raw['status']
342
+ headers = raw[:headers] || raw['headers'] || {}
343
+ NormalizedResponse.new(status, headers)
231
344
  end
232
345
 
233
346
  # Handle HTTP error responses
@@ -241,7 +354,11 @@ module MCPClient
241
354
 
242
355
  case response.status
243
356
  when 401, 403
244
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
357
+ # MCP 2025-11-25: clients MUST parse WWW-Authenticate headers on 401
358
+ # responses and use the advertised resource metadata; the challenge's
359
+ # scope parameter is authoritative for the next authorization.
360
+ process_authorization_challenge(response)
361
+ raise_authorization_error(response)
245
362
  when 400..499
246
363
  # Deterministic client errors: the request was processed/rejected and
247
364
  # will not succeed on retry, so raise a plain (non-retryable) ServerError.
@@ -255,6 +372,79 @@ module MCPClient
255
372
  end
256
373
  end
257
374
 
375
+ # Surface a 401/403 WWW-Authenticate challenge to the OAuth provider so
376
+ # the advertised resource metadata and challenge scope are captured before
377
+ # the error propagates. Discovery failures must not mask the original
378
+ # authorization error.
379
+ # @param response [Faraday::Response] the 401/403 response
380
+ # @return [void]
381
+ def process_authorization_challenge(response)
382
+ return unless @oauth_provider && response.respond_to?(:headers)
383
+
384
+ @oauth_provider.handle_unauthorized_response(response)
385
+ rescue StandardError => e
386
+ @logger.debug("OAuth challenge processing failed: #{e.message}")
387
+ end
388
+
389
+ # Raise the appropriate error for a 401/403: an insufficient_scope 403
390
+ # challenge (SEP-835) raises InsufficientScopeError exposing the required
391
+ # scopes so hosts can run a step-up authorization flow.
392
+ # @param response [Faraday::Response] the 401/403 response
393
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
394
+ def raise_authorization_error(response)
395
+ challenge = bearer_challenge_segment(www_authenticate_header(response))
396
+
397
+ if response.status == 403 && insufficient_scope_challenge?(challenge)
398
+ scope = challenge[/(?:^|[\s,])scope\s*=\s*"([^"]*)"/i, 1] ||
399
+ challenge[/(?:^|[\s,])scope\s*=\s*([^,\s"]+)/i, 1]
400
+ description = challenge[/(?:^|[\s,])error_description\s*=\s*"([^"]*)"/i, 1]
401
+ raise MCPClient::Errors::InsufficientScopeError.new(
402
+ "Authorization failed: HTTP 403 insufficient_scope#{" (required scopes: #{scope})" if scope}",
403
+ scope: scope, error_description: description
404
+ )
405
+ end
406
+
407
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
408
+ end
409
+
410
+ # Extract the Bearer challenge's own parameter segment from a (possibly
411
+ # multi-challenge) WWW-Authenticate header, so params belonging to other
412
+ # schemes (e.g. `Basic error="insufficient_scope", Bearer realm="x"`) are
413
+ # never attributed to the Bearer challenge.
414
+ # @param header [String, nil] the WWW-Authenticate header value
415
+ # @return [String, nil] the Bearer challenge's parameters (possibly empty),
416
+ # or nil when the header has no Bearer challenge
417
+ def bearer_challenge_segment(header)
418
+ return nil unless header
419
+
420
+ # Locate the Bearer scheme token only OUTSIDE quoted strings: a quoted
421
+ # value such as realm="prefix Bearer x" must not anchor the segment.
422
+ masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
423
+ match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
424
+ return nil unless match
425
+
426
+ header[match.end(0)..][AUTH_PARAMS_RUN]
427
+ end
428
+
429
+ # The Bearer challenge segment carries an error auth-param that is exactly
430
+ # insufficient_scope (RFC 6750 / SEP-835); prefixed or extended tokens
431
+ # (e.g. insufficient_scope.extra) do not match.
432
+ # @param challenge [String, nil] the Bearer challenge segment
433
+ # @return [Boolean]
434
+ def insufficient_scope_challenge?(challenge)
435
+ return false unless challenge
436
+
437
+ challenge.match?(/(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?![\w.-])/i)
438
+ end
439
+
440
+ # @param response [Faraday::Response] an HTTP response
441
+ # @return [String, nil] the WWW-Authenticate header value, if any
442
+ def www_authenticate_header(response)
443
+ return nil unless response.respond_to?(:headers) && response.headers
444
+
445
+ response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
446
+ end
447
+
258
448
  # Get or create HTTP connection
259
449
  # @return [Faraday::Connection] the HTTP connection
260
450
  def http_connection
@@ -288,7 +478,7 @@ module MCPClient
288
478
  # @param response [Faraday::Response] the HTTP response
289
479
  # @return [Hash] the parsed result
290
480
  # @raise [NotImplementedError] if not implemented by concrete transport
291
- def parse_response(response)
481
+ def parse_response(response, _request = nil)
292
482
  raise NotImplementedError, 'Subclass must implement parse_response'
293
483
  end
294
484
  end
@@ -23,6 +23,10 @@ module MCPClient
23
23
  yield
24
24
  rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::TransportError, IOError,
25
25
  Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
26
+ # A timed-out request may still be executing server-side; re-sending
27
+ # it could run a non-idempotent operation twice. Never retry those.
28
+ raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
29
+
26
30
  attempts += 1
27
31
  if attempts <= @max_retries
28
32
  delay = @retry_backoff * (2**(attempts - 1))
@@ -43,6 +47,46 @@ module MCPClient
43
47
  rpc_request('ping')
44
48
  end
45
49
 
50
+ # Whether automatic notifications/cancelled on timeout is appropriate
51
+ # for this request: never for initialize (MUST NOT be cancelled), and
52
+ # never for task-augmented requests (tasks use tasks/cancel instead).
53
+ # @param method [String] JSON-RPC method
54
+ # @param params [Hash] request params
55
+ # @return [Boolean]
56
+ def cancellable_request?(method, params)
57
+ return false if method == 'initialize'
58
+ return false if params.is_a?(Hash) && (params.key?('task') || params.key?(:task))
59
+
60
+ true
61
+ end
62
+
63
+ # Split request-level _meta (RequestParams._meta, e.g. progressToken or
64
+ # related-task metadata) out of user-supplied tool/prompt arguments.
65
+ # Accepts both :_meta and '_meta' key spellings; per MCP, _meta belongs at
66
+ # the request params level, not inside the tool's arguments.
67
+ # @param arguments [Hash, nil] user-supplied arguments
68
+ # @return [Array(Hash, Hash|nil)] [arguments without _meta, _meta or nil]
69
+ def split_request_meta(arguments)
70
+ return [arguments, nil] unless arguments.is_a?(Hash)
71
+
72
+ meta = arguments[:_meta] || arguments['_meta']
73
+ return [arguments, nil] unless meta
74
+
75
+ [arguments.except(:_meta, '_meta'), meta]
76
+ end
77
+
78
+ # Build tools/call- or prompts/get-style params with request-level _meta
79
+ # hoisted out of the arguments (string keys, matching the JSON wire form).
80
+ # @param name [String] tool or prompt name
81
+ # @param arguments [Hash] user-supplied arguments (possibly carrying _meta)
82
+ # @return [Hash] params hash for the JSON-RPC request
83
+ def build_named_request_params(name, arguments)
84
+ args, meta = split_request_meta(arguments)
85
+ params = { 'name' => name, 'arguments' => args }
86
+ params['_meta'] = meta if meta
87
+ params
88
+ end
89
+
46
90
  # Build a JSON-RPC request object
47
91
  # @param method [String] JSON-RPC method name
48
92
  # @param params [Hash] parameters for the request
@@ -72,24 +116,93 @@ module MCPClient
72
116
  # Generate initialization parameters for MCP protocol
73
117
  # @return [Hash] the initialization parameters
74
118
  def initialization_params
75
- capabilities = {
76
- 'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
77
- 'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
78
- 'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
79
- # NOTE: we intentionally do NOT declare a client `tasks` capability. That
80
- # capability marks the client as a RECEIVER of task-augmented
81
- # sampling/elicitation requests, which is not implemented here — this
82
- # client only acts as a task REQUESTOR for tools/call (see
83
- # Client#call_tool_as_task), which requires no client-side declaration.
84
- }
85
-
86
119
  {
87
120
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
88
- 'capabilities' => capabilities,
89
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
121
+ 'capabilities' => client_capabilities,
122
+ 'clientInfo' => client_info_payload
90
123
  }
91
124
  end
92
125
 
126
+ # Validate the protocol version the server negotiated in its initialize
127
+ # result. Per the MCP lifecycle, the server may answer with a different
128
+ # version than requested; if the client cannot support it, it MUST
129
+ # disconnect. Disconnects (via the transport's cleanup) and raises when
130
+ # the version is unsupported or absent.
131
+ # @param result [Hash] the initialize result
132
+ # @return [String] the negotiated protocol version
133
+ # @raise [MCPClient::Errors::ConnectionError] if the version is unsupported
134
+ def validate_protocol_version!(result)
135
+ version = result['protocolVersion']
136
+ return version if MCPClient::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
137
+
138
+ begin
139
+ cleanup if respond_to?(:cleanup)
140
+ rescue StandardError => e
141
+ @logger.debug("Cleanup after protocol version mismatch failed: #{e.message}")
142
+ end
143
+ raise MCPClient::Errors::ConnectionError,
144
+ "Server negotiated unsupported protocol version #{version.inspect} " \
145
+ "(supported: #{MCPClient::SUPPORTED_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
146
+ end
147
+
148
+ # The Implementation object sent as clientInfo: the host-provided info
149
+ # when configured (client_info=), otherwise the gem's identity.
150
+ # @return [Hash]
151
+ def client_info_payload
152
+ return @client_info if defined?(@client_info) && @client_info
153
+
154
+ { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
155
+ end
156
+
157
+ # Declared client capabilities, derived from the server-request callbacks
158
+ # the host actually registered before connecting. Per MCP 2025-11-25,
159
+ # clients that support a feature MUST declare it during initialization,
160
+ # and only negotiated capabilities may be used afterwards — so declaring
161
+ # a hardcoded set independent of host support violates the lifecycle in
162
+ # both directions.
163
+ # @return [Hash] the capabilities object for the initialize request
164
+ def client_capabilities
165
+ capabilities = {}
166
+ if registered_callback?(:@elicitation_request_callback)
167
+ # Both defined elicitation modes are implemented (an empty object
168
+ # would mean form-only per the spec's backwards-compatibility rule).
169
+ capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
170
+ end
171
+ capabilities['roots'] = { 'listChanged' => true } if registered_callback?(:@roots_list_request_callback)
172
+ if registered_callback?(:@sampling_request_callback)
173
+ # SEP-1577: servers may only send tool-enabled sampling requests when
174
+ # the client declares the sampling.tools sub-capability.
175
+ capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
176
+ end
177
+ # NOTE: we intentionally do NOT declare a client `tasks` capability. That
178
+ # capability marks the client as a RECEIVER of task-augmented
179
+ # sampling/elicitation requests, which is not implemented here — this
180
+ # client only acts as a task REQUESTOR for tools/call (see
181
+ # Client#call_tool_as_task), which requires no client-side declaration.
182
+ capabilities
183
+ end
184
+
185
+ # Opt this transport into declaring tool-use support for sampling
186
+ # (ClientCapabilities.sampling.tools, MCP 2025-11-25 / SEP-1577). Call
187
+ # before connect so the initialize request advertises it; it only takes
188
+ # effect when a sampling request callback is also registered, since
189
+ # sampling.tools is a sub-capability of sampling.
190
+ # @return [void]
191
+ def declare_sampling_tools
192
+ @sampling_tools_supported = true
193
+ end
194
+
195
+ # @param ivar [Symbol] callback instance variable name
196
+ # @return [Boolean] whether the callback is registered on this transport
197
+ def registered_callback?(ivar)
198
+ instance_variable_defined?(ivar) && !instance_variable_get(ivar).nil?
199
+ end
200
+
201
+ # @return [Boolean] whether the host opted into sampling tool use
202
+ def sampling_tools_supported?
203
+ instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
204
+ end
205
+
93
206
  # Process JSON-RPC response
94
207
  # @param response [Hash] the parsed JSON-RPC response
95
208
  # @return [Object] the result field from the response