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.
@@ -9,6 +9,22 @@ module MCPClient
9
9
 
10
10
  # Initialize the server with a name
11
11
  # @param name [String, nil] server name
12
+ # Server-declared instructions from the initialize result, if any
13
+ # @return [String, nil]
14
+ attr_reader :instructions
15
+
16
+ # Host-supplied Implementation info sent as clientInfo during initialize
17
+ # (MCP 2025-11-25 Implementation: name, version, plus optional title,
18
+ # description, websiteUrl, icons). Defaults to the gem's identity.
19
+ # @param info [Hash] implementation info; must include name and version
20
+ # @raise [ArgumentError] when name or version is missing
21
+ def client_info=(info)
22
+ raise ArgumentError, 'client_info must include name' unless info['name'] || info[:name]
23
+ raise ArgumentError, 'client_info must include version' unless info['version'] || info[:version]
24
+
25
+ @client_info = info.transform_keys(&:to_s)
26
+ end
27
+
12
28
  def initialize(name: nil)
13
29
  @name = name
14
30
  end
@@ -83,11 +99,61 @@ module MCPClient
83
99
  end
84
100
 
85
101
  # Get server capabilities
102
+ # MCP 2025-11-25 tasks: all messages related to a task MUST carry the
103
+ # io.modelcontextprotocol/related-task key in _meta. Reserved key name:
104
+ RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'
105
+
106
+ # Echo the related-task _meta of an incoming server request onto the
107
+ # outgoing result, so responses to task-related requests (elicitation or
108
+ # sampling during input_required) stay associated with their task.
109
+ # @param result [Hash] the outgoing JSON-RPC result payload
110
+ # @param params [Hash, nil] the incoming request params
111
+ # @return [Hash] result with related-task _meta merged when applicable
112
+ def merge_related_task_meta(result, params)
113
+ related = params.is_a?(Hash) ? params.dig('_meta', RELATED_TASK_META_KEY) : nil
114
+ return result unless related && result.is_a?(Hash) && !result.key?('error')
115
+
116
+ meta = (result['_meta'] || {}).merge(RELATED_TASK_META_KEY => related)
117
+ result.merge('_meta' => meta)
118
+ end
119
+
86
120
  # @return [Hash, nil] server capabilities
87
121
  def capabilities
88
122
  raise NotImplementedError, 'Subclasses must implement capabilities'
89
123
  end
90
124
 
125
+ # Whether the server declared the given (possibly nested) capability
126
+ # during initialization.
127
+ # @param path [Array<String, Symbol>] capability key path, e.g. 'logging'
128
+ # or 'resources', 'subscribe'
129
+ # @return [Boolean]
130
+ def capability?(*path)
131
+ node = begin
132
+ capabilities
133
+ rescue NotImplementedError
134
+ nil
135
+ end
136
+ path.each do |key|
137
+ return false unless node.is_a?(Hash)
138
+
139
+ node = node[key.to_s]
140
+ end
141
+ !node.nil? && node != false
142
+ end
143
+
144
+ # Raise unless the server negotiated the given capability (MCP lifecycle:
145
+ # "Only use capabilities that were successfully negotiated").
146
+ # @param path [Array<String, Symbol>] capability key path
147
+ # @param method [String] the JSON-RPC method the caller wants to send
148
+ # @raise [MCPClient::Errors::CapabilityError]
149
+ def require_capability!(*path, method:)
150
+ return if capability?(*path)
151
+
152
+ raise MCPClient::Errors::CapabilityError,
153
+ "Server #{name || self.class.name} did not declare the #{path.join('.')} capability " \
154
+ "required for #{method}"
155
+ end
156
+
91
157
  # Clean up the server connection
92
158
  def cleanup
93
159
  raise NotImplementedError, 'Subclasses must implement cleanup'
@@ -12,10 +12,11 @@ module MCPClient
12
12
 
13
13
  # Parse an HTTP JSON-RPC response
14
14
  # @param response [Faraday::Response] the HTTP response
15
+ # @param _request [Hash, nil] the originating JSON-RPC request (unused)
15
16
  # @return [Hash] the parsed result
16
17
  # @raise [MCPClient::Errors::TransportError] if parsing fails
17
18
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
18
- def parse_response(response)
19
+ def parse_response(response, _request = nil)
19
20
  body = response.body.strip
20
21
  data = JSON.parse(body)
21
22
  process_jsonrpc_response(data)
@@ -183,10 +183,7 @@ module MCPClient
183
183
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
184
184
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
185
185
  def call_tool(tool_name, parameters)
186
- rpc_request('tools/call', {
187
- name: tool_name,
188
- arguments: parameters
189
- })
186
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
190
187
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
191
188
  # Re-raise connection/transport errors directly to match test expectations
192
189
  raise
@@ -271,10 +268,7 @@ module MCPClient
271
268
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
272
269
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
273
270
  def get_prompt(prompt_name, parameters)
274
- rpc_request('prompts/get', {
275
- name: prompt_name,
276
- arguments: parameters
277
- })
271
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
278
272
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
279
273
  raise
280
274
  rescue StandardError => e
@@ -336,11 +330,14 @@ module MCPClient
336
330
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
337
331
  # @raise [MCPClient::Errors::ServerError] if server returns an error
338
332
  def complete(ref:, argument:, context: nil)
333
+ ensure_connected
334
+ require_capability!('completions', method: 'completion/complete')
339
335
  params = { ref: ref, argument: argument }
340
336
  params[:context] = context if context
341
337
  result = rpc_request('completion/complete', params)
342
338
  result['completion'] || { 'values' => [] }
343
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
339
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
340
+ MCPClient::Errors::CapabilityError
344
341
  raise
345
342
  rescue StandardError => e
346
343
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -352,8 +349,11 @@ module MCPClient
352
349
  # @return [Hash] empty result on success
353
350
  # @raise [MCPClient::Errors::ServerError] if server returns an error
354
351
  def log_level=(level)
352
+ ensure_connected
353
+ require_capability!('logging', method: 'logging/setLevel')
355
354
  rpc_request('logging/setLevel', { level: level })
356
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
355
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
356
+ MCPClient::Errors::CapabilityError
357
357
  raise
358
358
  rescue StandardError => e
359
359
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -384,9 +384,12 @@ module MCPClient
384
384
  # @return [Boolean] true if subscription successful
385
385
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
386
386
  def subscribe_resource(uri)
387
+ ensure_connected
388
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
387
389
  rpc_request('resources/subscribe', { uri: uri })
388
390
  true
389
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
391
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
392
+ MCPClient::Errors::CapabilityError
390
393
  raise
391
394
  rescue StandardError => e
392
395
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -397,9 +400,12 @@ module MCPClient
397
400
  # @return [Boolean] true if unsubscription successful
398
401
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
399
402
  def unsubscribe_resource(uri)
403
+ ensure_connected
404
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
400
405
  rpc_request('resources/unsubscribe', { uri: uri })
401
406
  true
402
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
407
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
408
+ MCPClient::Errors::CapabilityError
403
409
  raise
404
410
  rescue StandardError => e
405
411
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -16,16 +16,35 @@ module MCPClient
16
16
  # @raise [MCPClient::Errors::ServerError] if server returns an error
17
17
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
18
18
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
19
- def rpc_request(method, params = {})
19
+ def rpc_request(method, params = {}, timeout: nil)
20
20
  ensure_initialized
21
21
 
22
22
  with_retry do
23
23
  request_id = @mutex.synchronize { @request_id += 1 }
24
24
  request = build_jsonrpc_request(method, params, request_id)
25
- send_jsonrpc_request(request)
25
+ begin
26
+ send_jsonrpc_request(request, timeout: timeout)
27
+ rescue MCPClient::Errors::RequestTimeoutError
28
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
29
+ # notification for the abandoned request and stop waiting.
30
+ send_cancellation_notification(request_id) if cancellable_request?(method, params)
31
+ raise
32
+ end
26
33
  end
27
34
  end
28
35
 
36
+ # Best-effort notifications/cancelled for a request the client stopped
37
+ # waiting on. Failures are swallowed.
38
+ # @param request_id [Integer] id of the abandoned request
39
+ # @return [void]
40
+ def send_cancellation_notification(request_id)
41
+ notif = build_jsonrpc_notification('notifications/cancelled',
42
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
43
+ post_json_rpc_request(notif)
44
+ rescue StandardError => e
45
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
46
+ end
47
+
29
48
  # Send a JSON-RPC notification (no response expected)
30
49
  # @param method [String] JSON-RPC method name
31
50
  # @param params [Hash] parameters for the notification
@@ -62,15 +81,28 @@ module MCPClient
62
81
 
63
82
  # Perform JSON-RPC initialize handshake with the MCP server
64
83
  # @return [void]
84
+ # @raise [MCPClient::Errors::ConnectionError] if the initialize result is malformed
65
85
  def perform_initialize
66
86
  request_id = @mutex.synchronize { @request_id += 1 }
67
87
  json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
68
88
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
69
89
  result = send_jsonrpc_request(json_rpc_request)
70
- return unless result.is_a?(Hash)
90
+ unless result.is_a?(Hash)
91
+ # A non-object initialize result means the handshake did not succeed.
92
+ # Continuing would enter the Operation phase without ever sending the
93
+ # mandatory notifications/initialized (MCP lifecycle "Initialization":
94
+ # "After successful initialization, the client MUST send an
95
+ # `initialized` notification"), so fail the connection instead.
96
+ cleanup
97
+ raise MCPClient::Errors::ConnectionError,
98
+ "Invalid initialize response from server: expected an object, got #{result.inspect}"
99
+ end
71
100
 
101
+ # Disconnects if the server negotiated a version we cannot speak.
102
+ @protocol_version = validate_protocol_version!(result)
72
103
  @server_info = result['serverInfo']
73
104
  @capabilities = result['capabilities']
105
+ @instructions = result['instructions']
74
106
 
75
107
  # Send initialized notification to acknowledge completion of initialization
76
108
  initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
@@ -86,7 +118,7 @@ module MCPClient
86
118
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
87
119
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
88
120
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
89
- def send_jsonrpc_request(request)
121
+ def send_jsonrpc_request(request, timeout: nil)
90
122
  @logger.debug("Sending JSON-RPC request: #{request.to_json}")
91
123
  record_activity
92
124
 
@@ -94,7 +126,7 @@ module MCPClient
94
126
  response = post_json_rpc_request(request)
95
127
 
96
128
  if @use_sse
97
- wait_for_sse_result(request)
129
+ wait_for_sse_result(request, timeout: timeout)
98
130
  else
99
131
  parse_direct_response(response)
100
132
  end
@@ -133,6 +165,8 @@ module MCPClient
133
165
  end
134
166
 
135
167
  response
168
+ rescue Faraday::TimeoutError => e
169
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
136
170
  rescue Faraday::ConnectionFailed => e
137
171
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
138
172
  end
@@ -160,6 +194,11 @@ module MCPClient
160
194
  response = conn.post(endpoint) do |req|
161
195
  req.headers['Content-Type'] = 'application/json'
162
196
  req.headers['Accept'] = 'application/json'
197
+ # MCP lifecycle "Version Negotiation": the client MUST include the
198
+ # MCP-Protocol-Version header on all requests after the initialize
199
+ # handshake. The guard naturally skips the initialize POST itself,
200
+ # since the negotiated version is only known from its result.
201
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
163
202
  (@headers.dup.tap do |h|
164
203
  h.delete('Accept')
165
204
  h.delete('Cache-Control')
@@ -177,10 +216,10 @@ module MCPClient
177
216
  # @param request [Hash] the original JSON-RPC request
178
217
  # @return [Hash] the result data
179
218
  # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
180
- def wait_for_sse_result(request)
219
+ def wait_for_sse_result(request, timeout: nil)
181
220
  request_id = request['id']
182
221
  start_time = Time.now
183
- timeout = @read_timeout || 10
222
+ timeout ||= @read_timeout || 10
184
223
 
185
224
  ensure_sse_connection_active
186
225
 
@@ -222,12 +261,13 @@ module MCPClient
222
261
  sleep 0.1
223
262
  end
224
263
 
225
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
264
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
226
265
  end
227
266
 
228
267
  # Check if a result is available for the given request ID
229
268
  # @param request_id [Integer] the request ID to check
230
269
  # @return [Hash, nil] the result if available, nil otherwise
270
+ # @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
231
271
  def check_for_result(request_id)
232
272
  result = nil
233
273
  @mutex.synchronize do
@@ -236,12 +276,27 @@ module MCPClient
236
276
 
237
277
  if result
238
278
  record_activity
279
+ # SseParser#process_response? stores JSON-RPC error responses under
280
+ # the Symbol :error key; deliver them to the caller as ServerError
281
+ # (MCP lifecycle "Error Handling") instead of timing out.
282
+ raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
239
283
  return result
240
284
  end
241
285
 
242
286
  nil
243
287
  end
244
288
 
289
+ # Raise a ServerError for a JSON-RPC error response received over SSE,
290
+ # mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
291
+ # @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
292
+ # @raise [MCPClient::Errors::ServerError] always
293
+ def raise_sse_error_response(error)
294
+ error ||= {}
295
+ message = error['message'] || 'Unknown server error'
296
+ message = "#{message} (code #{error['code']})" if error['code']
297
+ raise MCPClient::Errors::ServerError, message
298
+ end
299
+
245
300
  # Parse a direct (non-SSE) JSON-RPC response
246
301
  # @param response [Faraday::Response] the HTTP response
247
302
  # @return [Hash] the parsed result
@@ -88,9 +88,13 @@ module MCPClient
88
88
 
89
89
  cleanup
90
90
 
91
- # Clear any stale auth error from the previous connection so it does
92
- # not spuriously abort this reconnect via wait_for_connection.
93
- @mutex.synchronize { @auth_error = nil }
91
+ # Clear any stale auth/connection error from the previous connection
92
+ # so it does not spuriously abort this reconnect via
93
+ # wait_for_connection.
94
+ @mutex.synchronize do
95
+ @auth_error = nil
96
+ @connection_error = nil
97
+ end
94
98
 
95
99
  connect
96
100
  @logger.info('Successfully reconnected after ping failures')
@@ -169,11 +173,14 @@ module MCPClient
169
173
  deadline = Time.now + timeout
170
174
 
171
175
  until @connection_established
176
+ break if @connection_error
177
+
172
178
  remaining = [1, deadline - Time.now].min
173
179
  break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
174
180
  end
175
181
 
176
182
  raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
183
+ raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
177
184
 
178
185
  unless @connection_established
179
186
  cleanup
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module MCPClient
6
7
  class ServerSSE
@@ -30,7 +31,7 @@ module MCPClient
30
31
  begin
31
32
  data = JSON.parse(event[:data])
32
33
 
33
- return if process_error_in_message(data)
34
+ return if process_error_in_message?(data)
34
35
  return if process_server_request?(data)
35
36
  return if process_notification?(data)
36
37
 
@@ -44,11 +45,17 @@ module MCPClient
44
45
  end
45
46
  end
46
47
 
47
- # Process a JSON-RPC error() in the SSE stream.
48
+ # Process a connection-level JSON-RPC error payload in the SSE stream.
49
+ # Error RESPONSES (id-bearing) belong to a pending request and are
50
+ # delivered to the waiting caller via process_response? instead, per the
51
+ # MCP lifecycle "Error Handling" section (implementations SHOULD handle
52
+ # error cases such as protocol version mismatch), so they must not be
53
+ # swallowed here.
48
54
  # @param data [Hash] the parsed JSON payload
49
- # @return [Boolean] true if we saw & handled an error
50
- def process_error_in_message(data)
51
- return unless data['error']
55
+ # @return [Boolean] true if we saw & handled an id-less error
56
+ def process_error_in_message?(data)
57
+ return false unless data['error']
58
+ return false if data['id']
52
59
 
53
60
  error_message = data['error']['message'] || 'Unknown server error'
54
61
  error_code = data['error']['code']
@@ -93,8 +100,10 @@ module MCPClient
93
100
  @mutex.synchronize do
94
101
  @sse_results[data['id']] =
95
102
  if data['error']
96
- { 'isError' => true,
97
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }] }
103
+ # JSON-RPC error response: store the error under a Symbol key
104
+ # (JSON.parse only produces String keys, so this cannot collide
105
+ # with a success result) for the waiter to raise ServerError.
106
+ { error: data['error'] }
98
107
  else
99
108
  data['result']
100
109
  end
@@ -130,16 +139,44 @@ module MCPClient
130
139
  has_content ? event : nil
131
140
  end
132
141
 
133
- # Handle the special "endpoint" control frame (for SSE handshake)
142
+ # Handle the special "endpoint" control frame (for SSE handshake).
143
+ # The event data is a URI reference (MCP 2024-11-05 HTTP with SSE: the
144
+ # server sends "an `endpoint` event containing a URI for the client to
145
+ # use for sending messages") which must be resolved against the SSE
146
+ # connection URL per RFC 3986 section 5.1.3, so relative endpoint URIs
147
+ # POST to the URL the server actually designated.
134
148
  # @param data [String] the raw endpoint payload
135
149
  def handle_endpoint_event(data)
150
+ endpoint = resolve_endpoint_uri(data)
136
151
  @mutex.synchronize do
137
- @rpc_endpoint = data
152
+ @rpc_endpoint = endpoint
138
153
  @sse_connected = true
139
154
  @connection_established = true
140
155
  @connection_cv.broadcast
141
156
  end
142
157
  end
158
+
159
+ # Resolve an endpoint URI reference against the SSE connection URL
160
+ # @param data [String] the endpoint event payload (absolute or relative URI)
161
+ # @return [String] the absolute endpoint URL
162
+ def resolve_endpoint_uri(data)
163
+ URI.join(@base_url, data).to_s
164
+ rescue URI::Error => e
165
+ # The endpoint event is the handshake's core payload; an unresolvable
166
+ # URI must fail the handshake rather than deferring a broken POST
167
+ # target to the first request.
168
+ @logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
169
+ message = "Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})"
170
+ # The SSE worker thread swallows this exception with a generic rescue,
171
+ # so also record the failure cause (mirroring @auth_error) for the
172
+ # connect caller blocked in wait_for_connection to surface promptly.
173
+ @mutex.synchronize do
174
+ @connection_error = message
175
+ @connection_established = false
176
+ @connection_cv.broadcast
177
+ end
178
+ raise MCPClient::Errors::TransportError, message
179
+ end
143
180
  end
144
181
  end
145
182
  end
@@ -97,7 +97,13 @@ module MCPClient
97
97
  @connection_established = false
98
98
  @connection_cv = @mutex.new_cond
99
99
  @initialized = false
100
+ # Negotiated protocol version captured from the initialize result
101
+ # (sent as the MCP-Protocol-Version header on post-initialize requests)
102
+ @protocol_version = nil
100
103
  @auth_error = nil
104
+ # Non-auth connection failure cause (e.g. invalid endpoint event URI)
105
+ # recorded by the SSE worker for wait_for_connection to surface
106
+ @connection_error = nil
101
107
  # Whether to use SSE transport; may disable if handshake fails
102
108
  @use_sse = true
103
109
 
@@ -157,10 +163,7 @@ module MCPClient
157
163
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
158
164
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
159
165
  def get_prompt(prompt_name, parameters)
160
- rpc_request('prompts/get', {
161
- name: prompt_name,
162
- arguments: parameters
163
- })
166
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
164
167
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
165
168
  # Re-raise connection/transport errors directly to match test expectations
166
169
  raise
@@ -254,9 +257,11 @@ module MCPClient
254
257
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
255
258
  def subscribe_resource(uri)
256
259
  ensure_initialized
260
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
257
261
  rpc_request('resources/subscribe', { uri: uri })
258
262
  true
259
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
263
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
264
+ MCPClient::Errors::CapabilityError
260
265
  raise
261
266
  rescue StandardError => e
262
267
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -269,9 +274,11 @@ module MCPClient
269
274
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
270
275
  def unsubscribe_resource(uri)
271
276
  ensure_initialized
277
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
272
278
  rpc_request('resources/unsubscribe', { uri: uri })
273
279
  true
274
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
280
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
281
+ MCPClient::Errors::CapabilityError
275
282
  raise
276
283
  rescue StandardError => e
277
284
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -315,10 +322,7 @@ module MCPClient
315
322
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
316
323
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
317
324
  def call_tool(tool_name, parameters)
318
- rpc_request('tools/call', {
319
- name: tool_name,
320
- arguments: parameters
321
- })
325
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
322
326
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
323
327
  # Re-raise connection/transport errors directly to match test expectations
324
328
  raise
@@ -334,11 +338,14 @@ module MCPClient
334
338
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
335
339
  # @raise [MCPClient::Errors::ServerError] if server returns an error
336
340
  def complete(ref:, argument:, context: nil)
341
+ ensure_initialized
342
+ require_capability!('completions', method: 'completion/complete')
337
343
  params = { ref: ref, argument: argument }
338
344
  params[:context] = context if context
339
345
  result = rpc_request('completion/complete', params)
340
346
  result['completion'] || { 'values' => [] }
341
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
347
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
348
+ MCPClient::Errors::CapabilityError
342
349
  raise
343
350
  rescue StandardError => e
344
351
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -350,8 +357,11 @@ module MCPClient
350
357
  # @return [Hash] empty result on success
351
358
  # @raise [MCPClient::Errors::ServerError] if server returns an error
352
359
  def log_level=(level)
360
+ ensure_initialized
361
+ require_capability!('logging', method: 'logging/setLevel')
353
362
  rpc_request('logging/setLevel', { level: level })
354
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
363
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
364
+ MCPClient::Errors::CapabilityError
355
365
  raise
356
366
  rescue StandardError => e
357
367
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -369,6 +379,8 @@ module MCPClient
369
379
  begin
370
380
  # Don't reset auth error if it's pre-existing
371
381
  @mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
382
+ # Clear any stale connection failure cause from a previous attempt
383
+ @mutex.synchronize { @connection_error = nil }
372
384
 
373
385
  start_sse_thread
374
386
  effective_timeout = [@read_timeout || 30, 30].min
@@ -404,6 +416,10 @@ module MCPClient
404
416
  @connection_established = false
405
417
  @sse_connected = false
406
418
  @initialized = false # Reset initialization state for reconnection
419
+ # A fresh session negotiates its own protocol version; keeping the old
420
+ # one would leak the previous session's version into the next
421
+ # initialize POST's MCP-Protocol-Version header.
422
+ @protocol_version = nil
407
423
 
408
424
  # Reset the SSE parse buffer so a reconnect never inherits a leftover
409
425
  # partial event from the previous connection.
@@ -527,18 +543,19 @@ module MCPClient
527
543
  # @param params [Hash] the elicitation parameters
528
544
  # @return [void]
529
545
  def handle_elicitation_create(request_id, params)
530
- # If no callback is registered, decline the request
546
+ # Without a callback there is no user to interact with: answer with a
547
+ # JSON-RPC error rather than fabricating a user "decline".
531
548
  unless @elicitation_request_callback
532
- @logger.warn('Received elicitation request but no callback registered, declining')
533
- send_elicitation_response(request_id, { 'action' => 'decline' })
549
+ @logger.warn('Received elicitation request but no callback registered')
550
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
534
551
  return
535
552
  end
536
553
 
537
554
  # Call the registered callback
538
555
  result = @elicitation_request_callback.call(request_id, params)
539
556
 
540
- # Send the response back to the server
541
- send_elicitation_response(request_id, result)
557
+ # Send the response back to the server (echoing related-task _meta)
558
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
542
559
  end
543
560
 
544
561
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -556,8 +573,8 @@ module MCPClient
556
573
  # Call the registered callback
557
574
  result = @roots_list_request_callback.call(request_id, params)
558
575
 
559
- # Send the response back to the server
560
- send_roots_list_response(request_id, result)
576
+ # Send the response back to the server (echoing related-task _meta)
577
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
561
578
  end
562
579
 
563
580
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -594,8 +611,8 @@ module MCPClient
594
611
  # Call the registered callback
595
612
  result = @sampling_request_callback.call(request_id, params)
596
613
 
597
- # Send the response back to the server
598
- send_sampling_response(request_id, result)
614
+ # Send the response back to the server (echoing related-task _meta)
615
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
599
616
  end
600
617
 
601
618
  # Send sampling response back to server via HTTP POST (MCP 2025-11-25)
@@ -628,6 +645,14 @@ module MCPClient
628
645
  # @param result [Hash] the elicitation result (action and optional content)
629
646
  # @return [void]
630
647
  def send_elicitation_response(request_id, result)
648
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
649
+ # an undeclared elicitation mode), mirroring the sampling error path.
650
+ if result.is_a?(Hash) && result['error']
651
+ send_error_response(request_id, result['error']['code'] || -32_603,
652
+ result['error']['message'] || 'Elicitation error')
653
+ return
654
+ end
655
+
631
656
  ensure_initialized
632
657
 
633
658
  response = {
@@ -685,6 +710,9 @@ module MCPClient
685
710
  @rpc_conn.post do |req|
686
711
  req.url @rpc_endpoint
687
712
  req.headers['Content-Type'] = 'application/json'
713
+ # MCP lifecycle "Version Negotiation": include the MCP-Protocol-Version
714
+ # header on all HTTP requests after the initialize handshake.
715
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
688
716
  @headers.each { |k, v| req.headers[k] = v }
689
717
  req.body = json_body
690
718
  end