ruby-mcp-client 1.0.1 → 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.
@@ -0,0 +1,285 @@
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
+ # Keywords whose value is a single subschema to walk.
39
+ SUBSCHEMA_KEYWORDS = %w[
40
+ items contains additionalProperties propertyNames not if then else
41
+ unevaluatedItems unevaluatedProperties
42
+ ].freeze
43
+
44
+ # Keywords whose value is a map of name => subschema.
45
+ SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
46
+
47
+ # Keywords whose value is an array of subschemas.
48
+ SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
49
+
50
+ # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
51
+ # top level or nested in subschemas). Property names that merely look like
52
+ # keywords (e.g. a property called 'not') are not reported, and
53
+ # data-carrying keywords (enum/const/default/examples) are not scanned.
54
+ # @param schema [Object] the JSON schema (string or symbol keys)
55
+ # @return [Array<String>] unique unsupported keywords, in discovery order
56
+ def self.unsupported_keywords(schema)
57
+ found = []
58
+ collect_unsupported_keywords(schema, found)
59
+ found.uniq
60
+ end
61
+
62
+ # Recursively collect unsupported keywords from a schema.
63
+ # @param schema [Object] a (sub)schema; non-Hash values are ignored
64
+ # @param found [Array<String>] accumulator
65
+ # @return [void]
66
+ def self.collect_unsupported_keywords(schema, found)
67
+ return unless schema.is_a?(Hash)
68
+
69
+ schema = schema.transform_keys(&:to_s)
70
+ found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
71
+ schema.each do |keyword, value|
72
+ if SUBSCHEMA_KEYWORDS.include?(keyword)
73
+ collect_unsupported_keywords(value, found)
74
+ elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
75
+ value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
76
+ elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
77
+ value.each { |subschema| collect_unsupported_keywords(subschema, found) }
78
+ end
79
+ end
80
+ end
81
+
82
+ # Validate data against a JSON Schema subset.
83
+ # Schema and data hashes may use string or symbol keys.
84
+ # @param data [Object] the value to validate
85
+ # @param schema [Hash] the JSON schema
86
+ # @param path [String] JSON-pointer-style location used in error messages
87
+ # @return [Array<String>] human-readable validation errors (empty if valid)
88
+ def self.validate(data, schema, path: '#')
89
+ return [] unless schema.is_a?(Hash)
90
+
91
+ schema = schema.transform_keys(&:to_s)
92
+ errors = []
93
+ errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
94
+ errors.concat(validate_enum(data, schema, path))
95
+ case data
96
+ when Hash then errors.concat(validate_object(data, schema, path))
97
+ when Array then errors.concat(validate_array(data, schema, path))
98
+ when String then errors.concat(validate_string(data, schema, path))
99
+ when Numeric then errors.concat(validate_number(data, schema, path))
100
+ end
101
+ errors
102
+ end
103
+
104
+ # Validate the JSON type of a value.
105
+ # @param data [Object] the value
106
+ # @param type [String, Symbol, Array<String, Symbol>] expected type(s)
107
+ # @param path [String] location for error messages
108
+ # @return [Array<String>] validation errors
109
+ def self.validate_type(data, type, path)
110
+ types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
111
+ return [] if types.any? { |t| type_match?(t, data) }
112
+
113
+ ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
114
+ end
115
+
116
+ # Whether a value matches a JSON Schema type name.
117
+ # Unknown type names are not enforced (returns true).
118
+ # @param type [String] the JSON Schema type name
119
+ # @param data [Object] the value
120
+ # @return [Boolean]
121
+ def self.type_match?(type, data)
122
+ case type
123
+ when 'object' then data.is_a?(Hash)
124
+ when 'array' then data.is_a?(Array)
125
+ when 'string' then data.is_a?(String)
126
+ when 'boolean' then data.equal?(true) || data.equal?(false)
127
+ when 'null' then data.nil?
128
+ when 'number' then data.is_a?(Numeric)
129
+ when 'integer' then integer?(data)
130
+ else true
131
+ end
132
+ end
133
+
134
+ # Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a
135
+ # number with a zero fractional part (e.g. 2.0) is a valid integer.
136
+ # @param data [Object] the value
137
+ # @return [Boolean]
138
+ def self.integer?(data)
139
+ return true if data.is_a?(Integer)
140
+ return false unless data.is_a?(Numeric)
141
+
142
+ (data % 1).zero?
143
+ end
144
+
145
+ # The JSON type name of a Ruby value (for error messages).
146
+ # @param data [Object] the value
147
+ # @return [String]
148
+ def self.json_type(data)
149
+ case data
150
+ when nil then 'null'
151
+ when true, false then 'boolean'
152
+ when Integer then 'integer'
153
+ when Numeric then 'number'
154
+ when String then 'string'
155
+ when Array then 'array'
156
+ when Hash then 'object'
157
+ else data.class.name
158
+ end
159
+ end
160
+
161
+ # Validate enum/const membership.
162
+ # @param data [Object] the value
163
+ # @param schema [Hash] string-keyed schema
164
+ # @param path [String] location for error messages
165
+ # @return [Array<String>] validation errors
166
+ def self.validate_enum(data, schema, path)
167
+ errors = []
168
+ if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
169
+ errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
170
+ end
171
+ if schema.key?('const') && schema['const'] != data
172
+ errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
173
+ end
174
+ errors
175
+ end
176
+
177
+ # Validate an object against required/properties.
178
+ # @param data [Hash] the object
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_object(data, schema, path)
183
+ errors = []
184
+ Array(schema['required']).each do |raw_name|
185
+ name = raw_name.to_s
186
+ errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
187
+ end
188
+ properties = schema['properties']
189
+ return errors unless properties.is_a?(Hash)
190
+
191
+ properties.each do |raw_name, prop_schema|
192
+ next unless prop_schema.is_a?(Hash)
193
+
194
+ name = raw_name.to_s
195
+ key = if data.key?(name)
196
+ name
197
+ elsif data.key?(name.to_sym)
198
+ name.to_sym
199
+ end
200
+ next if key.nil?
201
+
202
+ errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}"))
203
+ end
204
+ errors
205
+ end
206
+
207
+ # Validate an array against items/minItems/maxItems.
208
+ # @param data [Array] the array
209
+ # @param schema [Hash] string-keyed schema
210
+ # @param path [String] location for error messages
211
+ # @return [Array<String>] validation errors
212
+ def self.validate_array(data, schema, path)
213
+ errors = []
214
+ min_items = schema['minItems']
215
+ max_items = schema['maxItems']
216
+ if min_items.is_a?(Numeric) && data.length < min_items
217
+ errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
218
+ end
219
+ if max_items.is_a?(Numeric) && data.length > max_items
220
+ errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
221
+ end
222
+ items = schema['items']
223
+ if items.is_a?(Hash)
224
+ data.each_with_index { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
225
+ end
226
+ errors
227
+ end
228
+
229
+ # Validate a string against minLength/maxLength/pattern.
230
+ # @param data [String] the string
231
+ # @param schema [Hash] string-keyed schema
232
+ # @param path [String] location for error messages
233
+ # @return [Array<String>] validation errors
234
+ def self.validate_string(data, schema, path)
235
+ errors = []
236
+ min_length = schema['minLength']
237
+ max_length = schema['maxLength']
238
+ if min_length.is_a?(Numeric) && data.length < min_length
239
+ errors << "#{path}: string is shorter than minLength #{min_length}"
240
+ end
241
+ if max_length.is_a?(Numeric) && data.length > max_length
242
+ errors << "#{path}: string is longer than maxLength #{max_length}"
243
+ end
244
+ errors.concat(validate_pattern(data, schema['pattern'], path))
245
+ errors
246
+ end
247
+
248
+ # Validate a string against a regular-expression pattern.
249
+ # Invalid patterns are not enforced.
250
+ # @param data [String] the string
251
+ # @param pattern [Object] the pattern keyword value
252
+ # @param path [String] location for error messages
253
+ # @return [Array<String>] validation errors
254
+ def self.validate_pattern(data, pattern, path)
255
+ return [] unless pattern.is_a?(String)
256
+ return [] if data.match?(Regexp.new(pattern))
257
+
258
+ ["#{path}: string does not match pattern #{pattern.inspect}"]
259
+ rescue RegexpError
260
+ []
261
+ end
262
+
263
+ # Validate a number against inclusive/exclusive bounds.
264
+ # @param data [Numeric] the number
265
+ # @param schema [Hash] string-keyed schema
266
+ # @param path [String] location for error messages
267
+ # @return [Array<String>] validation errors
268
+ def self.validate_number(data, schema, path)
269
+ errors = []
270
+ minimum = schema['minimum']
271
+ maximum = schema['maximum']
272
+ exclusive_min = schema['exclusiveMinimum']
273
+ exclusive_max = schema['exclusiveMaximum']
274
+ errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
275
+ errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
276
+ if exclusive_min.is_a?(Numeric) && data <= exclusive_min
277
+ errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
278
+ end
279
+ if exclusive_max.is_a?(Numeric) && data >= exclusive_max
280
+ errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
281
+ end
282
+ errors
283
+ end
284
+ end
285
+ 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'
@@ -133,8 +199,81 @@ module MCPClient
133
199
  @notification_callback = block
134
200
  end
135
201
 
202
+ # Safety bound on the number of pages followed when auto-paginating a
203
+ # cursor-based list operation, to protect against a server that returns
204
+ # a nextCursor indefinitely.
205
+ MAX_LIST_PAGES = 1000
206
+
136
207
  protected
137
208
 
209
+ # Follow cursor-based pagination across pages, collecting every item.
210
+ #
211
+ # Yields the current cursor (nil for the first page) and expects the block
212
+ # to return a two-element array: [items_for_this_page, next_cursor]. The
213
+ # loop stops when next_cursor is nil or empty, when a cursor repeats
214
+ # (malformed server), or when MAX_LIST_PAGES pages have been fetched.
215
+ #
216
+ # @param kind [String] label used in diagnostic log messages
217
+ # @yieldparam cursor [String, nil] cursor for the page to fetch
218
+ # @yieldreturn [Array(Array, String), Array(Array, nil)] page items and next cursor
219
+ # @return [Array] all items collected across pages
220
+ def collect_paginated(kind = 'items')
221
+ items = []
222
+ cursor = nil
223
+ seen_cursors = {}
224
+ pages = 0
225
+
226
+ loop do
227
+ page_items, next_cursor = yield(cursor)
228
+ items.concat(Array(page_items))
229
+ pages += 1
230
+
231
+ break if next_cursor.nil? || next_cursor.to_s.empty?
232
+
233
+ if seen_cursors[next_cursor]
234
+ @logger.warn("Pagination for #{kind} stopped: server returned a repeated cursor #{next_cursor.inspect}")
235
+ break
236
+ end
237
+ if pages >= MAX_LIST_PAGES
238
+ @logger.warn("Pagination for #{kind} stopped after #{pages} pages (safety bound reached)")
239
+ break
240
+ end
241
+
242
+ seen_cursors[next_cursor] = true
243
+ cursor = next_cursor
244
+ end
245
+
246
+ items
247
+ end
248
+
249
+ # Fetch a full, cursor-paginated list result via rpc_request, following
250
+ # nextCursor across pages until the server stops returning one.
251
+ #
252
+ # Accepts either a spec-shaped Hash ({ key => [...], 'nextCursor' => ... })
253
+ # or, leniently, a bare Array (a single unpaginated page). A response that
254
+ # is neither (e.g. a null/missing result or a scalar) is a malformed list
255
+ # response and raises, rather than being silently treated as an empty list.
256
+ #
257
+ # @param method [String] the list method, e.g. 'tools/list'
258
+ # @param key [String] the result array key, e.g. 'tools'
259
+ # @return [Array<Hash>] all raw item hashes collected across pages
260
+ # @raise [MCPClient::Errors::TransportError] if a page result is not a Hash or Array
261
+ def request_paginated_list(method, key)
262
+ collect_paginated(key) do |cursor|
263
+ params = cursor ? { cursor: cursor } : {}
264
+ result = rpc_request(method, params)
265
+ case result
266
+ when Hash
267
+ [result[key] || [], result['nextCursor']]
268
+ when Array
269
+ [result, nil]
270
+ else
271
+ raise MCPClient::Errors::TransportError,
272
+ "Invalid #{method} response: expected an object or array, got #{result.class}"
273
+ end
274
+ end
275
+ end
276
+
138
277
  # Initialize logger with proper formatter handling
139
278
  # Preserves custom formatter if logger is provided, otherwise sets a default formatter
140
279
  # @param logger [Logger, nil] custom logger to use, or nil to create a default one
@@ -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
@@ -246,8 +243,8 @@ module MCPClient
246
243
  begin
247
244
  ensure_connected
248
245
 
249
- prompts_data = rpc_request('prompts/list')
250
- prompts = prompts_data['prompts'] || []
246
+ # Follow nextCursor across pages so the full prompt list is returned.
247
+ prompts = request_paginated_list('prompts/list', 'prompts')
251
248
 
252
249
  @mutex.synchronize do
253
250
  @prompts = prompts.map do |prompt_data|
@@ -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}"
@@ -448,6 +454,25 @@ module MCPClient
448
454
 
449
455
  private
450
456
 
457
+ # Perform the MCP initialize handshake, then announce readiness.
458
+ #
459
+ # The base handshake sends the initialize request and captures
460
+ # serverInfo/capabilities. Per the MCP lifecycle the client MUST send an
461
+ # `initialized` notification after a successful initialize before issuing
462
+ # any other requests; the plain HTTP transport previously skipped it.
463
+ # @return [void]
464
+ # @raise [MCPClient::Errors::TransportError] if the notification fails to send
465
+ def perform_initialize
466
+ super
467
+
468
+ notification = build_jsonrpc_notification('notifications/initialized', {})
469
+ begin
470
+ send_http_request(notification)
471
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
472
+ raise MCPClient::Errors::TransportError, "Failed to send initialized notification: #{e.message}"
473
+ end
474
+ end
475
+
451
476
  # Default options for server initialization
452
477
  # @return [Hash] Default options
453
478
  def default_options
@@ -497,24 +522,15 @@ module MCPClient
497
522
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
498
523
  def request_tools_list
499
524
  @mutex.synchronize do
500
- return @tools_data if @tools_data
525
+ return @tools_data.dup if @tools_data
501
526
  end
502
527
 
503
- result = rpc_request('tools/list')
504
-
505
- if result && result['tools']
506
- @mutex.synchronize do
507
- @tools_data = result['tools']
508
- end
509
- return @mutex.synchronize { @tools_data.dup }
510
- elsif result
511
- @mutex.synchronize do
512
- @tools_data = result
513
- end
514
- return @mutex.synchronize { @tools_data.dup }
515
- end
528
+ # Follow nextCursor across pages so the full tool list is returned even
529
+ # when the server paginates.
530
+ tools = request_paginated_list('tools/list', 'tools')
516
531
 
517
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
532
+ @mutex.synchronize { @tools_data = tools }
533
+ @mutex.synchronize { @tools_data.dup }
518
534
  end
519
535
  end
520
536
  end