ruby-mcp-client 0.9.0 → 1.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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Validates elicitation schemas and content per MCP 2025-11-25 spec.
5
+ # Schemas are restricted to flat objects with primitive property types:
6
+ # string (with optional enum, pattern, format, minLength, maxLength)
7
+ # number / integer (with optional minimum, maximum)
8
+ # boolean
9
+ # array (multi-select enum only, with items containing enum or anyOf)
10
+ module ElicitationValidator
11
+ # Allowed primitive types for schema properties
12
+ PRIMITIVE_TYPES = %w[string number integer boolean].freeze
13
+
14
+ # Allowed string formats per MCP spec
15
+ STRING_FORMATS = %w[email uri date date-time].freeze
16
+
17
+ # Validate that a requestedSchema conforms to MCP elicitation constraints.
18
+ # Returns an array of error messages (empty if valid).
19
+ # @param schema [Hash] the requestedSchema
20
+ # @return [Array<String>] validation errors
21
+ def self.validate_schema(schema)
22
+ errors = []
23
+ return errors unless schema.is_a?(Hash)
24
+
25
+ unless schema['type'] == 'object'
26
+ errors << "Schema type must be 'object', got '#{schema['type']}'"
27
+ return errors
28
+ end
29
+
30
+ properties = schema['properties']
31
+ return errors unless properties.is_a?(Hash)
32
+
33
+ properties.each do |name, prop|
34
+ errors.concat(validate_property(name, prop))
35
+ end
36
+
37
+ errors
38
+ end
39
+
40
+ # Validate a single property definition.
41
+ # @param name [String] property name
42
+ # @param prop [Hash] property schema
43
+ # @return [Array<String>] validation errors
44
+ def self.validate_property(name, prop)
45
+ errors = []
46
+ return errors unless prop.is_a?(Hash)
47
+
48
+ type = prop['type']
49
+
50
+ if type == 'array'
51
+ errors.concat(validate_array_property(name, prop))
52
+ elsif PRIMITIVE_TYPES.include?(type)
53
+ errors.concat(validate_primitive_property(name, prop))
54
+ else
55
+ errors << "Property '#{name}' has unsupported type '#{type}'"
56
+ end
57
+
58
+ errors
59
+ end
60
+
61
+ # Validate a primitive property (string, number, integer, boolean).
62
+ # @param name [String] property name
63
+ # @param prop [Hash] property schema
64
+ # @return [Array<String>] validation errors
65
+ def self.validate_primitive_property(name, prop)
66
+ errors = []
67
+ type = prop['type']
68
+
69
+ case type
70
+ when 'string'
71
+ if prop['format'] && !STRING_FORMATS.include?(prop['format'])
72
+ errors << "Property '#{name}' has unsupported format '#{prop['format']}'"
73
+ end
74
+ errors << "Property '#{name}' enum must be an array" if prop['enum'] && !prop['enum'].is_a?(Array)
75
+ when 'number', 'integer'
76
+ if prop.key?('minimum') && !prop['minimum'].is_a?(Numeric)
77
+ errors << "Property '#{name}' minimum must be numeric"
78
+ end
79
+ if prop.key?('maximum') && !prop['maximum'].is_a?(Numeric)
80
+ errors << "Property '#{name}' maximum must be numeric"
81
+ end
82
+ end
83
+
84
+ errors
85
+ end
86
+
87
+ # Validate an array property (multi-select enum only).
88
+ # @param name [String] property name
89
+ # @param prop [Hash] property schema
90
+ # @return [Array<String>] validation errors
91
+ def self.validate_array_property(name, prop)
92
+ errors = []
93
+ items = prop['items']
94
+
95
+ unless items.is_a?(Hash)
96
+ errors << "Property '#{name}' array type requires 'items' definition"
97
+ return errors
98
+ end
99
+
100
+ has_enum = items['enum'].is_a?(Array)
101
+ has_any_of = items['anyOf'].is_a?(Array)
102
+
103
+ errors << "Property '#{name}' array items must have 'enum' or 'anyOf'" unless has_enum || has_any_of
104
+
105
+ errors
106
+ end
107
+
108
+ # Validate content against a requestedSchema.
109
+ # Returns an array of error messages (empty if valid).
110
+ # @param content [Hash] the response content
111
+ # @param schema [Hash] the requestedSchema
112
+ # @return [Array<String>] validation errors
113
+ def self.validate_content(content, schema)
114
+ errors = []
115
+ return errors unless content.is_a?(Hash) && schema.is_a?(Hash)
116
+
117
+ properties = schema['properties'] || {}
118
+ required = Array(schema['required'])
119
+
120
+ # Check required fields
121
+ required.each do |field|
122
+ field_s = field.to_s
123
+ errors << "Missing required field '#{field_s}'" unless content.key?(field_s) || content.key?(field_s.to_sym)
124
+ end
125
+
126
+ # Validate each provided field
127
+ content.each do |field, value|
128
+ prop = properties[field.to_s]
129
+ next unless prop.is_a?(Hash)
130
+
131
+ errors.concat(validate_value(field.to_s, value, prop))
132
+ end
133
+
134
+ errors
135
+ end
136
+
137
+ # Validate a single value against its property schema.
138
+ # @param field [String] field name
139
+ # @param value [Object] the value to validate
140
+ # @param prop [Hash] property schema
141
+ # @return [Array<String>] validation errors
142
+ def self.validate_value(field, value, prop)
143
+ errors = []
144
+ type = prop['type']
145
+
146
+ case type
147
+ when 'string'
148
+ errors.concat(validate_string_value(field, value, prop))
149
+ when 'number', 'integer'
150
+ errors.concat(validate_number_value(field, value, prop))
151
+ when 'boolean'
152
+ errors << "Field '#{field}' must be a boolean" unless [true, false].include?(value)
153
+ when 'array'
154
+ errors.concat(validate_array_value(field, value, prop))
155
+ end
156
+
157
+ errors
158
+ end
159
+
160
+ # Validate a string value against its property schema.
161
+ # @param field [String] field name
162
+ # @param value [Object] the value
163
+ # @param prop [Hash] property schema
164
+ # @return [Array<String>] validation errors
165
+ def self.validate_string_value(field, value, prop)
166
+ errors = []
167
+
168
+ unless value.is_a?(String)
169
+ errors << "Field '#{field}' must be a string"
170
+ return errors
171
+ end
172
+
173
+ if prop['enum'].is_a?(Array) && !prop['enum'].include?(value)
174
+ errors << "Field '#{field}' must be one of: #{prop['enum'].join(', ')}"
175
+ end
176
+
177
+ if prop['oneOf'].is_a?(Array)
178
+ allowed = prop['oneOf'].map { |o| o['const'] }
179
+ errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
180
+ end
181
+
182
+ if prop['pattern']
183
+ begin
184
+ unless value.match?(Regexp.new(prop['pattern']))
185
+ errors << "Field '#{field}' must match pattern '#{prop['pattern']}'"
186
+ end
187
+ rescue RegexpError
188
+ # Skip pattern validation if the pattern is invalid
189
+ end
190
+ end
191
+
192
+ if prop['minLength'] && value.length < prop['minLength']
193
+ errors << "Field '#{field}' must be at least #{prop['minLength']} characters"
194
+ end
195
+
196
+ if prop['maxLength'] && value.length > prop['maxLength']
197
+ errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
198
+ end
199
+
200
+ errors
201
+ end
202
+
203
+ # Validate a number value against its property schema.
204
+ # @param field [String] field name
205
+ # @param value [Object] the value
206
+ # @param prop [Hash] property schema
207
+ # @return [Array<String>] validation errors
208
+ def self.validate_number_value(field, value, prop)
209
+ errors = []
210
+
211
+ unless value.is_a?(Numeric)
212
+ errors << "Field '#{field}' must be a number"
213
+ return errors
214
+ end
215
+
216
+ errors << "Field '#{field}' must be an integer" if prop['type'] == 'integer' && !value.is_a?(Integer)
217
+
218
+ errors << "Field '#{field}' must be >= #{prop['minimum']}" if prop['minimum'] && value < prop['minimum']
219
+
220
+ errors << "Field '#{field}' must be <= #{prop['maximum']}" if prop['maximum'] && value > prop['maximum']
221
+
222
+ errors
223
+ end
224
+
225
+ # Validate an array value against its property schema (multi-select enum).
226
+ # @param field [String] field name
227
+ # @param value [Object] the value
228
+ # @param prop [Hash] property schema
229
+ # @return [Array<String>] validation errors
230
+ def self.validate_array_value(field, value, prop)
231
+ errors = []
232
+
233
+ unless value.is_a?(Array)
234
+ errors << "Field '#{field}' must be an array"
235
+ return errors
236
+ end
237
+
238
+ items = prop['items'] || {}
239
+ allowed = if items['enum'].is_a?(Array)
240
+ items['enum']
241
+ elsif items['anyOf'].is_a?(Array)
242
+ items['anyOf'].map { |o| o['const'] }
243
+ end
244
+
245
+ if allowed
246
+ value.each do |v|
247
+ errors << "Field '#{field}' contains invalid value '#{v}'" unless allowed.include?(v)
248
+ end
249
+ end
250
+
251
+ if prop['minItems'] && value.length < prop['minItems']
252
+ errors << "Field '#{field}' must have at least #{prop['minItems']} items"
253
+ end
254
+
255
+ if prop['maxItems'] && value.length > prop['maxItems']
256
+ errors << "Field '#{field}' must have at most #{prop['maxItems']} items"
257
+ end
258
+
259
+ errors
260
+ end
261
+ end
262
+ end
@@ -47,5 +47,14 @@ module MCPClient
47
47
 
48
48
  # Raised when multiple resources with the same URI exist across different servers
49
49
  class AmbiguousResourceURI < MCPError; end
50
+
51
+ # Raised when transport type cannot be determined from target URL/command
52
+ class TransportDetectionError < MCPError; end
53
+
54
+ # Raised when a task is not found
55
+ class TaskNotFound < MCPError; end
56
+
57
+ # Raised when there's an error creating or managing a task
58
+ class TaskError < MCPError; end
50
59
  end
51
60
  end
@@ -58,6 +58,7 @@ module MCPClient
58
58
  # Apply base headers but prioritize session termination headers
59
59
  @headers.each { |k, v| req.headers[k] = v }
60
60
  req.headers['Mcp-Session-Id'] = @session_id
61
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
61
62
  end
62
63
 
63
64
  if response.success?
@@ -138,6 +139,7 @@ module MCPClient
138
139
 
139
140
  @server_info = result['serverInfo']
140
141
  @capabilities = result['capabilities']
142
+ @protocol_version = result['protocolVersion']
141
143
  end
142
144
 
143
145
  # Send a JSON-RPC request to the server and wait for result
@@ -256,14 +258,20 @@ module MCPClient
256
258
  end
257
259
 
258
260
  # Create a Faraday connection for HTTP requests
261
+ # Applies default configuration first, then allows user customization via @faraday_config block
259
262
  # @return [Faraday::Connection] the configured connection
260
263
  def create_http_connection
261
- Faraday.new(url: @base_url) do |f|
264
+ conn = Faraday.new(url: @base_url) do |f|
262
265
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
263
266
  f.options.open_timeout = @read_timeout
264
267
  f.options.timeout = @read_timeout
265
268
  f.adapter Faraday.default_adapter
266
269
  end
270
+
271
+ # Apply user's Faraday customizations after defaults
272
+ @faraday_config&.call(conn)
273
+
274
+ conn
267
275
  end
268
276
 
269
277
  # Log HTTP response (to be overridden by specific transports)
@@ -62,11 +62,15 @@ module MCPClient
62
62
  # Generate initialization parameters for MCP protocol
63
63
  # @return [Hash] the initialization parameters
64
64
  def initialization_params
65
+ capabilities = {
66
+ 'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
67
+ 'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
68
+ 'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
69
+ }
70
+
65
71
  {
66
72
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
67
- 'capabilities' => {
68
- 'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
69
- },
73
+ 'capabilities' => capabilities,
70
74
  'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
71
75
  }
72
76
  end
@@ -41,6 +41,14 @@ module MCPClient
41
41
  @server = server
42
42
  end
43
43
 
44
+ # Return the lastModified annotation value (ISO 8601 timestamp string)
45
+ # @return [String, nil] the lastModified timestamp, or nil if not set
46
+ def last_modified
47
+ return nil unless @annotations.is_a?(Hash)
48
+
49
+ @annotations['lastModified'] || @annotations[:lastModified]
50
+ end
51
+
44
52
  # Create a Resource instance from JSON data
45
53
  # @param data [Hash] JSON data from MCP server
46
54
  # @param server [MCPClient::ServerBase, nil] the server this resource belongs to
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Representation of an MCP resource link in tool result content
5
+ # A resource link references a server resource that can be read separately.
6
+ # Used in tool results to point clients to available resources (MCP 2025-11-25).
7
+ class ResourceLink
8
+ # @!attribute [r] uri
9
+ # @return [String] URI of the linked resource
10
+ # @!attribute [r] name
11
+ # @return [String] the name of the linked resource
12
+ # @!attribute [r] description
13
+ # @return [String, nil] optional human-readable description
14
+ # @!attribute [r] mime_type
15
+ # @return [String, nil] optional MIME type of the resource
16
+ # @!attribute [r] annotations
17
+ # @return [Hash, nil] optional annotations that provide hints to clients
18
+ # @!attribute [r] title
19
+ # @return [String, nil] optional display title for the resource
20
+ # @!attribute [r] size
21
+ # @return [Integer, nil] optional size of the resource in bytes
22
+ attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size
23
+
24
+ # Initialize a resource link
25
+ # @param uri [String] URI of the linked resource
26
+ # @param name [String] the name of the linked resource
27
+ # @param description [String, nil] optional human-readable description
28
+ # @param mime_type [String, nil] optional MIME type of the resource
29
+ # @param annotations [Hash, nil] optional annotations that provide hints to clients
30
+ # @param title [String, nil] optional display title for the resource
31
+ # @param size [Integer, nil] optional size of the resource in bytes
32
+ def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil)
33
+ @uri = uri
34
+ @name = name
35
+ @description = description
36
+ @mime_type = mime_type
37
+ @annotations = annotations
38
+ @title = title
39
+ @size = size
40
+ end
41
+
42
+ # Create a ResourceLink instance from JSON data
43
+ # @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
44
+ # @return [MCPClient::ResourceLink] resource link instance
45
+ def self.from_json(data)
46
+ new(
47
+ uri: data['uri'],
48
+ name: data['name'],
49
+ description: data['description'],
50
+ mime_type: data['mimeType'],
51
+ annotations: data['annotations'],
52
+ title: data['title'],
53
+ size: data['size']
54
+ )
55
+ end
56
+
57
+ # The content type identifier for this content type
58
+ # @return [String] 'resource_link'
59
+ def type
60
+ 'resource_link'
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Represents an MCP Root - a URI that defines a boundary where servers can operate
5
+ # Roots are declared by clients to inform servers about relevant resources and their locations
6
+ class Root
7
+ attr_reader :uri, :name
8
+
9
+ # Create a new Root
10
+ # @param uri [String] The URI for the root (typically file:// URI)
11
+ # @param name [String, nil] Optional human-readable name for display purposes
12
+ def initialize(uri:, name: nil)
13
+ @uri = uri
14
+ @name = name
15
+ end
16
+
17
+ # Create a Root from a JSON hash
18
+ # @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
19
+ # @return [Root]
20
+ def self.from_json(json)
21
+ new(
22
+ uri: json['uri'] || json[:uri],
23
+ name: json['name'] || json[:name]
24
+ )
25
+ end
26
+
27
+ # Convert to JSON-serializable hash
28
+ # @return [Hash]
29
+ def to_h
30
+ result = { 'uri' => @uri }
31
+ result['name'] = @name if @name
32
+ result
33
+ end
34
+
35
+ # Convert to JSON string
36
+ # @return [String]
37
+ def to_json(*)
38
+ to_h.to_json(*)
39
+ end
40
+
41
+ # Check equality
42
+ def ==(other)
43
+ return false unless other.is_a?(Root)
44
+
45
+ uri == other.uri && name == other.name
46
+ end
47
+
48
+ alias eql? ==
49
+
50
+ def hash
51
+ [uri, name].hash
52
+ end
53
+
54
+ # String representation
55
+ def to_s
56
+ name ? "#{name} (#{uri})" : uri
57
+ end
58
+
59
+ def inspect
60
+ "#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
61
+ end
62
+ end
63
+ end
@@ -77,7 +77,8 @@ module MCPClient
77
77
  retry_backoff: config[:retry_backoff] || 1,
78
78
  name: config[:name],
79
79
  logger: logger,
80
- oauth_provider: config[:oauth_provider]
80
+ oauth_provider: config[:oauth_provider],
81
+ faraday_config: config[:faraday_config]
81
82
  )
82
83
  end
83
84
 
@@ -97,7 +98,8 @@ module MCPClient
97
98
  retry_backoff: config[:retry_backoff] || 1,
98
99
  name: config[:name],
99
100
  logger: logger,
100
- oauth_provider: config[:oauth_provider]
101
+ oauth_provider: config[:oauth_provider],
102
+ faraday_config: config[:faraday_config]
101
103
  )
102
104
  end
103
105
 
@@ -55,6 +55,7 @@ module MCPClient
55
55
  # @option options [String, nil] :name Optional name for this server
56
56
  # @option options [Logger, nil] :logger Optional logger
57
57
  # @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
58
+ # @option options [Proc, nil] :faraday_config Optional block to customize the Faraday connection
58
59
  def initialize(base_url:, **options)
59
60
  opts = default_options.merge(options)
60
61
  super(name: opts[:name])
@@ -99,6 +100,7 @@ module MCPClient
99
100
  })
100
101
 
101
102
  @read_timeout = opts[:read_timeout]
103
+ @faraday_config = opts[:faraday_config]
102
104
  @tools = nil
103
105
  @tools_data = nil
104
106
  @request_id = 0
@@ -193,15 +195,22 @@ module MCPClient
193
195
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
194
196
  end
195
197
 
196
- # Override apply_request_headers to add session headers for MCP protocol
198
+ # Override apply_request_headers to add session and protocol version headers
197
199
  def apply_request_headers(req, request)
198
200
  super
199
201
 
200
- # Add session header if we have one (for non-initialize requests)
201
- return unless @session_id && request['method'] != 'initialize'
202
+ # Add session and protocol version headers for non-initialize requests
203
+ return unless request['method'] != 'initialize'
202
204
 
203
- req.headers['Mcp-Session-Id'] = @session_id
204
- @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
205
+ if @session_id
206
+ req.headers['Mcp-Session-Id'] = @session_id
207
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
208
+ end
209
+
210
+ return unless @protocol_version
211
+
212
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
213
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
205
214
  end
206
215
 
207
216
  # Override handle_successful_response to capture session ID
@@ -320,6 +329,36 @@ module MCPClient
320
329
  raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
321
330
  end
322
331
 
332
+ # Request completion suggestions from the server (MCP 2025-06-18)
333
+ # @param ref [Hash] reference to complete (prompt or resource)
334
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
335
+ # @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
336
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
337
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
338
+ def complete(ref:, argument:, context: nil)
339
+ params = { ref: ref, argument: argument }
340
+ params[:context] = context if context
341
+ result = rpc_request('completion/complete', params)
342
+ result['completion'] || { 'values' => [] }
343
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
344
+ raise
345
+ rescue StandardError => e
346
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
347
+ end
348
+
349
+ # Set the logging level on the server (MCP 2025-06-18)
350
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
351
+ # 'critical', 'alert', 'emergency')
352
+ # @return [Hash] empty result on success
353
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
354
+ def log_level=(level)
355
+ rpc_request('logging/setLevel', { level: level })
356
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
357
+ raise
358
+ rescue StandardError => e
359
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
360
+ end
361
+
323
362
  # List all resource templates available from the MCP server
324
363
  # @param cursor [String, nil] optional cursor for pagination
325
364
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
@@ -420,7 +459,8 @@ module MCPClient
420
459
  retry_backoff: 1,
421
460
  name: nil,
422
461
  logger: nil,
423
- oauth_provider: nil
462
+ oauth_provider: nil,
463
+ faraday_config: nil
424
464
  }
425
465
  end
426
466