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.
@@ -3,16 +3,30 @@
3
3
  module MCPClient
4
4
  # Shared retry/backoff logic for JSON-RPC transports
5
5
  module JsonRpcCommon
6
- # Execute the block with retry/backoff for transient errors
6
+ # Execute the block with retry/backoff for transient errors only.
7
+ #
8
+ # Retries genuinely transient failures where the request most likely did not
9
+ # complete at the server: transport/network errors (TransportError, IOError,
10
+ # Errno::ETIMEDOUT/ECONNRESET/EPIPE) and TransientServerError (HTTP 5xx).
11
+ #
12
+ # It deliberately does NOT retry a plain ServerError. A plain ServerError is
13
+ # raised for a JSON-RPC error response or an HTTP 4xx — cases where the
14
+ # server received and processed (or deterministically rejected) the request.
15
+ # Re-sending those would silently re-execute a non-idempotent operation
16
+ # (e.g. a tools/call), which JSON-RPC provides no way to make safe.
7
17
  # @yield block to execute
8
18
  # @return [Object] result of block
9
- # @raise original exception if max retries exceeded
19
+ # @raise original exception if max retries exceeded or the error is not retryable
10
20
  def with_retry
11
21
  attempts = 0
12
22
  begin
13
23
  yield
14
- rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
15
- Errno::ECONNRESET => e
24
+ rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::TransportError, IOError,
25
+ Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
26
+ # A timed-out request may still be executing server-side; re-sending
27
+ # it could run a non-idempotent operation twice. Never retry those.
28
+ raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
29
+
16
30
  attempts += 1
17
31
  if attempts <= @max_retries
18
32
  delay = @retry_backoff * (2**(attempts - 1))
@@ -33,6 +47,46 @@ module MCPClient
33
47
  rpc_request('ping')
34
48
  end
35
49
 
50
+ # Whether automatic notifications/cancelled on timeout is appropriate
51
+ # for this request: never for initialize (MUST NOT be cancelled), and
52
+ # never for task-augmented requests (tasks use tasks/cancel instead).
53
+ # @param method [String] JSON-RPC method
54
+ # @param params [Hash] request params
55
+ # @return [Boolean]
56
+ def cancellable_request?(method, params)
57
+ return false if method == 'initialize'
58
+ return false if params.is_a?(Hash) && (params.key?('task') || params.key?(:task))
59
+
60
+ true
61
+ end
62
+
63
+ # Split request-level _meta (RequestParams._meta, e.g. progressToken or
64
+ # related-task metadata) out of user-supplied tool/prompt arguments.
65
+ # Accepts both :_meta and '_meta' key spellings; per MCP, _meta belongs at
66
+ # the request params level, not inside the tool's arguments.
67
+ # @param arguments [Hash, nil] user-supplied arguments
68
+ # @return [Array(Hash, Hash|nil)] [arguments without _meta, _meta or nil]
69
+ def split_request_meta(arguments)
70
+ return [arguments, nil] unless arguments.is_a?(Hash)
71
+
72
+ meta = arguments[:_meta] || arguments['_meta']
73
+ return [arguments, nil] unless meta
74
+
75
+ [arguments.except(:_meta, '_meta'), meta]
76
+ end
77
+
78
+ # Build tools/call- or prompts/get-style params with request-level _meta
79
+ # hoisted out of the arguments (string keys, matching the JSON wire form).
80
+ # @param name [String] tool or prompt name
81
+ # @param arguments [Hash] user-supplied arguments (possibly carrying _meta)
82
+ # @return [Hash] params hash for the JSON-RPC request
83
+ def build_named_request_params(name, arguments)
84
+ args, meta = split_request_meta(arguments)
85
+ params = { 'name' => name, 'arguments' => args }
86
+ params['_meta'] = meta if meta
87
+ params
88
+ end
89
+
36
90
  # Build a JSON-RPC request object
37
91
  # @param method [String] JSON-RPC method name
38
92
  # @param params [Hash] parameters for the request
@@ -62,19 +116,93 @@ module MCPClient
62
116
  # Generate initialization parameters for MCP protocol
63
117
  # @return [Hash] the initialization parameters
64
118
  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
-
71
119
  {
72
120
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
73
- 'capabilities' => capabilities,
74
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
121
+ 'capabilities' => client_capabilities,
122
+ 'clientInfo' => client_info_payload
75
123
  }
76
124
  end
77
125
 
126
+ # Validate the protocol version the server negotiated in its initialize
127
+ # result. Per the MCP lifecycle, the server may answer with a different
128
+ # version than requested; if the client cannot support it, it MUST
129
+ # disconnect. Disconnects (via the transport's cleanup) and raises when
130
+ # the version is unsupported or absent.
131
+ # @param result [Hash] the initialize result
132
+ # @return [String] the negotiated protocol version
133
+ # @raise [MCPClient::Errors::ConnectionError] if the version is unsupported
134
+ def validate_protocol_version!(result)
135
+ version = result['protocolVersion']
136
+ return version if MCPClient::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
137
+
138
+ begin
139
+ cleanup if respond_to?(:cleanup)
140
+ rescue StandardError => e
141
+ @logger.debug("Cleanup after protocol version mismatch failed: #{e.message}")
142
+ end
143
+ raise MCPClient::Errors::ConnectionError,
144
+ "Server negotiated unsupported protocol version #{version.inspect} " \
145
+ "(supported: #{MCPClient::SUPPORTED_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
146
+ end
147
+
148
+ # The Implementation object sent as clientInfo: the host-provided info
149
+ # when configured (client_info=), otherwise the gem's identity.
150
+ # @return [Hash]
151
+ def client_info_payload
152
+ return @client_info if defined?(@client_info) && @client_info
153
+
154
+ { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
155
+ end
156
+
157
+ # Declared client capabilities, derived from the server-request callbacks
158
+ # the host actually registered before connecting. Per MCP 2025-11-25,
159
+ # clients that support a feature MUST declare it during initialization,
160
+ # and only negotiated capabilities may be used afterwards — so declaring
161
+ # a hardcoded set independent of host support violates the lifecycle in
162
+ # both directions.
163
+ # @return [Hash] the capabilities object for the initialize request
164
+ def client_capabilities
165
+ capabilities = {}
166
+ if registered_callback?(:@elicitation_request_callback)
167
+ # Both defined elicitation modes are implemented (an empty object
168
+ # would mean form-only per the spec's backwards-compatibility rule).
169
+ capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
170
+ end
171
+ capabilities['roots'] = { 'listChanged' => true } if registered_callback?(:@roots_list_request_callback)
172
+ if registered_callback?(:@sampling_request_callback)
173
+ # SEP-1577: servers may only send tool-enabled sampling requests when
174
+ # the client declares the sampling.tools sub-capability.
175
+ capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
176
+ end
177
+ # NOTE: we intentionally do NOT declare a client `tasks` capability. That
178
+ # capability marks the client as a RECEIVER of task-augmented
179
+ # sampling/elicitation requests, which is not implemented here — this
180
+ # client only acts as a task REQUESTOR for tools/call (see
181
+ # Client#call_tool_as_task), which requires no client-side declaration.
182
+ capabilities
183
+ end
184
+
185
+ # Opt this transport into declaring tool-use support for sampling
186
+ # (ClientCapabilities.sampling.tools, MCP 2025-11-25 / SEP-1577). Call
187
+ # before connect so the initialize request advertises it; it only takes
188
+ # effect when a sampling request callback is also registered, since
189
+ # sampling.tools is a sub-capability of sampling.
190
+ # @return [void]
191
+ def declare_sampling_tools
192
+ @sampling_tools_supported = true
193
+ end
194
+
195
+ # @param ivar [Symbol] callback instance variable name
196
+ # @return [Boolean] whether the callback is registered on this transport
197
+ def registered_callback?(ivar)
198
+ instance_variable_defined?(ivar) && !instance_variable_get(ivar).nil?
199
+ end
200
+
201
+ # @return [Boolean] whether the host opted into sampling tool use
202
+ def sampling_tools_supported?
203
+ instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
204
+ end
205
+
78
206
  # Process JSON-RPC response
79
207
  # @param response [Hash] the parsed JSON-RPC response
80
208
  # @return [Object] the result field from the response
@@ -20,6 +20,8 @@ module MCPClient
20
20
  # @option options [String, nil] :name Optional name for this server
21
21
  # @option options [Logger, nil] :logger Optional logger
22
22
  # @option options [Object, nil] :storage Storage backend for OAuth tokens and client info
23
+ # @option options [String, nil] :client_id_metadata_url HTTPS URL of this client's
24
+ # Client ID Metadata Document (SEP-991)
23
25
  # @return [ServerHTTP] OAuth-enabled HTTP server
24
26
  def self.create_http_server(server_url:, **options)
25
27
  opts = default_server_options.merge(options)
@@ -29,7 +31,8 @@ module MCPClient
29
31
  redirect_uri: opts[:redirect_uri],
30
32
  scope: opts[:scope],
31
33
  logger: opts[:logger],
32
- storage: opts[:storage]
34
+ storage: opts[:storage],
35
+ client_id_metadata_url: opts[:client_id_metadata_url]
33
36
  )
34
37
 
35
38
  ServerHTTP.new(
@@ -57,7 +60,8 @@ module MCPClient
57
60
  redirect_uri: opts[:redirect_uri],
58
61
  scope: opts[:scope],
59
62
  logger: opts[:logger],
60
- storage: opts[:storage]
63
+ storage: opts[:storage],
64
+ client_id_metadata_url: opts[:client_id_metadata_url]
61
65
  )
62
66
 
63
67
  ServerStreamableHTTP.new(
@@ -120,7 +124,8 @@ module MCPClient
120
124
  retry_backoff: 1,
121
125
  name: nil,
122
126
  logger: nil,
123
- storage: nil
127
+ storage: nil,
128
+ client_id_metadata_url: nil
124
129
  }
125
130
  end
126
131
  end
@@ -5,23 +5,35 @@ module MCPClient
5
5
  class Prompt
6
6
  # @!attribute [r] name
7
7
  # @return [String] the name of the prompt
8
+ # @!attribute [r] title
9
+ # @return [String, nil] optional human-readable name of the prompt for display purposes
8
10
  # @!attribute [r] description
9
11
  # @return [String] the description of the prompt
10
12
  # @!attribute [r] arguments
11
13
  # @return [Hash] the JSON arguments for the prompt
14
+ # @!attribute [r] icons
15
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
16
+ # @!attribute [r] meta
17
+ # @return [Hash, nil] optional `_meta` metadata attached to the prompt (MCP 2025-11-25)
12
18
  # @!attribute [r] server
13
19
  # @return [MCPClient::ServerBase, nil] the server this prompt belongs to
14
- attr_reader :name, :description, :arguments, :server
20
+ attr_reader :name, :title, :description, :arguments, :icons, :meta, :server
15
21
 
16
22
  # Initialize a new prompt
17
23
  # @param name [String] the name of the prompt
18
24
  # @param description [String] the description of the prompt
19
25
  # @param arguments [Hash] the JSON arguments for the prompt
26
+ # @param title [String, nil] optional human-readable name of the prompt for display purposes
27
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
28
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the prompt (MCP 2025-11-25)
20
29
  # @param server [MCPClient::ServerBase, nil] the server this prompt belongs to
21
- def initialize(name:, description:, arguments: {}, server: nil)
30
+ def initialize(name:, description:, arguments: {}, title: nil, icons: nil, meta: nil, server: nil)
22
31
  @name = name
32
+ @title = title
23
33
  @description = description
24
34
  @arguments = arguments
35
+ @icons = icons
36
+ @meta = meta
25
37
  @server = server
26
38
  end
27
39
 
@@ -32,8 +44,11 @@ module MCPClient
32
44
  def self.from_json(data, server: nil)
33
45
  new(
34
46
  name: data['name'],
47
+ title: data['title'],
35
48
  description: data['description'],
36
49
  arguments: data['arguments'] || {},
50
+ icons: data['icons'],
51
+ meta: data['_meta'],
37
52
  server: server
38
53
  )
39
54
  end
@@ -17,9 +17,13 @@ module MCPClient
17
17
  # @return [Integer, nil] optional size in bytes
18
18
  # @!attribute [r] annotations
19
19
  # @return [Hash, nil] optional annotations that provide hints to clients
20
+ # @!attribute [r] icons
21
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
22
+ # @!attribute [r] meta
23
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource (MCP 2025-11-25)
20
24
  # @!attribute [r] server
21
25
  # @return [MCPClient::ServerBase, nil] the server this resource belongs to
22
- attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :server
26
+ attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :icons, :meta, :server
23
27
 
24
28
  # Initialize a new resource
25
29
  # @param uri [String] unique identifier for the resource
@@ -29,8 +33,12 @@ module MCPClient
29
33
  # @param mime_type [String, nil] optional MIME type
30
34
  # @param size [Integer, nil] optional size in bytes
31
35
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
36
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
37
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource (MCP 2025-11-25)
32
38
  # @param server [MCPClient::ServerBase, nil] the server this resource belongs to
33
- def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, server: nil)
39
+ # rubocop:disable Metrics/ParameterLists
40
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil,
41
+ icons: nil, meta: nil, server: nil)
34
42
  @uri = uri
35
43
  @name = name
36
44
  @title = title
@@ -38,8 +46,11 @@ module MCPClient
38
46
  @mime_type = mime_type
39
47
  @size = size
40
48
  @annotations = annotations
49
+ @icons = icons
50
+ @meta = meta
41
51
  @server = server
42
52
  end
53
+ # rubocop:enable Metrics/ParameterLists
43
54
 
44
55
  # Return the lastModified annotation value (ISO 8601 timestamp string)
45
56
  # @return [String, nil] the lastModified timestamp, or nil if not set
@@ -62,6 +73,8 @@ module MCPClient
62
73
  mime_type: data['mimeType'],
63
74
  size: data['size'],
64
75
  annotations: data['annotations'],
76
+ icons: data['icons'],
77
+ meta: data['_meta'],
65
78
  server: server
66
79
  )
67
80
  end
@@ -18,7 +18,9 @@ module MCPClient
18
18
  # @return [String, nil] base64-encoded binary content (mutually exclusive with text)
19
19
  # @!attribute [r] annotations
20
20
  # @return [Hash, nil] optional annotations that provide hints to clients
21
- attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations
21
+ # @!attribute [r] meta
22
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource contents (MCP 2025-11-25)
23
+ attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations, :meta
22
24
 
23
25
  # Initialize resource content
24
26
  # @param uri [String] unique identifier for the resource
@@ -28,7 +30,8 @@ module MCPClient
28
30
  # @param text [String, nil] text content (mutually exclusive with blob)
29
31
  # @param blob [String, nil] base64-encoded binary content (mutually exclusive with text)
30
32
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
31
- def initialize(uri:, name:, title: nil, mime_type: nil, text: nil, blob: nil, annotations: nil)
33
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource contents (MCP 2025-11-25)
34
+ def initialize(uri:, name:, title: nil, mime_type: nil, text: nil, blob: nil, annotations: nil, meta: nil)
32
35
  raise ArgumentError, 'ResourceContent cannot have both text and blob' if text && blob
33
36
  raise ArgumentError, 'ResourceContent must have either text or blob' if !text && !blob
34
37
 
@@ -39,6 +42,7 @@ module MCPClient
39
42
  @text = text
40
43
  @blob = blob
41
44
  @annotations = annotations
45
+ @meta = meta
42
46
  end
43
47
 
44
48
  # Create a ResourceContent instance from JSON data
@@ -52,7 +56,8 @@ module MCPClient
52
56
  mime_type: data['mimeType'],
53
57
  text: data['text'],
54
58
  blob: data['blob'],
55
- annotations: data['annotations']
59
+ annotations: data['annotations'],
60
+ meta: data['_meta']
56
61
  )
57
62
  end
58
63
 
@@ -19,7 +19,11 @@ module MCPClient
19
19
  # @return [String, nil] optional display title for the resource
20
20
  # @!attribute [r] size
21
21
  # @return [Integer, nil] optional size of the resource in bytes
22
- attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size
22
+ # @!attribute [r] icons
23
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
24
+ # @!attribute [r] meta
25
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource link (MCP 2025-11-25)
26
+ attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size, :icons, :meta
23
27
 
24
28
  # Initialize a resource link
25
29
  # @param uri [String] URI of the linked resource
@@ -29,7 +33,11 @@ module MCPClient
29
33
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
30
34
  # @param title [String, nil] optional display title for the resource
31
35
  # @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)
36
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
37
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource link (MCP 2025-11-25)
38
+ # rubocop:disable Metrics/ParameterLists
39
+ def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil,
40
+ icons: nil, meta: nil)
33
41
  @uri = uri
34
42
  @name = name
35
43
  @description = description
@@ -37,7 +45,10 @@ module MCPClient
37
45
  @annotations = annotations
38
46
  @title = title
39
47
  @size = size
48
+ @icons = icons
49
+ @meta = meta
40
50
  end
51
+ # rubocop:enable Metrics/ParameterLists
41
52
 
42
53
  # Create a ResourceLink instance from JSON data
43
54
  # @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
@@ -50,7 +61,9 @@ module MCPClient
50
61
  mime_type: data['mimeType'],
51
62
  annotations: data['annotations'],
52
63
  title: data['title'],
53
- size: data['size']
64
+ size: data['size'],
65
+ icons: data['icons'],
66
+ meta: data['_meta']
54
67
  )
55
68
  end
56
69
 
@@ -16,9 +16,13 @@ module MCPClient
16
16
  # @return [String, nil] optional MIME type for resources created from this template
17
17
  # @!attribute [r] annotations
18
18
  # @return [Hash, nil] optional annotations that provide hints to clients
19
+ # @!attribute [r] icons
20
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
21
+ # @!attribute [r] meta
22
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource template (MCP 2025-11-25)
19
23
  # @!attribute [r] server
20
24
  # @return [MCPClient::ServerBase, nil] the server this resource template belongs to
21
- attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :server
25
+ attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :icons, :meta, :server
22
26
 
23
27
  # Initialize a new resource template
24
28
  # @param uri_template [String] URI template following RFC 6570
@@ -27,16 +31,23 @@ module MCPClient
27
31
  # @param description [String, nil] optional description
28
32
  # @param mime_type [String, nil] optional MIME type
29
33
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
34
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
35
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource template (MCP 2025-11-25)
30
36
  # @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
31
- def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil, server: nil)
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil,
39
+ icons: nil, meta: nil, server: nil)
32
40
  @uri_template = uri_template
33
41
  @name = name
34
42
  @title = title
35
43
  @description = description
36
44
  @mime_type = mime_type
37
45
  @annotations = annotations
46
+ @icons = icons
47
+ @meta = meta
38
48
  @server = server
39
49
  end
50
+ # rubocop:enable Metrics/ParameterLists
40
51
 
41
52
  # Create a ResourceTemplate instance from JSON data
42
53
  # @param data [Hash] JSON data from MCP server
@@ -50,6 +61,8 @@ module MCPClient
50
61
  description: data['description'],
51
62
  mime_type: data['mimeType'],
52
63
  annotations: data['annotations'],
64
+ icons: data['icons'],
65
+ meta: data['_meta'],
53
66
  server: server
54
67
  )
55
68
  end
@@ -1,26 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module MCPClient
4
6
  # Represents an MCP Root - a URI that defines a boundary where servers can operate
5
7
  # Roots are declared by clients to inform servers about relevant resources and their locations
6
8
  class Root
7
- attr_reader :uri, :name
9
+ attr_reader :uri, :name, :meta
8
10
 
9
11
  # Create a new Root
10
- # @param uri [String] The URI for the root (typically file:// URI)
12
+ # @param uri [String] The URI for the root. Per the MCP specification this
13
+ # MUST be a file:// URI ("This **MUST** be a `file://` URI in the current
14
+ # specification" - client/roots.mdx, 2025-11-25)
11
15
  # @param name [String, nil] Optional human-readable name for display purposes
12
- def initialize(uri:, name: nil)
16
+ # @param meta [Hash, nil] Optional _meta field attached to the root (schema.ts Root._meta)
17
+ # @raise [ArgumentError] if uri is not a valid file:// URI or contains '..' path segments
18
+ def initialize(uri:, name: nil, meta: nil)
19
+ validate_uri!(uri)
20
+ # Root._meta is an object of arbitrary keys per the schema
21
+ raise ArgumentError, "Root _meta must be a Hash, got #{meta.class}" if meta && !meta.is_a?(Hash)
22
+
13
23
  @uri = uri
14
24
  @name = name
25
+ @meta = meta
15
26
  end
16
27
 
17
28
  # Create a Root from a JSON hash
18
- # @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
29
+ # @param json [Hash] The JSON hash with 'uri' and optional 'name' and '_meta' keys
19
30
  # @return [Root]
31
+ # @raise [ArgumentError] if the uri is missing or not a valid file:// URI
20
32
  def self.from_json(json)
21
33
  new(
22
34
  uri: json['uri'] || json[:uri],
23
- name: json['name'] || json[:name]
35
+ name: json['name'] || json[:name],
36
+ meta: json['_meta'] || json[:_meta]
24
37
  )
25
38
  end
26
39
 
@@ -29,6 +42,7 @@ module MCPClient
29
42
  def to_h
30
43
  result = { 'uri' => @uri }
31
44
  result['name'] = @name if @name
45
+ result['_meta'] = @meta if @meta
32
46
  result
33
47
  end
34
48
 
@@ -42,13 +56,13 @@ module MCPClient
42
56
  def ==(other)
43
57
  return false unless other.is_a?(Root)
44
58
 
45
- uri == other.uri && name == other.name
59
+ uri == other.uri && name == other.name && meta == other.meta
46
60
  end
47
61
 
48
62
  alias eql? ==
49
63
 
50
64
  def hash
51
- [uri, name].hash
65
+ [uri, name, meta].hash
52
66
  end
53
67
 
54
68
  # String representation
@@ -59,5 +73,45 @@ module MCPClient
59
73
  def inspect
60
74
  "#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
61
75
  end
76
+
77
+ private
78
+
79
+ # Validate that the uri is a well-formed file:// URI without path traversal.
80
+ # Spec (client/roots.mdx, 2025-11-25): the root uri "MUST be a `file://` URI
81
+ # in the current specification", and clients "MUST ... Validate all root
82
+ # URIs to prevent path traversal".
83
+ # @param uri [Object] the uri to validate
84
+ # @return [void]
85
+ # @raise [ArgumentError] if the uri is invalid
86
+ def validate_uri!(uri)
87
+ raise ArgumentError, 'Root uri must be a String, got nil' if uri.nil?
88
+ raise ArgumentError, "Root uri must be a String, got #{uri.class}" unless uri.is_a?(String)
89
+
90
+ # The schema requires the literal file:// form ("must start with
91
+ # file://"), not merely a file scheme — file:relative and file:/path
92
+ # forms are rejected.
93
+ unless uri.downcase.start_with?('file://')
94
+ raise ArgumentError,
95
+ "Root uri must be a file:// URI (MCP spec: 'This MUST be a file:// URI'), got: #{uri.inspect}"
96
+ end
97
+
98
+ parsed = parse_uri(uri)
99
+ # Decode before the traversal check so percent-encoded segments
100
+ # (%2e%2e) cannot smuggle a '..' past validation.
101
+ decoded_path = URI::DEFAULT_PARSER.unescape(parsed.path.to_s)
102
+ return unless decoded_path.split('/').include?('..')
103
+
104
+ raise ArgumentError, "Root uri must not contain '..' path traversal segments, got: #{uri.inspect}"
105
+ end
106
+
107
+ # Parse a URI string, converting parse errors to ArgumentError
108
+ # @param uri [String] the uri string to parse
109
+ # @return [URI::Generic]
110
+ # @raise [ArgumentError] if the uri cannot be parsed
111
+ def parse_uri(uri)
112
+ URI.parse(uri)
113
+ rescue URI::InvalidURIError => e
114
+ raise ArgumentError, "Root uri is not a valid URI: #{uri.inspect} (#{e.message})"
115
+ end
62
116
  end
63
117
  end