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.
- checksums.yaml +4 -4
- data/README.md +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- 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' =>
|
|
89
|
-
'clientInfo' =>
|
|
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
|
data/lib/mcp_client/prompt.rb
CHANGED
|
@@ -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
|
data/lib/mcp_client/resource.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/mcp_client/root.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|