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 +4 -4
- data/README.md +257 -13
- data/lib/mcp_client/client.rb +225 -6
- data/lib/mcp_client/errors.rb +18 -0
- data/lib/mcp_client/prompt.rb +41 -0
- data/lib/mcp_client/resource.rb +61 -0
- data/lib/mcp_client/resource_content.rb +80 -0
- data/lib/mcp_client/resource_template.rb +57 -0
- data/lib/mcp_client/server_base.rb +55 -0
- data/lib/mcp_client/server_http.rb +142 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +1 -0
- data/lib/mcp_client/server_sse.rb +217 -1
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +6 -0
- data/lib/mcp_client/server_stdio.rb +182 -0
- data/lib/mcp_client/server_streamable_http.rb +201 -0
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7e0949102c5c22625bef68e2f5b4a0e2aecdfc636495ecba52d16f663c35f2fc
|
4
|
+
data.tar.gz: fbeb7484c1458775b88aa074422913e9d09d8a37b23aac38c7edf82e2111b9cb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
107
|
-
#
|
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": "
|
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/
|
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
|
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
|
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
|
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
|
-
- `
|
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
|
data/lib/mcp_client/client.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
data/lib/mcp_client/errors.rb
CHANGED
@@ -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
|