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.
- checksums.yaml +4 -4
- data/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -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::
|
|
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' =>
|
|
74
|
-
'clientInfo' =>
|
|
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
|
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,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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
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
|