ruby-mcp-client 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +128 -28
- data/lib/mcp_client/client.rb +182 -5
- 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/server_base.rb +27 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +1 -0
- data/lib/mcp_client/server_sse.rb +157 -1
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -0
- data/lib/mcp_client/server_stdio.rb +91 -0
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +26 -12
- data/lib/mcp_client/server_streamable_http.rb +422 -9
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 27b97462ec0c99d98299df120726f90944f495aaddf198272d72725544240ec0
|
4
|
+
data.tar.gz: b58b8a6ba53f698abe1cb3576fba8030d66cb0bd7eafa2e39e28943e7ef1337f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 50d5791642a74f521133a057d924d3daf14e657ec617e73ad8f95510b4d97f3d52742c5a467c54d75dc02030a087f29e63ee346429271e8c80eb71cb96e38f94
|
7
|
+
data.tar.gz: 5f92d87a5d176e3271a11e9bbea7eafc6720107c6ae795601250e088cde69e6d6340911eecd6ea8ab32776d97d9bfb0b4d3740087509eb704cb400bf4e23e5aa
|
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** - Complete resources implementation for accessing files and data with URI-based identification
|
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,26 @@ 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
|
169
|
+
resources = client.list_resources
|
170
|
+
|
171
|
+
# Read a specific resource by URI
|
172
|
+
contents = client.read_resource('file:///example.txt')
|
173
|
+
|
174
|
+
# Read a resource from a specific server
|
175
|
+
contents = client.read_resource('file:///example.txt', server: 'filesystem')
|
176
|
+
|
143
177
|
# Format tools for specific AI services
|
144
178
|
openai_tools = client.to_openai_tools
|
145
179
|
anthropic_tools = client.to_anthropic_tools
|
@@ -288,12 +322,12 @@ require 'logger'
|
|
288
322
|
logger = Logger.new($stdout)
|
289
323
|
logger.level = Logger::INFO
|
290
324
|
|
291
|
-
# Create an MCP client that connects to a Playwright MCP server via
|
325
|
+
# Create an MCP client that connects to a Playwright MCP server via Streamable HTTP
|
292
326
|
# First run: npx @playwright/mcp@latest --port 8931
|
293
|
-
|
327
|
+
mcp_client = MCPClient.create_client(
|
294
328
|
mcp_server_configs: [
|
295
|
-
MCPClient.
|
296
|
-
base_url: 'http://localhost:8931/
|
329
|
+
MCPClient.streamable_http_config(
|
330
|
+
base_url: 'http://localhost:8931/mcp',
|
297
331
|
read_timeout: 30, # Timeout in seconds for request fulfillment
|
298
332
|
ping: 10, # Send ping after 10 seconds of inactivity
|
299
333
|
# Connection closes automatically after inactivity (2.5x ping interval)
|
@@ -304,44 +338,50 @@ sse_client = MCPClient.create_client(
|
|
304
338
|
)
|
305
339
|
|
306
340
|
# List available tools
|
307
|
-
tools =
|
341
|
+
tools = mcp_client.list_tools
|
308
342
|
|
309
343
|
# Launch a browser
|
310
|
-
result =
|
311
|
-
result =
|
344
|
+
result = mcp_client.call_tool('browser_install', {})
|
345
|
+
result = mcp_client.call_tool('browser_navigate', { url: 'about:blank' })
|
312
346
|
# No browser ID needed with these tool names
|
313
347
|
|
314
348
|
# Create a new page
|
315
|
-
page_result =
|
349
|
+
page_result = mcp_client.call_tool('browser_tab', {action: 'create'})
|
316
350
|
# No page ID needed with these tool names
|
317
351
|
|
318
352
|
# Navigate to a website
|
319
|
-
|
353
|
+
mcp_client.call_tool('browser_navigate', { url: 'https://example.com' })
|
320
354
|
|
321
355
|
# Get page title
|
322
|
-
title_result =
|
356
|
+
title_result = mcp_client.call_tool('browser_snapshot', {})
|
323
357
|
puts "Page snapshot: #{title_result}"
|
324
358
|
|
325
359
|
# Take a screenshot
|
326
|
-
screenshot_result =
|
360
|
+
screenshot_result = mcp_client.call_tool('browser_take_screenshot', {})
|
327
361
|
|
328
362
|
# Ping the server to verify connectivity
|
329
|
-
ping_result =
|
363
|
+
ping_result = mcp_client.ping
|
330
364
|
puts "Ping successful: #{ping_result.inspect}"
|
331
365
|
|
332
366
|
# Clean up
|
333
|
-
|
367
|
+
mcp_client.cleanup
|
334
368
|
```
|
335
369
|
|
336
|
-
See `examples/
|
370
|
+
See `examples/streamable_http_example.rb` for the full Playwright SSE example.
|
337
371
|
|
338
372
|
### FastMCP Example
|
339
373
|
|
340
|
-
The repository includes
|
374
|
+
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:
|
375
|
+
|
376
|
+
#### Basic FastMCP Example
|
377
|
+
|
378
|
+
**For FastMCP server with SSE transport (includes tools, prompts, and resources):**
|
379
|
+
```bash
|
380
|
+
# From the ruby-mcp-client directory
|
381
|
+
python examples/echo_server.py
|
382
|
+
```
|
341
383
|
|
342
384
|
```ruby
|
343
|
-
# Start the FastMCP server
|
344
|
-
# python examples/echo_server.py
|
345
385
|
|
346
386
|
# Run the Ruby client
|
347
387
|
# bundle exec ruby examples/echo_server_client.rb
|
@@ -352,17 +392,72 @@ require 'mcp_client'
|
|
352
392
|
client = MCPClient.create_client(
|
353
393
|
mcp_server_configs: [
|
354
394
|
MCPClient.sse_config(
|
355
|
-
base_url: 'http://127.0.0.1:8000/sse
|
395
|
+
base_url: 'http://127.0.0.1:8000/sse',
|
356
396
|
read_timeout: 30
|
357
397
|
)
|
358
398
|
]
|
359
399
|
)
|
360
400
|
|
361
|
-
# List
|
401
|
+
# List and use tools
|
362
402
|
tools = client.list_tools
|
363
403
|
puts "Found #{tools.length} tools:"
|
364
404
|
tools.each { |tool| puts "- #{tool.name}: #{tool.description}" }
|
365
405
|
|
406
|
+
result = client.call_tool('echo', { message: 'Hello FastMCP!' })
|
407
|
+
|
408
|
+
# List and use prompts
|
409
|
+
prompts = client.list_prompts
|
410
|
+
puts "Found #{prompts.length} prompts:"
|
411
|
+
prompts.each { |prompt| puts "- #{prompt.name}: #{prompt.description}" }
|
412
|
+
|
413
|
+
greeting = client.get_prompt('greeting', { name: 'Ruby Developer' })
|
414
|
+
|
415
|
+
# List and read resources
|
416
|
+
resources = client.list_resources
|
417
|
+
puts "Found #{resources.length} resources:"
|
418
|
+
resources.each { |resource| puts "- #{resource.name} (#{resource.uri})" }
|
419
|
+
|
420
|
+
readme_content = client.read_resource('file:///sample/README.md')
|
421
|
+
```
|
422
|
+
|
423
|
+
#### Streamable HTTP Example
|
424
|
+
|
425
|
+
**For FastMCP server with Streamable HTTP transport (includes tools, prompts, and resources):**
|
426
|
+
```bash
|
427
|
+
# From the ruby-mcp-client directory
|
428
|
+
python examples/echo_server_streamable.py
|
429
|
+
```
|
430
|
+
|
431
|
+
```ruby
|
432
|
+
|
433
|
+
# Run the streamable HTTP client
|
434
|
+
# bundle exec ruby examples/echo_server_streamable_client.rb
|
435
|
+
|
436
|
+
require 'mcp_client'
|
437
|
+
|
438
|
+
# Connect to streamable HTTP server with full MCP protocol support
|
439
|
+
client = MCPClient.create_client(
|
440
|
+
mcp_server_configs: [
|
441
|
+
MCPClient.streamable_http_config(
|
442
|
+
base_url: 'http://localhost:8931/mcp',
|
443
|
+
read_timeout: 60
|
444
|
+
)
|
445
|
+
]
|
446
|
+
)
|
447
|
+
|
448
|
+
# Full protocol support including real-time notifications
|
449
|
+
client.on_notification do |method, params|
|
450
|
+
puts "Server notification: #{method} - #{params}"
|
451
|
+
end
|
452
|
+
|
453
|
+
# Use all MCP features: tools, prompts, resources
|
454
|
+
tools = client.list_tools
|
455
|
+
prompts = client.list_prompts
|
456
|
+
resources = client.list_resources
|
457
|
+
|
458
|
+
# Real-time progress notifications for long-running tasks
|
459
|
+
result = client.call_tool('long_task', { duration: 5, steps: 5 })
|
460
|
+
|
366
461
|
# Use the tools
|
367
462
|
result = client.call_tool('echo', { message: 'Hello from Ruby!' })
|
368
463
|
result = client.call_tool('reverse', { text: 'FastMCP rocks!' })
|
@@ -371,8 +466,10 @@ client.cleanup
|
|
371
466
|
```
|
372
467
|
|
373
468
|
The FastMCP example includes:
|
374
|
-
- **`echo_server.py`** - A Python FastMCP server with
|
375
|
-
- **`echo_server_client.rb`** - Ruby client demonstrating all features
|
469
|
+
- **`echo_server.py`** - A Python FastMCP server with tools, prompts, and resources
|
470
|
+
- **`echo_server_client.rb`** - Ruby client demonstrating all features including prompts and resources
|
471
|
+
- **`echo_server_streamable.py`** - Enhanced streamable HTTP server with tools, prompts, and resources
|
472
|
+
- **`echo_server_streamable_client.rb`** - Ruby client demonstrating streamable HTTP transport
|
376
473
|
- **`README_ECHO_SERVER.md`** - Complete setup and usage instructions
|
377
474
|
|
378
475
|
This example showcases redirect support, proper line ending handling, and seamless integration between Ruby and Python MCP implementations.
|
@@ -464,8 +561,9 @@ Complete examples can be found in the `examples/` directory:
|
|
464
561
|
- `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
|
465
562
|
- `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
|
466
563
|
- `gemini_ai_mcp.rb` - Integration with Google Vertex AI and Gemini models
|
467
|
-
- `
|
564
|
+
- `streamable_http_example.rb` - Streamable HTTP transport with Playwright MCP
|
468
565
|
- `echo_server.py` & `echo_server_client.rb` - FastMCP server example with full setup
|
566
|
+
- `echo_server_streamable.py` & `echo_server_streamable_client.rb` - Enhanced streamable HTTP server example
|
469
567
|
|
470
568
|
## MCP Server Compatibility
|
471
569
|
|
@@ -485,7 +583,7 @@ You can define MCP server configurations in JSON files for easier management:
|
|
485
583
|
"mcpServers": {
|
486
584
|
"playwright": {
|
487
585
|
"type": "sse",
|
488
|
-
"url": "http://localhost:8931/
|
586
|
+
"url": "http://localhost:8931/mcp",
|
489
587
|
"headers": {
|
490
588
|
"Authorization": "Bearer TOKEN"
|
491
589
|
}
|
@@ -517,7 +615,7 @@ A simpler example used in the Playwright demo (found in `examples/sample_server_
|
|
517
615
|
{
|
518
616
|
"mcpServers": {
|
519
617
|
"playwright": {
|
520
|
-
"url": "http://localhost:8931/
|
618
|
+
"url": "http://localhost:8931/mcp",
|
521
619
|
"headers": {},
|
522
620
|
"comment": "Local Playwright MCP Server running on port 8931"
|
523
621
|
}
|
@@ -679,6 +777,8 @@ For complete OAuth documentation, see [OAUTH.md](OAUTH.md).
|
|
679
777
|
- **Server disambiguation** - Specify which server to use when tools with same name exist in multiple servers
|
680
778
|
- **Atomic tool calls** - Simple API for invoking tools with parameters
|
681
779
|
- **Batch support** - Call multiple tools in a single operation
|
780
|
+
- **Prompts support** - List and get prompts with parameters from MCP servers
|
781
|
+
- **Resources support** - List and read resources by URI from MCP servers
|
682
782
|
- **API conversions** - Built-in format conversion for OpenAI, Anthropic, and Google Vertex AI APIs
|
683
783
|
- **Thread safety** - Synchronized access for thread-safe operation
|
684
784
|
- **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,163 @@ 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
|
+
# @return [Array<MCPClient::Resource>] list of available resources
|
130
|
+
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
131
|
+
# @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?
|
134
|
+
|
135
|
+
resources = []
|
136
|
+
connection_errors = []
|
137
|
+
|
138
|
+
servers.each do |server|
|
139
|
+
server.list_resources.each do |resource|
|
140
|
+
cache_key = cache_key_for(server, resource.uri)
|
141
|
+
@resource_cache[cache_key] = resource
|
142
|
+
resources << resource
|
143
|
+
end
|
144
|
+
rescue MCPClient::Errors::ConnectionError => e
|
145
|
+
# Fast-fail on authorization errors for better user experience
|
146
|
+
# If this is the first server or we haven't collected any resources yet,
|
147
|
+
# raise the auth error directly to avoid cascading error messages
|
148
|
+
raise e if e.message.include?('Authorization failed') && resources.empty?
|
149
|
+
|
150
|
+
# Store the error and try other servers
|
151
|
+
connection_errors << e
|
152
|
+
@logger.error("Server error: #{e.message}")
|
153
|
+
end
|
154
|
+
|
155
|
+
resources
|
156
|
+
end
|
157
|
+
|
158
|
+
# Reads a specific resource by URI
|
159
|
+
# @param uri [String] the URI of the resource to read
|
160
|
+
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
161
|
+
# @return [Object] the resource contents
|
162
|
+
def read_resource(uri, server: nil)
|
163
|
+
resources = list_resources
|
164
|
+
|
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
|
190
|
+
|
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
|
203
|
+
end
|
204
|
+
|
42
205
|
# Lists all available tools from all connected MCP servers
|
43
206
|
# @param cache [Boolean] whether to use cached tools or fetch fresh
|
44
207
|
# @return [Array<MCPClient::Tool>] list of available tools
|
@@ -52,7 +215,8 @@ module MCPClient
|
|
52
215
|
|
53
216
|
servers.each do |server|
|
54
217
|
server.list_tools.each do |tool|
|
55
|
-
|
218
|
+
cache_key = cache_key_for(server, tool.name)
|
219
|
+
@tool_cache[cache_key] = tool
|
56
220
|
tools << tool
|
57
221
|
end
|
58
222
|
rescue MCPClient::Errors::ConnectionError => e
|
@@ -162,6 +326,8 @@ module MCPClient
|
|
162
326
|
# @return [void]
|
163
327
|
def clear_cache
|
164
328
|
@tool_cache.clear
|
329
|
+
@prompt_cache.clear
|
330
|
+
@resource_cache.clear
|
165
331
|
end
|
166
332
|
|
167
333
|
# Register a callback for JSON-RPC notifications from servers
|
@@ -316,9 +482,11 @@ module MCPClient
|
|
316
482
|
when 'notifications/resources/updated'
|
317
483
|
logger.warn("[#{server_id}] Resource #{params['uri']} updated")
|
318
484
|
when 'notifications/prompts/list_changed'
|
319
|
-
logger.warn("[#{server_id}] Prompt list has changed")
|
485
|
+
logger.warn("[#{server_id}] Prompt list has changed, clearing prompt cache")
|
486
|
+
@prompt_cache.clear
|
320
487
|
when 'notifications/resources/list_changed'
|
321
|
-
logger.warn("[#{server_id}] Resource list has changed")
|
488
|
+
logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
|
489
|
+
@resource_cache.clear
|
322
490
|
else
|
323
491
|
# Log unknown notification types for debugging purposes
|
324
492
|
logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
|
@@ -379,5 +547,14 @@ module MCPClient
|
|
379
547
|
server.list_tools.any? { |t| t.name == tool.name }
|
380
548
|
end
|
381
549
|
end
|
550
|
+
|
551
|
+
# Generate a cache key for server-specific items
|
552
|
+
# @param server [MCPClient::ServerBase] the server
|
553
|
+
# @param item_id [String] the item identifier (name or URI)
|
554
|
+
# @return [String] composite cache key
|
555
|
+
def cache_key_for(server, item_id)
|
556
|
+
server_id = server.object_id.to_s
|
557
|
+
"#{server_id}:#{item_id}"
|
558
|
+
end
|
382
559
|
end
|
383
560
|
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
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MCPClient
|
4
|
+
# Representation of an MCP prompt
|
5
|
+
class Prompt
|
6
|
+
# @!attribute [r] name
|
7
|
+
# @return [String] the name of the prompt
|
8
|
+
# @!attribute [r] description
|
9
|
+
# @return [String] the description of the prompt
|
10
|
+
# @!attribute [r] arguments
|
11
|
+
# @return [Hash] the JSON arguments for the prompt
|
12
|
+
# @!attribute [r] server
|
13
|
+
# @return [MCPClient::ServerBase, nil] the server this prompt belongs to
|
14
|
+
attr_reader :name, :description, :arguments, :server
|
15
|
+
|
16
|
+
# Initialize a new prompt
|
17
|
+
# @param name [String] the name of the prompt
|
18
|
+
# @param description [String] the description of the prompt
|
19
|
+
# @param arguments [Hash] the JSON arguments for the prompt
|
20
|
+
# @param server [MCPClient::ServerBase, nil] the server this prompt belongs to
|
21
|
+
def initialize(name:, description:, arguments: {}, server: nil)
|
22
|
+
@name = name
|
23
|
+
@description = description
|
24
|
+
@arguments = arguments
|
25
|
+
@server = server
|
26
|
+
end
|
27
|
+
|
28
|
+
# Create a Prompt instance from JSON data
|
29
|
+
# @param data [Hash] JSON data from MCP server
|
30
|
+
# @param server [MCPClient::ServerBase, nil] the server this prompt belongs to
|
31
|
+
# @return [MCPClient::Prompt] prompt instance
|
32
|
+
def self.from_json(data, server: nil)
|
33
|
+
new(
|
34
|
+
name: data['name'],
|
35
|
+
description: data['description'],
|
36
|
+
arguments: data['arguments'] || {},
|
37
|
+
server: server
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MCPClient
|
4
|
+
# Representation of an MCP resource
|
5
|
+
class Resource
|
6
|
+
# @!attribute [r] uri
|
7
|
+
# @return [String] unique identifier for the resource
|
8
|
+
# @!attribute [r] name
|
9
|
+
# @return [String] the name of the resource
|
10
|
+
# @!attribute [r] title
|
11
|
+
# @return [String, nil] optional human-readable name of the resource for display purposes
|
12
|
+
# @!attribute [r] description
|
13
|
+
# @return [String, nil] optional description
|
14
|
+
# @!attribute [r] mime_type
|
15
|
+
# @return [String, nil] optional MIME type
|
16
|
+
# @!attribute [r] size
|
17
|
+
# @return [Integer, nil] optional size in bytes
|
18
|
+
# @!attribute [r] annotations
|
19
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
20
|
+
# @!attribute [r] server
|
21
|
+
# @return [MCPClient::ServerBase, nil] the server this resource belongs to
|
22
|
+
attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :server
|
23
|
+
|
24
|
+
# Initialize a new resource
|
25
|
+
# @param uri [String] unique identifier for the resource
|
26
|
+
# @param name [String] the name of the resource
|
27
|
+
# @param title [String, nil] optional human-readable name of the resource for display purposes
|
28
|
+
# @param description [String, nil] optional description
|
29
|
+
# @param mime_type [String, nil] optional MIME type
|
30
|
+
# @param size [Integer, nil] optional size in bytes
|
31
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
32
|
+
# @param server [MCPClient::ServerBase, nil] the server this resource belongs to
|
33
|
+
def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, server: nil)
|
34
|
+
@uri = uri
|
35
|
+
@name = name
|
36
|
+
@title = title
|
37
|
+
@description = description
|
38
|
+
@mime_type = mime_type
|
39
|
+
@size = size
|
40
|
+
@annotations = annotations
|
41
|
+
@server = server
|
42
|
+
end
|
43
|
+
|
44
|
+
# Create a Resource instance from JSON data
|
45
|
+
# @param data [Hash] JSON data from MCP server
|
46
|
+
# @param server [MCPClient::ServerBase, nil] the server this resource belongs to
|
47
|
+
# @return [MCPClient::Resource] resource instance
|
48
|
+
def self.from_json(data, server: nil)
|
49
|
+
new(
|
50
|
+
uri: data['uri'],
|
51
|
+
name: data['name'],
|
52
|
+
title: data['title'],
|
53
|
+
description: data['description'],
|
54
|
+
mime_type: data['mimeType'],
|
55
|
+
size: data['size'],
|
56
|
+
annotations: data['annotations'],
|
57
|
+
server: server
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|