ruby-mcp-client 1.1.0 → 2.1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Self-contained JSON Schema validator used to check a tool call result's
5
+ # structuredContent against the tool's declared outputSchema (MCP 2025-11-25
6
+ # server/tools spec: "Clients SHOULD validate structured results against this
7
+ # schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).
8
+ #
9
+ # Only the common JSON Schema keywords are supported:
10
+ # - type (single value or array of values), enum, const
11
+ # - properties, required (objects)
12
+ # - items, minItems, maxItems (arrays)
13
+ # - minLength, maxLength, pattern (strings)
14
+ # - minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
15
+ #
16
+ # The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not,
17
+ # conditional keywords, additionalProperties, format assertions, ...) is out
18
+ # of scope: unrecognized keywords are ignored rather than misapplied, so
19
+ # validation is best-effort — it may accept data a full validator would
20
+ # reject, but it does not reject data that conforms to the schema. So that
21
+ # this gap is never silent, {.unsupported_keywords} reports which unapplied
22
+ # validation keywords a schema uses; callers surface them as a warning.
23
+ module SchemaValidator
24
+ # JSON Schema 2020-12 keywords that affect validation but that this
25
+ # validator does not evaluate: applicator/reference keywords, assertion
26
+ # keywords (multipleOf, uniqueItems, contains bounds, property-count
27
+ # bounds, dependentRequired), and format (asserted by full validators in
28
+ # format-assertion mode). Their presence means validation is partial: data
29
+ # may pass here that a full validator would reject.
30
+ UNSUPPORTED_KEYWORDS = %w[
31
+ $ref $dynamicRef $defs allOf anyOf oneOf not if then else
32
+ additionalProperties patternProperties propertyNames dependentSchemas
33
+ prefixItems contains minContains maxContains uniqueItems
34
+ multipleOf format dependentRequired minProperties maxProperties
35
+ unevaluatedProperties unevaluatedItems
36
+ ].freeze
37
+
38
+ # Wall-clock budget for ALL pattern matching in a single validate call.
39
+ # Schemas come from the remote server, so an expensive expression must not
40
+ # be able to monopolize the calling thread.
41
+ #
42
+ # The budget is for the whole operation, not per match: a per-match limit
43
+ # multiplies, since the server also controls how many strings it sends
44
+ # (N array items under one pathological items.pattern costs N x limit).
45
+ PATTERN_MATCH_TIMEOUT = 1.0
46
+
47
+ # Floor for an individual match's timeout, so a nearly-exhausted budget
48
+ # still makes progress rather than failing every remaining pattern.
49
+ MIN_PATTERN_MATCH_TIMEOUT = 0.01
50
+
51
+ # Keywords whose value is a single subschema to walk.
52
+ SUBSCHEMA_KEYWORDS = %w[
53
+ items contains additionalProperties propertyNames not if then else
54
+ unevaluatedItems unevaluatedProperties
55
+ ].freeze
56
+
57
+ # Keywords whose value is a map of name => subschema.
58
+ SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
59
+
60
+ # Keywords whose value is an array of subschemas.
61
+ SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
62
+
63
+ # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
64
+ # top level or nested in subschemas). Property names that merely look like
65
+ # keywords (e.g. a property called 'not') are not reported, and
66
+ # data-carrying keywords (enum/const/default/examples) are not scanned.
67
+ # @param schema [Object] the JSON schema (string or symbol keys)
68
+ # @return [Array<String>] unique unsupported keywords, in discovery order
69
+ def self.unsupported_keywords(schema)
70
+ found = []
71
+ collect_unsupported_keywords(schema, found)
72
+ found.uniq
73
+ end
74
+
75
+ # Recursively collect unsupported keywords from a schema.
76
+ # @param schema [Object] a (sub)schema; non-Hash values are ignored
77
+ # @param found [Array<String>] accumulator
78
+ # @return [void]
79
+ def self.collect_unsupported_keywords(schema, found)
80
+ return unless schema.is_a?(Hash)
81
+
82
+ schema = schema.transform_keys(&:to_s)
83
+ found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
84
+ schema.each do |keyword, value|
85
+ if SUBSCHEMA_KEYWORDS.include?(keyword)
86
+ collect_unsupported_keywords(value, found)
87
+ elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
88
+ value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
89
+ elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
90
+ value.each { |subschema| collect_unsupported_keywords(subschema, found) }
91
+ end
92
+ end
93
+ end
94
+
95
+ # Validate data against a JSON Schema subset.
96
+ # Schema and data hashes may use string or symbol keys.
97
+ # @param data [Object] the value to validate
98
+ # @param schema [Hash] the JSON schema
99
+ # @param path [String] JSON-pointer-style location used in error messages
100
+ # @return [Array<String>] human-readable validation errors (empty if valid)
101
+ def self.validate(data, schema, path: '#', deadline: nil)
102
+ return [] unless schema.is_a?(Hash)
103
+
104
+ # One deadline covers the entire (recursive) validation.
105
+ deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
106
+
107
+ schema = schema.transform_keys(&:to_s)
108
+ errors = []
109
+ errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
110
+ errors.concat(validate_enum(data, schema, path))
111
+ case data
112
+ when Hash then errors.concat(validate_object(data, schema, path, deadline))
113
+ when Array then errors.concat(validate_array(data, schema, path, deadline))
114
+ when String then errors.concat(validate_string(data, schema, path, deadline))
115
+ when Numeric then errors.concat(validate_number(data, schema, path))
116
+ end
117
+ errors
118
+ end
119
+
120
+ # Validate the JSON type of a value.
121
+ # @param data [Object] the value
122
+ # @param type [String, Symbol, Array<String, Symbol>] expected type(s)
123
+ # @param path [String] location for error messages
124
+ # @return [Array<String>] validation errors
125
+ def self.validate_type(data, type, path)
126
+ types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
127
+ return [] if types.any? { |t| type_match?(t, data) }
128
+
129
+ ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
130
+ end
131
+
132
+ # Whether a value matches a JSON Schema type name.
133
+ # Unknown type names are not enforced (returns true).
134
+ # @param type [String] the JSON Schema type name
135
+ # @param data [Object] the value
136
+ # @return [Boolean]
137
+ def self.type_match?(type, data)
138
+ case type
139
+ when 'object' then data.is_a?(Hash)
140
+ when 'array' then data.is_a?(Array)
141
+ when 'string' then data.is_a?(String)
142
+ when 'boolean' then data.equal?(true) || data.equal?(false)
143
+ when 'null' then data.nil?
144
+ when 'number' then data.is_a?(Numeric)
145
+ when 'integer' then integer?(data)
146
+ else true
147
+ end
148
+ end
149
+
150
+ # Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a
151
+ # number with a zero fractional part (e.g. 2.0) is a valid integer.
152
+ # @param data [Object] the value
153
+ # @return [Boolean]
154
+ def self.integer?(data)
155
+ return true if data.is_a?(Integer)
156
+ return false unless data.is_a?(Numeric)
157
+
158
+ (data % 1).zero?
159
+ end
160
+
161
+ # The JSON type name of a Ruby value (for error messages).
162
+ # @param data [Object] the value
163
+ # @return [String]
164
+ def self.json_type(data)
165
+ case data
166
+ when nil then 'null'
167
+ when true, false then 'boolean'
168
+ when Integer then 'integer'
169
+ when Numeric then 'number'
170
+ when String then 'string'
171
+ when Array then 'array'
172
+ when Hash then 'object'
173
+ else data.class.name
174
+ end
175
+ end
176
+
177
+ # Validate enum/const membership.
178
+ # @param data [Object] the value
179
+ # @param schema [Hash] string-keyed schema
180
+ # @param path [String] location for error messages
181
+ # @return [Array<String>] validation errors
182
+ def self.validate_enum(data, schema, path)
183
+ errors = []
184
+ if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
185
+ errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
186
+ end
187
+ if schema.key?('const') && schema['const'] != data
188
+ errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
189
+ end
190
+ errors
191
+ end
192
+
193
+ # Validate an object against required/properties.
194
+ # @param data [Hash] the object
195
+ # @param schema [Hash] string-keyed schema
196
+ # @param path [String] location for error messages
197
+ # @return [Array<String>] validation errors
198
+ def self.validate_object(data, schema, path, deadline = nil)
199
+ errors = []
200
+ Array(schema['required']).each do |raw_name|
201
+ name = raw_name.to_s
202
+ errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
203
+ end
204
+ properties = schema['properties']
205
+ return errors unless properties.is_a?(Hash)
206
+
207
+ properties.each do |raw_name, prop_schema|
208
+ next unless prop_schema.is_a?(Hash)
209
+
210
+ name = raw_name.to_s
211
+ key = if data.key?(name)
212
+ name
213
+ elsif data.key?(name.to_sym)
214
+ name.to_sym
215
+ end
216
+ next if key.nil?
217
+
218
+ errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
219
+ end
220
+ errors
221
+ end
222
+
223
+ # Validate an array against items/minItems/maxItems.
224
+ # @param data [Array] the array
225
+ # @param schema [Hash] string-keyed schema
226
+ # @param path [String] location for error messages
227
+ # @return [Array<String>] validation errors
228
+ def self.validate_array(data, schema, path, deadline = nil)
229
+ errors = []
230
+ min_items = schema['minItems']
231
+ max_items = schema['maxItems']
232
+ if min_items.is_a?(Numeric) && data.length < min_items
233
+ errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
234
+ end
235
+ if max_items.is_a?(Numeric) && data.length > max_items
236
+ errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
237
+ end
238
+ items = schema['items']
239
+ if items.is_a?(Hash)
240
+ data.each_with_index do |item, idx|
241
+ errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
242
+ end
243
+ end
244
+ errors
245
+ end
246
+
247
+ # Validate a string against minLength/maxLength/pattern.
248
+ # @param data [String] the string
249
+ # @param schema [Hash] string-keyed schema
250
+ # @param path [String] location for error messages
251
+ # @return [Array<String>] validation errors
252
+ def self.validate_string(data, schema, path, deadline = nil)
253
+ errors = []
254
+ min_length = schema['minLength']
255
+ max_length = schema['maxLength']
256
+ if min_length.is_a?(Numeric) && data.length < min_length
257
+ errors << "#{path}: string is shorter than minLength #{min_length}"
258
+ end
259
+ if max_length.is_a?(Numeric) && data.length > max_length
260
+ errors << "#{path}: string is longer than maxLength #{max_length}"
261
+ end
262
+ errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
263
+ errors
264
+ end
265
+
266
+ # Validate a string against a regular-expression pattern.
267
+ # Invalid patterns are not enforced.
268
+ #
269
+ # The pattern comes from the tool's outputSchema, i.e. from the remote
270
+ # server, so matching runs against the validation-wide deadline: neither a
271
+ # single expensive expression nor many cheap-looking ones can pin the
272
+ # calling thread. A match that exceeds the budget is reported as a
273
+ # validation error rather than silently accepted — the value was never
274
+ # shown to satisfy the schema.
275
+ # @param data [String] the string
276
+ # @param pattern [Object] the pattern keyword value
277
+ # @param path [String] location for error messages
278
+ # @param deadline [Float, nil] monotonic deadline for the whole validation
279
+ # @return [Array<String>] validation errors
280
+ def self.validate_pattern(data, pattern, path, deadline = nil)
281
+ return [] unless pattern.is_a?(String)
282
+
283
+ remaining = pattern_budget_remaining(deadline)
284
+ return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?
285
+
286
+ return [] if data.match?(Regexp.new(pattern, timeout: remaining))
287
+
288
+ ["#{path}: string does not match pattern #{pattern.inspect}"]
289
+ rescue Regexp::TimeoutError
290
+ ["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
291
+ rescue RegexpError
292
+ []
293
+ end
294
+
295
+ # Time left in the validation-wide pattern budget.
296
+ # @param deadline [Float, nil] monotonic deadline, or nil for a lone match
297
+ # @return [Float] seconds available for the next match; 0.0 when exhausted
298
+ def self.pattern_budget_remaining(deadline)
299
+ return PATTERN_MATCH_TIMEOUT unless deadline
300
+
301
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
302
+ return 0.0 if remaining <= 0
303
+
304
+ [remaining, MIN_PATTERN_MATCH_TIMEOUT].max
305
+ end
306
+
307
+ # Validate a number against inclusive/exclusive bounds.
308
+ # @param data [Numeric] the number
309
+ # @param schema [Hash] string-keyed schema
310
+ # @param path [String] location for error messages
311
+ # @return [Array<String>] validation errors
312
+ def self.validate_number(data, schema, path)
313
+ errors = []
314
+ minimum = schema['minimum']
315
+ maximum = schema['maximum']
316
+ exclusive_min = schema['exclusiveMinimum']
317
+ exclusive_max = schema['exclusiveMaximum']
318
+ errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
319
+ errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
320
+ if exclusive_min.is_a?(Numeric) && data <= exclusive_min
321
+ errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
322
+ end
323
+ if exclusive_max.is_a?(Numeric) && data >= exclusive_max
324
+ errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
325
+ end
326
+ errors
327
+ end
328
+ end
329
+ end
@@ -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'
@@ -99,7 +99,10 @@ module MCPClient
99
99
  name: config[:name],
100
100
  logger: logger,
101
101
  oauth_provider: config[:oauth_provider],
102
- faraday_config: config[:faraday_config]
102
+ faraday_config: config[:faraday_config],
103
+ max_decompressed_body_bytes:
104
+ config[:max_decompressed_body_bytes] ||
105
+ MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
103
106
  )
104
107
  end
105
108
 
@@ -12,15 +12,16 @@ 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)
22
23
  rescue JSON::ParserError => e
23
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
24
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
24
25
  end
25
26
  end
26
27
  end
@@ -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}"