ruby-mcp-client 0.8.0 → 0.9.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.
@@ -21,7 +21,8 @@ module MCPClient
21
21
  # Initialize a new MCPClient::Client
22
22
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
23
23
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
24
- def initialize(mcp_server_configs: [], logger: nil)
24
+ # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
25
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil)
25
26
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
26
27
  @logger.progname = self.class.name
27
28
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -34,6 +35,8 @@ module MCPClient
34
35
  @resource_cache = {}
35
36
  # JSON-RPC notification listeners
36
37
  @notification_listeners = []
38
+ # Elicitation handler (MCP 2025-06-18)
39
+ @elicitation_handler = elicitation_handler
37
40
  # Register default and user-defined notification handlers on each server
38
41
  @servers.each do |server|
39
42
  server.on_notification do |method, params|
@@ -42,6 +45,10 @@ module MCPClient
42
45
  # Invoke user-defined listeners
43
46
  @notification_listeners.each { |cb| cb.call(server, method, params) }
44
47
  end
48
+ # Register elicitation handler on each server
49
+ if server.respond_to?(:on_elicitation_request)
50
+ server.on_elicitation_request(&method(:handle_elicitation_request))
51
+ end
45
52
  end
46
53
  end
47
54
 
@@ -126,17 +133,32 @@ module MCPClient
126
133
 
127
134
  # Lists all available resources from all connected MCP servers
128
135
  # @param cache [Boolean] whether to use cached resources or fetch fresh
129
- # @return [Array<MCPClient::Resource>] list of available resources
136
+ # @param cursor [String, nil] optional cursor for pagination (only works with single server)
137
+ # @return [Hash] result containing 'resources' array and optional 'nextCursor'
130
138
  # @raise [MCPClient::Errors::ConnectionError] on authorization failures
131
139
  # @raise [MCPClient::Errors::ResourceReadError] if no resources could be retrieved from any server
132
- def list_resources(cache: true)
133
- return @resource_cache.values if cache && !@resource_cache.empty?
140
+ def list_resources(cache: true, cursor: nil)
141
+ # If cursor is provided, we can only query one server (the one that provided the cursor)
142
+ # This is a limitation of aggregating multiple servers
143
+ if cursor
144
+ # For now, just use the first server when cursor is provided
145
+ # In a real implementation, you'd need to track which server the cursor came from
146
+ return servers.first.list_resources(cursor: cursor) if servers.any?
147
+
148
+ return { 'resources' => [], 'nextCursor' => nil }
149
+ end
150
+
151
+ # Use cache if available and no cursor
152
+ return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?
134
153
 
135
154
  resources = []
136
155
  connection_errors = []
137
156
 
138
157
  servers.each do |server|
139
- server.list_resources.each do |resource|
158
+ result = server.list_resources
159
+ resource_list = result['resources'] || []
160
+
161
+ resource_list.each do |resource|
140
162
  cache_key = cache_key_for(server, resource.uri)
141
163
  @resource_cache[cache_key] = resource
142
164
  resources << resource
@@ -152,7 +174,8 @@ module MCPClient
152
174
  @logger.error("Server error: #{e.message}")
153
175
  end
154
176
 
155
- resources
177
+ # Return hash format consistent with server methods
178
+ { 'resources' => resources, 'nextCursor' => nil }
156
179
  end
157
180
 
158
181
  # Reads a specific resource by URI
@@ -160,46 +183,16 @@ module MCPClient
160
183
  # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
161
184
  # @return [Object] the resource contents
162
185
  def read_resource(uri, server: nil)
163
- resources = list_resources
186
+ result = list_resources
187
+ resources = result['resources'] || []
164
188
 
165
- if server
166
- # Use the specified server
167
- srv = select_server(server)
168
- # Find the resource on this specific server
169
- resource = resources.find { |r| r.uri == uri && r.server == srv }
170
- unless resource
171
- raise MCPClient::Errors::ResourceNotFound,
172
- "Resource '#{uri}' not found on server '#{srv.name || srv.class.name}'"
173
- end
174
- else
175
- # Find the resource across all servers
176
- matching_resources = resources.select { |r| r.uri == uri }
177
-
178
- if matching_resources.empty?
179
- raise MCPClient::Errors::ResourceNotFound, "Resource '#{uri}' not found"
180
- elsif matching_resources.size > 1
181
- # If multiple matches, disambiguate with server names
182
- server_names = matching_resources.map { |r| r.server&.name || 'unnamed' }
183
- raise MCPClient::Errors::AmbiguousResourceURI,
184
- "Multiple resources with URI '#{uri}' found across servers (#{server_names.join(', ')}). " \
185
- "Please specify a server using the 'server' parameter."
186
- end
187
-
188
- resource = matching_resources.first
189
- end
189
+ resource = if server
190
+ find_resource_on_server(uri, resources, server)
191
+ else
192
+ find_resource_across_servers(uri, resources)
193
+ end
190
194
 
191
- # Use the resource's associated server
192
- server = resource.server
193
- raise MCPClient::Errors::ServerNotFound, "No server found for resource '#{uri}'" unless server
194
-
195
- begin
196
- server.read_resource(uri)
197
- rescue MCPClient::Errors::ConnectionError => e
198
- # Add server identity information to the error for better context
199
- server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
200
- raise MCPClient::Errors::ResourceReadError,
201
- "Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
202
- end
195
+ execute_resource_read(resource, uri)
203
196
  end
204
197
 
205
198
  # Lists all available tools from all connected MCP servers
@@ -478,7 +471,7 @@ module MCPClient
478
471
  case method
479
472
  when 'notifications/tools/list_changed'
480
473
  logger.warn("[#{server_id}] Tool list has changed, clearing tool cache")
481
- clear_cache
474
+ @tool_cache.clear
482
475
  when 'notifications/resources/updated'
483
476
  logger.warn("[#{server_id}] Resource #{params['uri']} updated")
484
477
  when 'notifications/prompts/list_changed'
@@ -556,5 +549,126 @@ module MCPClient
556
549
  server_id = server.object_id.to_s
557
550
  "#{server_id}:#{item_id}"
558
551
  end
552
+
553
+ # Find a resource on a specific server
554
+ # @param uri [String] the URI of the resource
555
+ # @param resources [Array<Resource>] available resources
556
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase] server selector
557
+ # @return [Resource] the found resource
558
+ # @raise [MCPClient::Errors::ResourceNotFound] if resource not found
559
+ def find_resource_on_server(uri, resources, server)
560
+ srv = select_server(server)
561
+ resource = resources.find { |r| r.uri == uri && r.server == srv }
562
+ unless resource
563
+ raise MCPClient::Errors::ResourceNotFound,
564
+ "Resource '#{uri}' not found on server '#{srv.name || srv.class.name}'"
565
+ end
566
+ resource
567
+ end
568
+
569
+ # Find a resource across all servers
570
+ # @param uri [String] the URI of the resource
571
+ # @param resources [Array<Resource>] available resources
572
+ # @return [Resource] the found resource
573
+ # @raise [MCPClient::Errors::ResourceNotFound] if resource not found
574
+ # @raise [MCPClient::Errors::AmbiguousResourceURI] if multiple resources found
575
+ def find_resource_across_servers(uri, resources)
576
+ matching_resources = resources.select { |r| r.uri == uri }
577
+
578
+ if matching_resources.empty?
579
+ raise MCPClient::Errors::ResourceNotFound, "Resource '#{uri}' not found"
580
+ elsif matching_resources.size > 1
581
+ server_names = matching_resources.map { |r| r.server&.name || 'unnamed' }
582
+ raise MCPClient::Errors::AmbiguousResourceURI,
583
+ "Multiple resources with URI '#{uri}' found across servers (#{server_names.join(', ')}). " \
584
+ "Please specify a server using the 'server' parameter."
585
+ end
586
+
587
+ matching_resources.first
588
+ end
589
+
590
+ # Execute the resource read operation
591
+ # @param resource [Resource] the resource to read
592
+ # @param uri [String] the URI of the resource
593
+ # @return [Object] the resource contents
594
+ # @raise [MCPClient::Errors::ServerNotFound] if no server found
595
+ # @raise [MCPClient::Errors::ResourceReadError] on read errors
596
+ def execute_resource_read(resource, uri)
597
+ server = resource.server
598
+ raise MCPClient::Errors::ServerNotFound, "No server found for resource '#{uri}'" unless server
599
+
600
+ begin
601
+ server.read_resource(uri)
602
+ rescue MCPClient::Errors::ConnectionError => e
603
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
604
+ raise MCPClient::Errors::ResourceReadError,
605
+ "Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
606
+ end
607
+ end
608
+
609
+ # Handle elicitation request from server (MCP 2025-06-18)
610
+ # @param request_id [String, Integer] the JSON-RPC request ID
611
+ # @param params [Hash] the elicitation parameters
612
+ # @return [Hash] the elicitation response
613
+ def handle_elicitation_request(_request_id, params)
614
+ # If no handler is configured, decline the request
615
+ unless @elicitation_handler
616
+ @logger.warn('Received elicitation request but no handler configured, declining')
617
+ return { 'action' => 'decline' }
618
+ end
619
+
620
+ message = params['message']
621
+ schema = params['schema'] || params['requestedSchema']
622
+ metadata = params['metadata']
623
+
624
+ begin
625
+ # Call the user-defined handler
626
+ result = case @elicitation_handler.arity
627
+ when 0
628
+ @elicitation_handler.call
629
+ when 1
630
+ @elicitation_handler.call(message)
631
+ when 2, -1
632
+ @elicitation_handler.call(message, schema)
633
+ else
634
+ @elicitation_handler.call(message, schema, metadata)
635
+ end
636
+
637
+ # Validate and format response
638
+ case result
639
+ when Hash
640
+ if result['action']
641
+ normalised_action_response(result)
642
+ elsif result[:action]
643
+ # Convert symbol keys to strings
644
+ {
645
+ 'action' => result[:action].to_s,
646
+ 'content' => result[:content]
647
+ }.compact.then { |payload| normalised_action_response(payload) }
648
+ else
649
+ # Assume it's content for an accept action
650
+ { 'action' => 'accept', 'content' => result }
651
+ end
652
+ when nil
653
+ { 'action' => 'cancel' }
654
+ else
655
+ { 'action' => 'accept', 'content' => result }
656
+ end
657
+ rescue StandardError => e
658
+ @logger.error("Elicitation handler error: #{e.message}")
659
+ @logger.debug(e.backtrace.join("\n"))
660
+ { 'action' => 'decline' }
661
+ end
662
+ end
663
+
664
+ # Ensure the action value conforms to MCP spec (accept, decline, cancel)
665
+ # Falls back to accept for unknown action values.
666
+ def normalised_action_response(result)
667
+ action = result['action']
668
+ return result if %w[accept decline cancel].include?(action)
669
+
670
+ @logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
671
+ result.merge('action' => 'accept')
672
+ end
559
673
  end
560
674
  end
@@ -64,7 +64,9 @@ module MCPClient
64
64
  def initialization_params
65
65
  {
66
66
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
67
- 'capabilities' => {},
67
+ 'capabilities' => {
68
+ 'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
69
+ },
68
70
  'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
69
71
  }
70
72
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Representation of MCP resource content
5
+ # Resources can contain either text or binary data
6
+ class ResourceContent
7
+ # @!attribute [r] uri
8
+ # @return [String] unique identifier for the resource
9
+ # @!attribute [r] name
10
+ # @return [String] the name of the resource
11
+ # @!attribute [r] title
12
+ # @return [String, nil] optional human-readable name for display purposes
13
+ # @!attribute [r] mime_type
14
+ # @return [String, nil] optional MIME type
15
+ # @!attribute [r] text
16
+ # @return [String, nil] text content (mutually exclusive with blob)
17
+ # @!attribute [r] blob
18
+ # @return [String, nil] base64-encoded binary content (mutually exclusive with text)
19
+ # @!attribute [r] annotations
20
+ # @return [Hash, nil] optional annotations that provide hints to clients
21
+ attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations
22
+
23
+ # Initialize resource content
24
+ # @param uri [String] unique identifier for the resource
25
+ # @param name [String] the name of the resource
26
+ # @param title [String, nil] optional human-readable name for display purposes
27
+ # @param mime_type [String, nil] optional MIME type
28
+ # @param text [String, nil] text content (mutually exclusive with blob)
29
+ # @param blob [String, nil] base64-encoded binary content (mutually exclusive with text)
30
+ # @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)
32
+ raise ArgumentError, 'ResourceContent cannot have both text and blob' if text && blob
33
+ raise ArgumentError, 'ResourceContent must have either text or blob' if !text && !blob
34
+
35
+ @uri = uri
36
+ @name = name
37
+ @title = title
38
+ @mime_type = mime_type
39
+ @text = text
40
+ @blob = blob
41
+ @annotations = annotations
42
+ end
43
+
44
+ # Create a ResourceContent instance from JSON data
45
+ # @param data [Hash] JSON data from MCP server
46
+ # @return [MCPClient::ResourceContent] resource content instance
47
+ def self.from_json(data)
48
+ new(
49
+ uri: data['uri'],
50
+ name: data['name'],
51
+ title: data['title'],
52
+ mime_type: data['mimeType'],
53
+ text: data['text'],
54
+ blob: data['blob'],
55
+ annotations: data['annotations']
56
+ )
57
+ end
58
+
59
+ # Check if content is text
60
+ # @return [Boolean] true if content is text
61
+ def text?
62
+ !@text.nil?
63
+ end
64
+
65
+ # Check if content is binary
66
+ # @return [Boolean] true if content is binary
67
+ def binary?
68
+ !@blob.nil?
69
+ end
70
+
71
+ # Get the content (text or decoded blob)
72
+ # @return [String] the content
73
+ def content
74
+ return @text if text?
75
+
76
+ require 'base64'
77
+ Base64.decode64(@blob)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Representation of an MCP resource template
5
+ # Resource templates allow servers to expose parameterized resources using URI templates
6
+ class ResourceTemplate
7
+ # @!attribute [r] uri_template
8
+ # @return [String] URI template following RFC 6570
9
+ # @!attribute [r] name
10
+ # @return [String] the name of the resource template
11
+ # @!attribute [r] title
12
+ # @return [String, nil] optional human-readable name for display purposes
13
+ # @!attribute [r] description
14
+ # @return [String, nil] optional description
15
+ # @!attribute [r] mime_type
16
+ # @return [String, nil] optional MIME type for resources created from this template
17
+ # @!attribute [r] annotations
18
+ # @return [Hash, nil] optional annotations that provide hints to clients
19
+ # @!attribute [r] server
20
+ # @return [MCPClient::ServerBase, nil] the server this resource template belongs to
21
+ attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :server
22
+
23
+ # Initialize a new resource template
24
+ # @param uri_template [String] URI template following RFC 6570
25
+ # @param name [String] the name of the resource template
26
+ # @param title [String, nil] optional human-readable name for display purposes
27
+ # @param description [String, nil] optional description
28
+ # @param mime_type [String, nil] optional MIME type
29
+ # @param annotations [Hash, nil] optional annotations that provide hints to clients
30
+ # @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)
32
+ @uri_template = uri_template
33
+ @name = name
34
+ @title = title
35
+ @description = description
36
+ @mime_type = mime_type
37
+ @annotations = annotations
38
+ @server = server
39
+ end
40
+
41
+ # Create a ResourceTemplate instance from JSON data
42
+ # @param data [Hash] JSON data from MCP server
43
+ # @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
44
+ # @return [MCPClient::ResourceTemplate] resource template instance
45
+ def self.from_json(data, server: nil)
46
+ new(
47
+ uri_template: data['uriTemplate'],
48
+ name: data['name'],
49
+ title: data['title'],
50
+ description: data['description'],
51
+ mime_type: data['mimeType'],
52
+ annotations: data['annotations'],
53
+ server: server
54
+ )
55
+ end
56
+ end
57
+ end
@@ -48,18 +48,46 @@ module MCPClient
48
48
  end
49
49
 
50
50
  # List all resources available from the MCP server
51
- # @return [Array<MCPClient::Resource>] list of available resources
52
- def list_resources
51
+ # @param cursor [String, nil] optional cursor for pagination
52
+ # @return [Hash] result containing resources array and optional nextCursor
53
+ def list_resources(cursor: nil)
53
54
  raise NotImplementedError, 'Subclasses must implement list_resources'
54
55
  end
55
56
 
56
57
  # Read a resource by its URI
57
58
  # @param uri [String] the URI of the resource to read
58
- # @return [Object] the resource contents
59
+ # @return [Array<MCPClient::ResourceContent>] array of resource contents
59
60
  def read_resource(uri)
60
61
  raise NotImplementedError, 'Subclasses must implement read_resource'
61
62
  end
62
63
 
64
+ # List all resource templates available from the MCP server
65
+ # @param cursor [String, nil] optional cursor for pagination
66
+ # @return [Hash] result containing resourceTemplates array and optional nextCursor
67
+ def list_resource_templates(cursor: nil)
68
+ raise NotImplementedError, 'Subclasses must implement list_resource_templates'
69
+ end
70
+
71
+ # Subscribe to resource updates
72
+ # @param uri [String] the URI of the resource to subscribe to
73
+ # @return [Boolean] true if subscription successful
74
+ def subscribe_resource(uri)
75
+ raise NotImplementedError, 'Subclasses must implement subscribe_resource'
76
+ end
77
+
78
+ # Unsubscribe from resource updates
79
+ # @param uri [String] the URI of the resource to unsubscribe from
80
+ # @return [Boolean] true if unsubscription successful
81
+ def unsubscribe_resource(uri)
82
+ raise NotImplementedError, 'Subclasses must implement unsubscribe_resource'
83
+ end
84
+
85
+ # Get server capabilities
86
+ # @return [Hash, nil] server capabilities
87
+ def capabilities
88
+ raise NotImplementedError, 'Subclasses must implement capabilities'
89
+ end
90
+
63
91
  # Clean up the server connection
64
92
  def cleanup
65
93
  raise NotImplementedError, 'Subclasses must implement cleanup'
@@ -76,7 +76,8 @@ module MCPClient
76
76
  retries: config[:retries] || 3,
77
77
  retry_backoff: config[:retry_backoff] || 1,
78
78
  name: config[:name],
79
- logger: logger
79
+ logger: logger,
80
+ oauth_provider: config[:oauth_provider]
80
81
  )
81
82
  end
82
83
 
@@ -95,7 +96,8 @@ module MCPClient
95
96
  retries: config[:retries] || 3,
96
97
  retry_backoff: config[:retry_backoff] || 1,
97
98
  name: config[:name],
98
- logger: logger
99
+ logger: logger,
100
+ oauth_provider: config[:oauth_provider]
99
101
  )
100
102
  end
101
103
 
@@ -12,6 +12,14 @@ module MCPClient
12
12
  # Implementation of MCP server that communicates via HTTP requests/responses
13
13
  # Useful for communicating with MCP servers that support HTTP-based transport
14
14
  # without Server-Sent Events streaming
15
+ #
16
+ # @note Elicitation Support (MCP 2025-06-18)
17
+ # This transport does NOT support server-initiated elicitation requests.
18
+ # The HTTP transport uses a pure request-response architecture where only the client
19
+ # can initiate requests. For elicitation support, use one of these transports instead:
20
+ # - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
21
+ # - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
22
+ # - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
15
23
  class ServerHTTP < ServerBase
16
24
  require_relative 'server_http/json_rpc_transport'
17
25
 
@@ -216,6 +224,148 @@ module MCPClient
216
224
  end
217
225
  end
218
226
 
227
+ # List all prompts available from the MCP server
228
+ # @return [Array<MCPClient::Prompt>] list of available prompts
229
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
230
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
231
+ # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
232
+ def list_prompts
233
+ @mutex.synchronize do
234
+ return @prompts if @prompts
235
+ end
236
+
237
+ begin
238
+ ensure_connected
239
+
240
+ prompts_data = rpc_request('prompts/list')
241
+ prompts = prompts_data['prompts'] || []
242
+
243
+ @mutex.synchronize do
244
+ @prompts = prompts.map do |prompt_data|
245
+ MCPClient::Prompt.from_json(prompt_data, server: self)
246
+ end
247
+ end
248
+
249
+ @mutex.synchronize { @prompts }
250
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
251
+ raise
252
+ rescue StandardError => e
253
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
254
+ end
255
+ end
256
+
257
+ # Get a prompt with the given parameters
258
+ # @param prompt_name [String] the name of the prompt to get
259
+ # @param parameters [Hash] the parameters to pass to the prompt
260
+ # @return [Object] the result of the prompt interpolation
261
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
262
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
263
+ # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
264
+ def get_prompt(prompt_name, parameters)
265
+ rpc_request('prompts/get', {
266
+ name: prompt_name,
267
+ arguments: parameters
268
+ })
269
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
270
+ raise
271
+ rescue StandardError => e
272
+ raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
273
+ end
274
+
275
+ # List all resources available from the MCP server
276
+ # @param cursor [String, nil] optional cursor for pagination
277
+ # @return [Hash] result containing resources array and optional nextCursor
278
+ # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
279
+ def list_resources(cursor: nil)
280
+ @mutex.synchronize do
281
+ return @resources_result if @resources_result && !cursor
282
+ end
283
+
284
+ begin
285
+ ensure_connected
286
+
287
+ params = {}
288
+ params['cursor'] = cursor if cursor
289
+ result = rpc_request('resources/list', params)
290
+
291
+ resources = (result['resources'] || []).map do |resource_data|
292
+ MCPClient::Resource.from_json(resource_data, server: self)
293
+ end
294
+
295
+ resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
296
+
297
+ @mutex.synchronize do
298
+ @resources_result = resources_result unless cursor
299
+ end
300
+
301
+ resources_result
302
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
303
+ raise
304
+ rescue StandardError => e
305
+ raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
306
+ end
307
+ end
308
+
309
+ # Read a resource by its URI
310
+ # @param uri [String] the URI of the resource to read
311
+ # @return [Array<MCPClient::ResourceContent>] array of resource contents
312
+ # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
313
+ def read_resource(uri)
314
+ result = rpc_request('resources/read', { uri: uri })
315
+ contents = result['contents'] || []
316
+ contents.map { |content| MCPClient::ResourceContent.from_json(content) }
317
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
318
+ raise
319
+ rescue StandardError => e
320
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
321
+ end
322
+
323
+ # List all resource templates available from the MCP server
324
+ # @param cursor [String, nil] optional cursor for pagination
325
+ # @return [Hash] result containing resourceTemplates array and optional nextCursor
326
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
327
+ def list_resource_templates(cursor: nil)
328
+ params = {}
329
+ params['cursor'] = cursor if cursor
330
+ result = rpc_request('resources/templates/list', params)
331
+
332
+ templates = (result['resourceTemplates'] || []).map do |template_data|
333
+ MCPClient::ResourceTemplate.from_json(template_data, server: self)
334
+ end
335
+
336
+ { 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
337
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
338
+ raise
339
+ rescue StandardError => e
340
+ raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
341
+ end
342
+
343
+ # Subscribe to resource updates
344
+ # @param uri [String] the URI of the resource to subscribe to
345
+ # @return [Boolean] true if subscription successful
346
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
347
+ def subscribe_resource(uri)
348
+ rpc_request('resources/subscribe', { uri: uri })
349
+ true
350
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
351
+ raise
352
+ rescue StandardError => e
353
+ raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
354
+ end
355
+
356
+ # Unsubscribe from resource updates
357
+ # @param uri [String] the URI of the resource to unsubscribe from
358
+ # @return [Boolean] true if unsubscription successful
359
+ # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
360
+ def unsubscribe_resource(uri)
361
+ rpc_request('resources/unsubscribe', { uri: uri })
362
+ true
363
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
364
+ raise
365
+ rescue StandardError => e
366
+ raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
367
+ end
368
+
219
369
  # Stream tool call (default implementation returns single-value stream)
220
370
  # @param tool_name [String] the name of the tool to call
221
371
  # @param parameters [Hash] the parameters to pass to the tool
@@ -31,6 +31,7 @@ module MCPClient
31
31
  data = JSON.parse(event[:data])
32
32
 
33
33
  return if process_error_in_message(data)
34
+ return if process_server_request?(data)
34
35
  return if process_notification?(data)
35
36
 
36
37
  process_response?(data)
@@ -58,6 +59,16 @@ module MCPClient
58
59
  true
59
60
  end
60
61
 
62
+ # Process a JSON-RPC request from server (has both id AND method)
63
+ # @param data [Hash] the parsed JSON payload
64
+ # @return [Boolean] true if we saw & handled a server request
65
+ def process_server_request?(data)
66
+ return false unless data['method'] && data.key?('id')
67
+
68
+ handle_server_request(data)
69
+ true
70
+ end
71
+
61
72
  # Process a JSON-RPC notification (no id => notification)
62
73
  # @param data [Hash] the parsed JSON payload
63
74
  # @return [Boolean] true if we saw & handled a notification