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
@@ -3,6 +3,14 @@
3
3
  module MCPClient
4
4
  # Shared retry/backoff logic for JSON-RPC transports
5
5
  module JsonRpcCommon
6
+ # JSON-RPC methods with arbitrary side effects that MUST NOT be re-sent
7
+ # automatically. Even a "transient" failure (5xx, dropped connection,
8
+ # malformed response) can arrive AFTER the server received the request,
9
+ # so a retry could execute the operation twice — and JSON-RPC has no
10
+ # idempotency key to make the duplicate safe. Callers who want to retry
11
+ # such an operation must decide that explicitly.
12
+ NON_IDEMPOTENT_METHODS = %w[tools/call].freeze
13
+
6
14
  # Execute the block with retry/backoff for transient errors only.
7
15
  #
8
16
  # Retries genuinely transient failures where the request most likely did not
@@ -14,15 +22,33 @@ module MCPClient
14
22
  # server received and processed (or deterministically rejected) the request.
15
23
  # Re-sending those would silently re-execute a non-idempotent operation
16
24
  # (e.g. a tools/call), which JSON-RPC provides no way to make safe.
25
+ #
26
+ # It also never retries a NON_IDEMPOTENT_METHODS request (pass the
27
+ # JSON-RPC method being sent): an ambiguous failure may follow server-side
28
+ # receipt, so those fail fast instead of risking a duplicate execution.
29
+ # @param method [String, nil] the JSON-RPC method the block sends
17
30
  # @yield block to execute
18
31
  # @return [Object] result of block
19
32
  # @raise original exception if max retries exceeded or the error is not retryable
20
- def with_retry
33
+ def with_retry(method = nil)
21
34
  attempts = 0
22
35
  begin
23
36
  yield
24
37
  rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::TransportError, IOError,
25
38
  Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
39
+ # A timed-out request may still be executing server-side; re-sending
40
+ # it could run a non-idempotent operation twice. Never retry those.
41
+ # An oversized response is the same story from the other direction:
42
+ # the server already ran the request, so a re-send risks a duplicate
43
+ # side effect (and re-does the oversized decode).
44
+ raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
45
+ raise if e.is_a?(MCPClient::Errors::ResponseTooLargeError)
46
+
47
+ if NON_IDEMPOTENT_METHODS.include?(method)
48
+ @logger.debug("Not retrying non-idempotent #{method} after error: #{e.message}")
49
+ raise
50
+ end
51
+
26
52
  attempts += 1
27
53
  if attempts <= @max_retries
28
54
  delay = @retry_backoff * (2**(attempts - 1))
@@ -34,6 +60,53 @@ module MCPClient
34
60
  end
35
61
  end
36
62
 
63
+ # A log-safe description of a JSON-RPC message: its method and id only.
64
+ #
65
+ # Params and results are deliberately omitted. tools/call arguments and
66
+ # tool results routinely carry credentials, personal data or customer
67
+ # content, and logs are frequently shipped to lower-trust destinations
68
+ # (aggregators, CI artifacts, support bundles) — so enabling DEBUG must
69
+ # not silently start recording payloads.
70
+ # @param message [Hash] a JSON-RPC request, notification or response
71
+ # @return [String] method/id summary, never payload content
72
+ def describe_jsonrpc_message(message)
73
+ return '(non-object message)' unless message.is_a?(Hash)
74
+
75
+ parts = []
76
+ parts << (message['method'] || message[:method] || '(response)').to_s
77
+ id = message['id'] || message[:id]
78
+ parts << "id=#{id}" if id
79
+ parts << 'error' if message['error'] || message[:error]
80
+ parts.join(' ')
81
+ end
82
+
83
+ # A log-safe description of a JSON parse failure.
84
+ #
85
+ # JSON::ParserError#message quotes the offending token — e.g.
86
+ # "expected object key, got 'SECRET-123' at line 1 column 2" — so
87
+ # interpolating it puts peer-controlled bytes straight into logs and
88
+ # exception messages. Keep the position, which is what actually helps
89
+ # diagnose a broken server, and drop the quoted content.
90
+ # @param error [JSON::ParserError] the parse failure
91
+ # @param payload [String, nil] the payload that failed to parse
92
+ # @return [String] position and size, never payload content
93
+ def describe_parse_error(error, payload = nil)
94
+ location = error.message[/at line \d+ column \d+/]
95
+ parts = ['malformed JSON']
96
+ parts << location if location
97
+ parts << describe_body_size(payload) if payload
98
+ parts.join(', ')
99
+ end
100
+
101
+ # A log-safe description of a payload body: its size, never its content.
102
+ # @param body [String, nil] the response/request body
103
+ # @return [String]
104
+ def describe_body_size(body)
105
+ return 'empty body' if body.nil? || body.empty?
106
+
107
+ "#{body.bytesize} bytes"
108
+ end
109
+
37
110
  # Ping the server to keep the connection alive
38
111
  # @return [Hash] the result of the ping request
39
112
  # @raise [MCPClient::Errors::ToolCallError] if ping times out or fails
@@ -43,6 +116,46 @@ module MCPClient
43
116
  rpc_request('ping')
44
117
  end
45
118
 
119
+ # Whether automatic notifications/cancelled on timeout is appropriate
120
+ # for this request: never for initialize (MUST NOT be cancelled), and
121
+ # never for task-augmented requests (tasks use tasks/cancel instead).
122
+ # @param method [String] JSON-RPC method
123
+ # @param params [Hash] request params
124
+ # @return [Boolean]
125
+ def cancellable_request?(method, params)
126
+ return false if method == 'initialize'
127
+ return false if params.is_a?(Hash) && (params.key?('task') || params.key?(:task))
128
+
129
+ true
130
+ end
131
+
132
+ # Split request-level _meta (RequestParams._meta, e.g. progressToken or
133
+ # related-task metadata) out of user-supplied tool/prompt arguments.
134
+ # Accepts both :_meta and '_meta' key spellings; per MCP, _meta belongs at
135
+ # the request params level, not inside the tool's arguments.
136
+ # @param arguments [Hash, nil] user-supplied arguments
137
+ # @return [Array(Hash, Hash|nil)] [arguments without _meta, _meta or nil]
138
+ def split_request_meta(arguments)
139
+ return [arguments, nil] unless arguments.is_a?(Hash)
140
+
141
+ meta = arguments[:_meta] || arguments['_meta']
142
+ return [arguments, nil] unless meta
143
+
144
+ [arguments.except(:_meta, '_meta'), meta]
145
+ end
146
+
147
+ # Build tools/call- or prompts/get-style params with request-level _meta
148
+ # hoisted out of the arguments (string keys, matching the JSON wire form).
149
+ # @param name [String] tool or prompt name
150
+ # @param arguments [Hash] user-supplied arguments (possibly carrying _meta)
151
+ # @return [Hash] params hash for the JSON-RPC request
152
+ def build_named_request_params(name, arguments)
153
+ args, meta = split_request_meta(arguments)
154
+ params = { 'name' => name, 'arguments' => args }
155
+ params['_meta'] = meta if meta
156
+ params
157
+ end
158
+
46
159
  # Build a JSON-RPC request object
47
160
  # @param method [String] JSON-RPC method name
48
161
  # @param params [Hash] parameters for the request
@@ -72,24 +185,93 @@ module MCPClient
72
185
  # Generate initialization parameters for MCP protocol
73
186
  # @return [Hash] the initialization parameters
74
187
  def initialization_params
75
- capabilities = {
76
- 'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
77
- 'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
78
- 'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
79
- # NOTE: we intentionally do NOT declare a client `tasks` capability. That
80
- # capability marks the client as a RECEIVER of task-augmented
81
- # sampling/elicitation requests, which is not implemented here — this
82
- # client only acts as a task REQUESTOR for tools/call (see
83
- # Client#call_tool_as_task), which requires no client-side declaration.
84
- }
85
-
86
188
  {
87
189
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
88
- 'capabilities' => capabilities,
89
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
190
+ 'capabilities' => client_capabilities,
191
+ 'clientInfo' => client_info_payload
90
192
  }
91
193
  end
92
194
 
195
+ # Validate the protocol version the server negotiated in its initialize
196
+ # result. Per the MCP lifecycle, the server may answer with a different
197
+ # version than requested; if the client cannot support it, it MUST
198
+ # disconnect. Disconnects (via the transport's cleanup) and raises when
199
+ # the version is unsupported or absent.
200
+ # @param result [Hash] the initialize result
201
+ # @return [String] the negotiated protocol version
202
+ # @raise [MCPClient::Errors::ConnectionError] if the version is unsupported
203
+ def validate_protocol_version!(result)
204
+ version = result['protocolVersion']
205
+ return version if MCPClient::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
206
+
207
+ begin
208
+ cleanup if respond_to?(:cleanup)
209
+ rescue StandardError => e
210
+ @logger.debug("Cleanup after protocol version mismatch failed: #{e.message}")
211
+ end
212
+ raise MCPClient::Errors::ConnectionError,
213
+ "Server negotiated unsupported protocol version #{version.inspect} " \
214
+ "(supported: #{MCPClient::SUPPORTED_PROTOCOL_VERSIONS.join(', ')}); disconnecting"
215
+ end
216
+
217
+ # The Implementation object sent as clientInfo: the host-provided info
218
+ # when configured (client_info=), otherwise the gem's identity.
219
+ # @return [Hash]
220
+ def client_info_payload
221
+ return @client_info if defined?(@client_info) && @client_info
222
+
223
+ { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
224
+ end
225
+
226
+ # Declared client capabilities, derived from the server-request callbacks
227
+ # the host actually registered before connecting. Per MCP 2025-11-25,
228
+ # clients that support a feature MUST declare it during initialization,
229
+ # and only negotiated capabilities may be used afterwards — so declaring
230
+ # a hardcoded set independent of host support violates the lifecycle in
231
+ # both directions.
232
+ # @return [Hash] the capabilities object for the initialize request
233
+ def client_capabilities
234
+ capabilities = {}
235
+ if registered_callback?(:@elicitation_request_callback)
236
+ # Both defined elicitation modes are implemented (an empty object
237
+ # would mean form-only per the spec's backwards-compatibility rule).
238
+ capabilities['elicitation'] = { 'form' => {}, 'url' => {} }
239
+ end
240
+ capabilities['roots'] = { 'listChanged' => true } if registered_callback?(:@roots_list_request_callback)
241
+ if registered_callback?(:@sampling_request_callback)
242
+ # SEP-1577: servers may only send tool-enabled sampling requests when
243
+ # the client declares the sampling.tools sub-capability.
244
+ capabilities['sampling'] = sampling_tools_supported? ? { 'tools' => {} } : {}
245
+ end
246
+ # NOTE: we intentionally do NOT declare a client `tasks` capability. That
247
+ # capability marks the client as a RECEIVER of task-augmented
248
+ # sampling/elicitation requests, which is not implemented here — this
249
+ # client only acts as a task REQUESTOR for tools/call (see
250
+ # Client#call_tool_as_task), which requires no client-side declaration.
251
+ capabilities
252
+ end
253
+
254
+ # Opt this transport into declaring tool-use support for sampling
255
+ # (ClientCapabilities.sampling.tools, MCP 2025-11-25 / SEP-1577). Call
256
+ # before connect so the initialize request advertises it; it only takes
257
+ # effect when a sampling request callback is also registered, since
258
+ # sampling.tools is a sub-capability of sampling.
259
+ # @return [void]
260
+ def declare_sampling_tools
261
+ @sampling_tools_supported = true
262
+ end
263
+
264
+ # @param ivar [Symbol] callback instance variable name
265
+ # @return [Boolean] whether the callback is registered on this transport
266
+ def registered_callback?(ivar)
267
+ instance_variable_defined?(ivar) && !instance_variable_get(ivar).nil?
268
+ end
269
+
270
+ # @return [Boolean] whether the host opted into sampling tool use
271
+ def sampling_tools_supported?
272
+ instance_variable_defined?(:@sampling_tools_supported) && @sampling_tools_supported
273
+ end
274
+
93
275
  # Process JSON-RPC response
94
276
  # @param response [Hash] the parsed JSON-RPC response
95
277
  # @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,11 @@ 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
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil,
40
+ icons: nil, meta: nil, server: nil)
34
41
  @uri = uri
35
42
  @name = name
36
43
  @title = title
@@ -38,6 +45,8 @@ module MCPClient
38
45
  @mime_type = mime_type
39
46
  @size = size
40
47
  @annotations = annotations
48
+ @icons = icons
49
+ @meta = meta
41
50
  @server = server
42
51
  end
43
52
 
@@ -62,6 +71,8 @@ module MCPClient
62
71
  mime_type: data['mimeType'],
63
72
  size: data['size'],
64
73
  annotations: data['annotations'],
74
+ icons: data['icons'],
75
+ meta: data['_meta'],
65
76
  server: server
66
77
  )
67
78
  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,10 @@ 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
+ def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil,
39
+ icons: nil, meta: nil)
33
40
  @uri = uri
34
41
  @name = name
35
42
  @description = description
@@ -37,6 +44,8 @@ module MCPClient
37
44
  @annotations = annotations
38
45
  @title = title
39
46
  @size = size
47
+ @icons = icons
48
+ @meta = meta
40
49
  end
41
50
 
42
51
  # Create a ResourceLink instance from JSON data
@@ -50,7 +59,9 @@ module MCPClient
50
59
  mime_type: data['mimeType'],
51
60
  annotations: data['annotations'],
52
61
  title: data['title'],
53
- size: data['size']
62
+ size: data['size'],
63
+ icons: data['icons'],
64
+ meta: data['_meta']
54
65
  )
55
66
  end
56
67
 
@@ -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,14 +31,19 @@ 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
+ def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil,
38
+ icons: nil, meta: nil, server: nil)
32
39
  @uri_template = uri_template
33
40
  @name = name
34
41
  @title = title
35
42
  @description = description
36
43
  @mime_type = mime_type
37
44
  @annotations = annotations
45
+ @icons = icons
46
+ @meta = meta
38
47
  @server = server
39
48
  end
40
49
 
@@ -50,6 +59,8 @@ module MCPClient
50
59
  description: data['description'],
51
60
  mime_type: data['mimeType'],
52
61
  annotations: data['annotations'],
62
+ icons: data['icons'],
63
+ meta: data['_meta'],
53
64
  server: server
54
65
  )
55
66
  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