ruby-mcp-client 0.7.3 → 0.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd6b4269c644ed783b7d574e5a60b26ef0c749c3fe0a58fc86b4d7d5d8786a0d
4
- data.tar.gz: 58eb4935ec413db8478dc29983f5baa68d7c28627968ffb5189d1b9754090a51
3
+ metadata.gz: 7e0949102c5c22625bef68e2f5b4a0e2aecdfc636495ecba52d16f663c35f2fc
4
+ data.tar.gz: fbeb7484c1458775b88aa074422913e9d09d8a37b23aac38c7edf82e2111b9cb
5
5
  SHA512:
6
- metadata.gz: ee86e5f1e58fb98df9a9a7e715049eb8d7ae5f182430060633c9f97d7b2268d1a02859403ace69f6a400344cffad80dd9238169a638e27491a6bc99ce70fb462
7
- data.tar.gz: 27e82788b84bc0887c273ab3c1277180e85f4930f413500fab4dbf2a7474dcb151bf510ec2509caf7a95bf53311dee587e65d8826b226e7ef164d9acd640582a
6
+ metadata.gz: b2e75071c0fceb5061037b1537d9e71fd8f03dc5c94aa6c2bbc2a63ce6f24ae16c1c2c2cdb560f48e12ca4115224561f89043dde2145116d1e4e397c9cb7e409
7
+ data.tar.gz: d3900ae79aba5d896a1496bc28af3e9ec8f0b7066f30eb0723cd1664845769dd09afd130faa2db0b4b1b67d948f15e3532cfb5abcd3b076d0e7547e7e89671fb
data/README.md CHANGED
@@ -49,6 +49,8 @@ This Ruby MCP Client implements key features from the latest MCP specification (
49
49
  - **Streamable HTTP Transport** - Enhanced transport with Server-Sent Event formatted responses and session management
50
50
  - **HTTP Redirect Support** - Automatic redirect handling for both SSE and HTTP transports with configurable limits
51
51
  - **FastMCP Compatibility** - Full compatibility with FastMCP servers including proper line ending handling
52
+ - **Prompts Support** - Full implementation of MCP prompts for dynamic content generation
53
+ - **Resources Support** - Full MCP resources specification compliance including templates, subscriptions, pagination, and annotations
52
54
 
53
55
  ## Usage
54
56
 
@@ -87,6 +89,17 @@ client = MCPClient.create_client(
87
89
  retries: 3, # Optional number of retry attempts (default: 3)
88
90
  retry_backoff: 1, # Optional backoff delay in seconds (default: 1)
89
91
  logger: Logger.new($stdout, level: Logger::INFO) # Optional logger for this server
92
+ ),
93
+ # Streamable HTTP server (HTTP POST with SSE responses and session management)
94
+ MCPClient.streamable_http_config(
95
+ base_url: 'https://api.example.com/mcp',
96
+ endpoint: '/rpc', # Optional JSON-RPC endpoint path (default: '/rpc')
97
+ headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
98
+ name: 'streamable_api', # Optional name for this server
99
+ read_timeout: 60, # Optional timeout in seconds (default: 30)
100
+ retries: 3, # Optional number of retry attempts (default: 3)
101
+ retry_backoff: 1, # Optional backoff delay in seconds (default: 1)
102
+ logger: Logger.new($stdout, level: Logger::INFO) # Optional logger for this server
90
103
  )
91
104
  ],
92
105
  # Optional logger for the client and all servers without explicit loggers
@@ -100,13 +113,14 @@ client = MCPClient.create_client(
100
113
  )
101
114
 
102
115
  # MCP server configuration JSON format can be:
103
- # 1. A single server object:
116
+ # 1. A single server object:
104
117
  # { "type": "sse", "url": "http://example.com/sse" }
105
118
  # { "type": "http", "url": "http://example.com", "endpoint": "/rpc" }
106
- # 2. An array of server objects:
107
- # [{ "type": "stdio", "command": "npx server" }, { "type": "sse", "url": "http://..." }, { "type": "http", "url": "http://..." }]
119
+ # { "type": "streamable_http", "url": "http://example.com/mcp", "endpoint": "/rpc" }
120
+ # 2. An array of server objects:
121
+ # [{ "type": "stdio", "command": "npx server" }, { "type": "sse", "url": "http://..." }, { "type": "streamable_http", "url": "http://..." }]
108
122
  # 3. An object with "mcpServers" key containing named servers:
109
- # { "mcpServers": { "server1": { "type": "sse", "url": "http://..." }, "server2": { "type": "http", "url": "http://..." } } }
123
+ # { "mcpServers": { "server1": { "type": "sse", "url": "http://..." }, "server2": { "type": "streamable_http", "url": "http://..." } } }
110
124
  # Note: When using this format, server1/server2 will be accessible by name
111
125
 
112
126
  # List available tools
@@ -140,6 +154,45 @@ client.call_tool_streaming('streaming_tool', { param: 'value' }, server: 'api').
140
154
  puts chunk
141
155
  end
142
156
 
157
+ # === Working with Prompts ===
158
+ # List available prompts from all servers
159
+ prompts = client.list_prompts
160
+
161
+ # Get a specific prompt with parameters
162
+ result = client.get_prompt('greeting', { name: 'Ruby Developer' })
163
+
164
+ # Get a prompt from a specific server
165
+ result = client.get_prompt('greeting', { name: 'Ruby Developer' }, server: 'filesystem')
166
+
167
+ # === Working with Resources ===
168
+ # List available resources from all servers (returns hash with 'resources' array and optional 'nextCursor')
169
+ result = client.list_resources
170
+ resources = result['resources'] # Array of Resource objects from all servers
171
+ next_cursor = result['nextCursor'] # nil when aggregating multiple servers
172
+
173
+ # Get resources from a specific server with pagination support
174
+ result = client.servers.first.list_resources
175
+ resources = result['resources'] # Array of Resource objects
176
+ next_cursor = result['nextCursor'] # For pagination
177
+
178
+ # List resources with pagination (only works with single server or client.list_resources with cursor)
179
+ result = client.list_resources(cursor: next_cursor) # Uses first server when cursor provided
180
+
181
+ # Read a specific resource by URI (returns array of ResourceContent objects)
182
+ contents = client.read_resource('file:///example.txt')
183
+ contents.each do |content|
184
+ if content.text?
185
+ puts content.text # Text content
186
+ elsif content.binary?
187
+ data = Base64.decode64(content.blob) # Binary content
188
+ end
189
+ puts content.mime_type if content.mime_type
190
+ puts content.annotations if content.annotations # Optional metadata
191
+ end
192
+
193
+ # Read a resource from a specific server
194
+ contents = client.read_resource('file:///example.txt', server: 'filesystem')
195
+
143
196
  # Format tools for specific AI services
144
197
  openai_tools = client.to_openai_tools
145
198
  anthropic_tools = client.to_anthropic_tools
@@ -333,15 +386,21 @@ puts "Ping successful: #{ping_result.inspect}"
333
386
  mcp_client.cleanup
334
387
  ```
335
388
 
336
- See `examples/mcp_sse_server_example.rb` for the full Playwright SSE example.
389
+ See `examples/streamable_http_example.rb` for the full Playwright SSE example.
337
390
 
338
391
  ### FastMCP Example
339
392
 
340
- The repository includes a complete FastMCP server example that demonstrates the Ruby MCP client working with a Python FastMCP server:
393
+ The repository includes complete FastMCP server examples that demonstrate the Ruby MCP client working with Python FastMCP servers, including full MCP protocol support with tools, prompts, and resources:
394
+
395
+ #### Basic FastMCP Example
396
+
397
+ **For FastMCP server with SSE transport (includes tools, prompts, and resources):**
398
+ ```bash
399
+ # From the ruby-mcp-client directory
400
+ python examples/echo_server.py
401
+ ```
341
402
 
342
403
  ```ruby
343
- # Start the FastMCP server
344
- # python examples/echo_server.py
345
404
 
346
405
  # Run the Ruby client
347
406
  # bundle exec ruby examples/echo_server_client.rb
@@ -352,17 +411,77 @@ require 'mcp_client'
352
411
  client = MCPClient.create_client(
353
412
  mcp_server_configs: [
354
413
  MCPClient.sse_config(
355
- base_url: 'http://127.0.0.1:8000/sse/',
414
+ base_url: 'http://127.0.0.1:8000/sse',
356
415
  read_timeout: 30
357
416
  )
358
417
  ]
359
418
  )
360
419
 
361
- # List available tools
420
+ # List and use tools
362
421
  tools = client.list_tools
363
422
  puts "Found #{tools.length} tools:"
364
423
  tools.each { |tool| puts "- #{tool.name}: #{tool.description}" }
365
424
 
425
+ result = client.call_tool('echo', { message: 'Hello FastMCP!' })
426
+
427
+ # List and use prompts
428
+ prompts = client.list_prompts
429
+ puts "Found #{prompts.length} prompts:"
430
+ prompts.each { |prompt| puts "- #{prompt.name}: #{prompt.description}" }
431
+
432
+ greeting = client.get_prompt('greeting', { name: 'Ruby Developer' })
433
+
434
+ # List and read resources
435
+ result = client.list_resources
436
+ resources = result['resources']
437
+ puts "Found #{resources.length} resources:"
438
+ resources.each { |resource| puts "- #{resource.name} (#{resource.uri})" }
439
+
440
+ # Read resource (returns array of ResourceContent objects)
441
+ contents = client.read_resource('file:///sample/README.md')
442
+ contents.each do |content|
443
+ puts content.text if content.text?
444
+ end
445
+ ```
446
+
447
+ #### Streamable HTTP Example
448
+
449
+ **For FastMCP server with Streamable HTTP transport (includes tools, prompts, and resources):**
450
+ ```bash
451
+ # From the ruby-mcp-client directory
452
+ python examples/echo_server_streamable.py
453
+ ```
454
+
455
+ ```ruby
456
+
457
+ # Run the streamable HTTP client
458
+ # bundle exec ruby examples/echo_server_streamable_client.rb
459
+
460
+ require 'mcp_client'
461
+
462
+ # Connect to streamable HTTP server with full MCP protocol support
463
+ client = MCPClient.create_client(
464
+ mcp_server_configs: [
465
+ MCPClient.streamable_http_config(
466
+ base_url: 'http://localhost:8931/mcp',
467
+ read_timeout: 60
468
+ )
469
+ ]
470
+ )
471
+
472
+ # Full protocol support including real-time notifications
473
+ client.on_notification do |method, params|
474
+ puts "Server notification: #{method} - #{params}"
475
+ end
476
+
477
+ # Use all MCP features: tools, prompts, resources
478
+ tools = client.list_tools
479
+ prompts = client.list_prompts
480
+ resources = client.list_resources
481
+
482
+ # Real-time progress notifications for long-running tasks
483
+ result = client.call_tool('long_task', { duration: 5, steps: 5 })
484
+
366
485
  # Use the tools
367
486
  result = client.call_tool('echo', { message: 'Hello from Ruby!' })
368
487
  result = client.call_tool('reverse', { text: 'FastMCP rocks!' })
@@ -371,8 +490,10 @@ client.cleanup
371
490
  ```
372
491
 
373
492
  The FastMCP example includes:
374
- - **`echo_server.py`** - A Python FastMCP server with 4 interactive tools
375
- - **`echo_server_client.rb`** - Ruby client demonstrating all features
493
+ - **`echo_server.py`** - A Python FastMCP server with tools, prompts, and resources
494
+ - **`echo_server_client.rb`** - Ruby client demonstrating all features including prompts and resources
495
+ - **`echo_server_streamable.py`** - Enhanced streamable HTTP server with tools, prompts, and resources
496
+ - **`echo_server_streamable_client.rb`** - Ruby client demonstrating streamable HTTP transport
376
497
  - **`README_ECHO_SERVER.md`** - Complete setup and usage instructions
377
498
 
378
499
  This example showcases redirect support, proper line ending handling, and seamless integration between Ruby and Python MCP implementations.
@@ -464,8 +585,9 @@ Complete examples can be found in the `examples/` directory:
464
585
  - `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
465
586
  - `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
466
587
  - `gemini_ai_mcp.rb` - Integration with Google Vertex AI and Gemini models
467
- - `mcp_sse_server_example.rb` - SSE transport with Playwright MCP
588
+ - `streamable_http_example.rb` - Streamable HTTP transport with Playwright MCP
468
589
  - `echo_server.py` & `echo_server_client.rb` - FastMCP server example with full setup
590
+ - `echo_server_streamable.py` & `echo_server_streamable_client.rb` - Enhanced streamable HTTP server example
469
591
 
470
592
  ## MCP Server Compatibility
471
593
 
@@ -666,6 +788,126 @@ token = oauth_provider.complete_authorization_flow(code, state)
666
788
 
667
789
  For complete OAuth documentation, see [OAUTH.md](OAUTH.md).
668
790
 
791
+ ## Resources
792
+
793
+ The Ruby MCP Client provides full support for the MCP resources specification, enabling access to files, data, and other content with URI-based identification.
794
+
795
+ ### Resource Features
796
+
797
+ - **Resource listing** with cursor-based pagination
798
+ - **Resource templates** with URI patterns (RFC 6570)
799
+ - **Resource subscriptions** for real-time updates
800
+ - **Binary content support** with base64 encoding
801
+ - **Resource annotations** for metadata (audience, priority, lastModified)
802
+ - **ResourceContent objects** for structured content access
803
+
804
+ ### Resource API
805
+
806
+ ```ruby
807
+ # Get a server instance
808
+ server = client.servers.first # or client.find_server('name')
809
+
810
+ # List resources with pagination
811
+ result = server.list_resources
812
+ resources = result['resources'] # Array of Resource objects
813
+ next_cursor = result['nextCursor'] # String cursor for next page (if any)
814
+
815
+ # Get next page of resources
816
+ if next_cursor
817
+ next_result = server.list_resources(cursor: next_cursor)
818
+ end
819
+
820
+ # Access Resource properties
821
+ resource = resources.first
822
+ resource.uri # "file:///example.txt"
823
+ resource.name # "example.txt"
824
+ resource.title # Optional human-readable title
825
+ resource.description # Optional description
826
+ resource.mime_type # "text/plain"
827
+ resource.size # Optional file size in bytes
828
+ resource.annotations # Optional metadata hash
829
+
830
+ # Read resource contents (returns array of ResourceContent objects)
831
+ contents = server.read_resource(resource.uri)
832
+
833
+ contents.each do |content|
834
+ # Check content type
835
+ if content.text?
836
+ # Text content
837
+ text = content.text
838
+ mime = content.mime_type # e.g., "text/plain"
839
+ elsif content.binary?
840
+ # Binary content (base64 encoded)
841
+ blob = content.blob # Base64 string
842
+ data = Base64.decode64(blob) # Decoded binary data
843
+ end
844
+
845
+ # Access optional annotations
846
+ if content.annotations
847
+ audience = content.annotations['audience'] # e.g., ["user", "assistant"]
848
+ priority = content.annotations['priority'] # e.g., 0.5
849
+ modified = content.annotations['lastModified'] # ISO 8601 timestamp
850
+ end
851
+ end
852
+
853
+ # List resource templates
854
+ templates_result = server.list_resource_templates
855
+ templates = templates_result['resourceTemplates'] # Array of ResourceTemplate objects
856
+
857
+ template = templates.first
858
+ template.uri_template # "file:///{path}" (RFC 6570 URI template)
859
+ template.name # Template name
860
+ template.title # Optional title
861
+ template.description # Optional description
862
+ template.mime_type # Optional MIME type hint
863
+
864
+ # Subscribe to resource updates
865
+ server.subscribe_resource('file:///watched.txt') # Returns true on success
866
+
867
+ # Unsubscribe from resource updates
868
+ server.unsubscribe_resource('file:///watched.txt') # Returns true on success
869
+
870
+ # Check server capabilities for resources
871
+ capabilities = server.capabilities
872
+ if capabilities['resources']
873
+ can_subscribe = capabilities['resources']['subscribe'] # true/false
874
+ list_changed = capabilities['resources']['listChanged'] # true/false
875
+ end
876
+ ```
877
+
878
+ ### Working with ResourceContent
879
+
880
+ The `ResourceContent` class provides a structured way to handle both text and binary content:
881
+
882
+ ```ruby
883
+ # ResourceContent for text
884
+ content = MCPClient::ResourceContent.new(
885
+ uri: 'file:///example.txt',
886
+ name: 'example.txt',
887
+ mime_type: 'text/plain',
888
+ text: 'File contents here',
889
+ annotations: {
890
+ 'audience' => ['user'],
891
+ 'priority' => 1.0
892
+ }
893
+ )
894
+
895
+ # ResourceContent for binary data
896
+ binary_content = MCPClient::ResourceContent.new(
897
+ uri: 'file:///image.png',
898
+ name: 'image.png',
899
+ mime_type: 'image/png',
900
+ blob: Base64.strict_encode64(binary_data)
901
+ )
902
+
903
+ # Access content
904
+ if content.text?
905
+ puts content.text
906
+ elsif content.binary?
907
+ data = Base64.decode64(content.blob)
908
+ end
909
+ ```
910
+
669
911
  ## Key Features
670
912
 
671
913
  ### Client Features
@@ -679,6 +921,8 @@ For complete OAuth documentation, see [OAUTH.md](OAUTH.md).
679
921
  - **Server disambiguation** - Specify which server to use when tools with same name exist in multiple servers
680
922
  - **Atomic tool calls** - Simple API for invoking tools with parameters
681
923
  - **Batch support** - Call multiple tools in a single operation
924
+ - **Prompts support** - List and get prompts with parameters from MCP servers
925
+ - **Resources support** - List and read resources by URI from MCP servers
682
926
  - **API conversions** - Built-in format conversion for OpenAI, Anthropic, and Google Vertex AI APIs
683
927
  - **Thread safety** - Synchronized access for thread-safe operation
684
928
  - **Server notifications** - Support for JSON-RPC notifications
@@ -9,10 +9,14 @@ module MCPClient
9
9
  # @!attribute [r] servers
10
10
  # @return [Array<MCPClient::ServerBase>] list of servers
11
11
  # @!attribute [r] tool_cache
12
- # @return [Hash<String, MCPClient::Tool>] cache of tools by name
12
+ # @return [Hash<String, MCPClient::Tool>] cache of tools by composite key (server_id:name)
13
+ # @!attribute [r] prompt_cache
14
+ # @return [Hash<String, MCPClient::Prompt>] cache of prompts by composite key (server_id:name)
15
+ # @!attribute [r] resource_cache
16
+ # @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
13
17
  # @!attribute [r] logger
14
18
  # @return [Logger] logger for client operations
15
- attr_reader :servers, :tool_cache, :logger
19
+ attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger
16
20
 
17
21
  # Initialize a new MCPClient::Client
18
22
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
@@ -26,6 +30,8 @@ module MCPClient
26
30
  MCPClient::ServerFactory.create(config, logger: @logger)
27
31
  end
28
32
  @tool_cache = {}
33
+ @prompt_cache = {}
34
+ @resource_cache = {}
29
35
  # JSON-RPC notification listeners
30
36
  @notification_listeners = []
31
37
  # Register default and user-defined notification handlers on each server
@@ -39,6 +45,149 @@ module MCPClient
39
45
  end
40
46
  end
41
47
 
48
+ # Lists all available prompts from all connected MCP servers
49
+ # @param cache [Boolean] whether to use cached prompts or fetch fresh
50
+ # @return [Array<MCPClient::Prompt>] list of available prompts
51
+ # @raise [MCPClient::Errors::ConnectionError] on authorization failures
52
+ # @raise [MCPClient::Errors::PromptGetError] if no prompts could be retrieved from any server
53
+ def list_prompts(cache: true)
54
+ return @prompt_cache.values if cache && !@prompt_cache.empty?
55
+
56
+ prompts = []
57
+ connection_errors = []
58
+
59
+ servers.each do |server|
60
+ server.list_prompts.each do |prompt|
61
+ cache_key = cache_key_for(server, prompt.name)
62
+ @prompt_cache[cache_key] = prompt
63
+ prompts << prompt
64
+ end
65
+ rescue MCPClient::Errors::ConnectionError => e
66
+ # Fast-fail on authorization errors for better user experience
67
+ # If this is the first server or we haven't collected any prompts yet,
68
+ # raise the auth error directly to avoid cascading error messages
69
+ raise e if e.message.include?('Authorization failed') && prompts.empty?
70
+
71
+ # Store the error and try other servers
72
+ connection_errors << e
73
+ @logger.error("Server error: #{e.message}")
74
+ end
75
+
76
+ prompts
77
+ end
78
+
79
+ # Gets a specific prompt by name with the given parameters
80
+ # @param prompt_name [String] the name of the prompt to get
81
+ # @param parameters [Hash] the parameters to pass to the prompt
82
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
83
+ # @return [Object] the final prompt
84
+ def get_prompt(prompt_name, parameters, server: nil)
85
+ prompts = list_prompts
86
+
87
+ if server
88
+ # Use the specified server
89
+ srv = select_server(server)
90
+ # Find the prompt on this specific server
91
+ prompt = prompts.find { |t| t.name == prompt_name && t.server == srv }
92
+ unless prompt
93
+ raise MCPClient::Errors::PromptNotFound,
94
+ "Prompt '#{prompt_name}' not found on server '#{srv.name || srv.class.name}'"
95
+ end
96
+ else
97
+ # Find the prompt across all servers
98
+ matching_prompts = prompts.select { |t| t.name == prompt_name }
99
+
100
+ if matching_prompts.empty?
101
+ raise MCPClient::Errors::PromptNotFound, "Prompt '#{prompt_name}' not found"
102
+ elsif matching_prompts.size > 1
103
+ # If multiple matches, disambiguate with server names
104
+ server_names = matching_prompts.map { |t| t.server&.name || 'unnamed' }
105
+ raise MCPClient::Errors::AmbiguousPromptName,
106
+ "Multiple prompts named '#{prompt_name}' found across servers (#{server_names.join(', ')}). " \
107
+ "Please specify a server using the 'server' parameter."
108
+ end
109
+
110
+ prompt = matching_prompts.first
111
+ end
112
+
113
+ # Use the prompt's associated server
114
+ server = prompt.server
115
+ raise MCPClient::Errors::ServerNotFound, "No server found for prompt '#{prompt_name}'" unless server
116
+
117
+ begin
118
+ server.get_prompt(prompt_name, parameters)
119
+ rescue MCPClient::Errors::ConnectionError => e
120
+ # Add server identity information to the error for better context
121
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
122
+ raise MCPClient::Errors::PromptGetError,
123
+ "Error getting prompt '#{prompt_name}': #{e.message} (Server: #{server_id})"
124
+ end
125
+ end
126
+
127
+ # Lists all available resources from all connected MCP servers
128
+ # @param cache [Boolean] whether to use cached resources or fetch fresh
129
+ # @param cursor [String, nil] optional cursor for pagination (only works with single server)
130
+ # @return [Hash] result containing 'resources' array and optional 'nextCursor'
131
+ # @raise [MCPClient::Errors::ConnectionError] on authorization failures
132
+ # @raise [MCPClient::Errors::ResourceReadError] if no resources could be retrieved from any server
133
+ def list_resources(cache: true, cursor: nil)
134
+ # If cursor is provided, we can only query one server (the one that provided the cursor)
135
+ # This is a limitation of aggregating multiple servers
136
+ if cursor
137
+ # For now, just use the first server when cursor is provided
138
+ # In a real implementation, you'd need to track which server the cursor came from
139
+ return servers.first.list_resources(cursor: cursor) if servers.any?
140
+
141
+ return { 'resources' => [], 'nextCursor' => nil }
142
+ end
143
+
144
+ # Use cache if available and no cursor
145
+ return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?
146
+
147
+ resources = []
148
+ connection_errors = []
149
+
150
+ servers.each do |server|
151
+ result = server.list_resources
152
+ resource_list = result['resources'] || []
153
+
154
+ resource_list.each do |resource|
155
+ cache_key = cache_key_for(server, resource.uri)
156
+ @resource_cache[cache_key] = resource
157
+ resources << resource
158
+ end
159
+ rescue MCPClient::Errors::ConnectionError => e
160
+ # Fast-fail on authorization errors for better user experience
161
+ # If this is the first server or we haven't collected any resources yet,
162
+ # raise the auth error directly to avoid cascading error messages
163
+ raise e if e.message.include?('Authorization failed') && resources.empty?
164
+
165
+ # Store the error and try other servers
166
+ connection_errors << e
167
+ @logger.error("Server error: #{e.message}")
168
+ end
169
+
170
+ # Return hash format consistent with server methods
171
+ { 'resources' => resources, 'nextCursor' => nil }
172
+ end
173
+
174
+ # Reads a specific resource by URI
175
+ # @param uri [String] the URI of the resource to read
176
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
177
+ # @return [Object] the resource contents
178
+ def read_resource(uri, server: nil)
179
+ result = list_resources
180
+ resources = result['resources'] || []
181
+
182
+ resource = if server
183
+ find_resource_on_server(uri, resources, server)
184
+ else
185
+ find_resource_across_servers(uri, resources)
186
+ end
187
+
188
+ execute_resource_read(resource, uri)
189
+ end
190
+
42
191
  # Lists all available tools from all connected MCP servers
43
192
  # @param cache [Boolean] whether to use cached tools or fetch fresh
44
193
  # @return [Array<MCPClient::Tool>] list of available tools
@@ -52,7 +201,8 @@ module MCPClient
52
201
 
53
202
  servers.each do |server|
54
203
  server.list_tools.each do |tool|
55
- @tool_cache[tool.name] = tool
204
+ cache_key = cache_key_for(server, tool.name)
205
+ @tool_cache[cache_key] = tool
56
206
  tools << tool
57
207
  end
58
208
  rescue MCPClient::Errors::ConnectionError => e
@@ -162,6 +312,8 @@ module MCPClient
162
312
  # @return [void]
163
313
  def clear_cache
164
314
  @tool_cache.clear
315
+ @prompt_cache.clear
316
+ @resource_cache.clear
165
317
  end
166
318
 
167
319
  # Register a callback for JSON-RPC notifications from servers
@@ -312,13 +464,15 @@ module MCPClient
312
464
  case method
313
465
  when 'notifications/tools/list_changed'
314
466
  logger.warn("[#{server_id}] Tool list has changed, clearing tool cache")
315
- clear_cache
467
+ @tool_cache.clear
316
468
  when 'notifications/resources/updated'
317
469
  logger.warn("[#{server_id}] Resource #{params['uri']} updated")
318
470
  when 'notifications/prompts/list_changed'
319
- logger.warn("[#{server_id}] Prompt list has changed")
471
+ logger.warn("[#{server_id}] Prompt list has changed, clearing prompt cache")
472
+ @prompt_cache.clear
320
473
  when 'notifications/resources/list_changed'
321
- logger.warn("[#{server_id}] Resource list has changed")
474
+ logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
475
+ @resource_cache.clear
322
476
  else
323
477
  # Log unknown notification types for debugging purposes
324
478
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
@@ -379,5 +533,70 @@ module MCPClient
379
533
  server.list_tools.any? { |t| t.name == tool.name }
380
534
  end
381
535
  end
536
+
537
+ # Generate a cache key for server-specific items
538
+ # @param server [MCPClient::ServerBase] the server
539
+ # @param item_id [String] the item identifier (name or URI)
540
+ # @return [String] composite cache key
541
+ def cache_key_for(server, item_id)
542
+ server_id = server.object_id.to_s
543
+ "#{server_id}:#{item_id}"
544
+ end
545
+
546
+ # Find a resource on a specific server
547
+ # @param uri [String] the URI of the resource
548
+ # @param resources [Array<Resource>] available resources
549
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase] server selector
550
+ # @return [Resource] the found resource
551
+ # @raise [MCPClient::Errors::ResourceNotFound] if resource not found
552
+ def find_resource_on_server(uri, resources, server)
553
+ srv = select_server(server)
554
+ resource = resources.find { |r| r.uri == uri && r.server == srv }
555
+ unless resource
556
+ raise MCPClient::Errors::ResourceNotFound,
557
+ "Resource '#{uri}' not found on server '#{srv.name || srv.class.name}'"
558
+ end
559
+ resource
560
+ end
561
+
562
+ # Find a resource across all servers
563
+ # @param uri [String] the URI of the resource
564
+ # @param resources [Array<Resource>] available resources
565
+ # @return [Resource] the found resource
566
+ # @raise [MCPClient::Errors::ResourceNotFound] if resource not found
567
+ # @raise [MCPClient::Errors::AmbiguousResourceURI] if multiple resources found
568
+ def find_resource_across_servers(uri, resources)
569
+ matching_resources = resources.select { |r| r.uri == uri }
570
+
571
+ if matching_resources.empty?
572
+ raise MCPClient::Errors::ResourceNotFound, "Resource '#{uri}' not found"
573
+ elsif matching_resources.size > 1
574
+ server_names = matching_resources.map { |r| r.server&.name || 'unnamed' }
575
+ raise MCPClient::Errors::AmbiguousResourceURI,
576
+ "Multiple resources with URI '#{uri}' found across servers (#{server_names.join(', ')}). " \
577
+ "Please specify a server using the 'server' parameter."
578
+ end
579
+
580
+ matching_resources.first
581
+ end
582
+
583
+ # Execute the resource read operation
584
+ # @param resource [Resource] the resource to read
585
+ # @param uri [String] the URI of the resource
586
+ # @return [Object] the resource contents
587
+ # @raise [MCPClient::Errors::ServerNotFound] if no server found
588
+ # @raise [MCPClient::Errors::ResourceReadError] on read errors
589
+ def execute_resource_read(resource, uri)
590
+ server = resource.server
591
+ raise MCPClient::Errors::ServerNotFound, "No server found for resource '#{uri}'" unless server
592
+
593
+ begin
594
+ server.read_resource(uri)
595
+ rescue MCPClient::Errors::ConnectionError => e
596
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
597
+ raise MCPClient::Errors::ResourceReadError,
598
+ "Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
599
+ end
600
+ end
382
601
  end
383
602
  end
@@ -9,12 +9,24 @@ module MCPClient
9
9
  # Raised when a tool is not found
10
10
  class ToolNotFound < MCPError; end
11
11
 
12
+ # Raised when a prompt is not found
13
+ class PromptNotFound < MCPError; end
14
+
15
+ # Raised when a resource is not found
16
+ class ResourceNotFound < MCPError; end
17
+
12
18
  # Raised when a server is not found
13
19
  class ServerNotFound < MCPError; end
14
20
 
15
21
  # Raised when there's an error calling a tool
16
22
  class ToolCallError < MCPError; end
17
23
 
24
+ # Raised when there's an error getting a prompt
25
+ class PromptGetError < MCPError; end
26
+
27
+ # Raised when there's an error reading a resource
28
+ class ResourceReadError < MCPError; end
29
+
18
30
  # Raised when there's a connection error with an MCP server
19
31
  class ConnectionError < MCPError; end
20
32
 
@@ -29,5 +41,11 @@ module MCPClient
29
41
 
30
42
  # Raised when multiple tools with the same name exist across different servers
31
43
  class AmbiguousToolName < MCPError; end
44
+
45
+ # Raised when multiple prompts with the same name exist across different servers
46
+ class AmbiguousPromptName < MCPError; end
47
+
48
+ # Raised when multiple resources with the same URI exist across different servers
49
+ class AmbiguousResourceURI < MCPError; end
32
50
  end
33
51
  end