ruby-mcp-client 0.1.0 → 0.3.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 +26 -3
- data/lib/mcp_client/client.rb +105 -7
- data/lib/mcp_client/errors.rb +2 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_sse.rb +44 -1
- data/lib/mcp_client/server_stdio.rb +20 -1
- data/lib/mcp_client/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 233c83227145cb76167e75784b7eaecef56828743f65f6256c84062bb2290b1e
|
4
|
+
data.tar.gz: 4fb49b0cc82f5bd2b07d15ec200da4f9dced0fce52ddfacd4860a5a689152328
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bdd2b7223f25e4e8516f9bad648854a8e935b823d269762d769a545571960041041082f861a85e559a637e00bb9357541548bd87155bbe9df490c501fb755ade
|
7
|
+
data.tar.gz: 3aef9c1fd10b846e46cc183c072443946a5aa39bfae43245949204e15e89a132df676af927cdabd4cae9c65e4052d220d4012bf0dadaffeb988cddf2a5ab7cfc
|
data/README.md
CHANGED
@@ -29,7 +29,7 @@ MCP enables AI assistants and other services to discover and invoke external too
|
|
29
29
|
via different transport mechanisms:
|
30
30
|
|
31
31
|
- **Standard I/O**: Local processes implementing the MCP protocol
|
32
|
-
- **Server-Sent Events (SSE)**: Remote MCP servers over HTTP
|
32
|
+
- **Server-Sent Events (SSE)**: Remote MCP servers over HTTP with streaming support
|
33
33
|
|
34
34
|
The core client resides in `MCPClient::Client` and provides helper methods for integrating
|
35
35
|
with popular AI services with built-in conversions:
|
@@ -48,11 +48,13 @@ client = MCPClient.create_client(
|
|
48
48
|
mcp_server_configs: [
|
49
49
|
# Local stdio server
|
50
50
|
MCPClient.stdio_config(command: 'npx -y @modelcontextprotocol/server-filesystem /home/user'),
|
51
|
-
# Remote HTTP SSE server
|
51
|
+
# Remote HTTP SSE server (with streaming support)
|
52
52
|
MCPClient.sse_config(
|
53
53
|
base_url: 'https://api.example.com/sse',
|
54
54
|
headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
|
55
|
-
read_timeout: 30 # Optional timeout in seconds (default: 30)
|
55
|
+
read_timeout: 30, # Optional timeout in seconds (default: 30)
|
56
|
+
retries: 3, # Optional number of retry attempts (default: 0)
|
57
|
+
retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
|
56
58
|
)
|
57
59
|
]
|
58
60
|
)
|
@@ -60,13 +62,31 @@ client = MCPClient.create_client(
|
|
60
62
|
# List available tools
|
61
63
|
tools = client.list_tools
|
62
64
|
|
65
|
+
# Find tools by name pattern (string or regex)
|
66
|
+
file_tools = client.find_tools('file')
|
67
|
+
first_tool = client.find_tool(/^file_/)
|
68
|
+
|
63
69
|
# Call a specific tool by name
|
64
70
|
result = client.call_tool('example_tool', { param1: 'value1', param2: 42 })
|
65
71
|
|
72
|
+
# Call multiple tools in batch
|
73
|
+
results = client.call_tools([
|
74
|
+
{ name: 'tool1', parameters: { key1: 'value1' } },
|
75
|
+
{ name: 'tool2', parameters: { key2: 'value2' } }
|
76
|
+
])
|
77
|
+
|
78
|
+
# Stream results (for supported transports like SSE)
|
79
|
+
client.call_tool_streaming('streaming_tool', { param: 'value' }).each do |chunk|
|
80
|
+
# Process each chunk as it arrives
|
81
|
+
puts chunk
|
82
|
+
end
|
83
|
+
|
66
84
|
# Format tools for specific AI services
|
67
85
|
openai_tools = client.to_openai_tools
|
68
86
|
anthropic_tools = client.to_anthropic_tools
|
69
87
|
|
88
|
+
# Clear cached tools to force fresh fetch on next list
|
89
|
+
client.clear_cache
|
70
90
|
# Clean up connections
|
71
91
|
client.cleanup
|
72
92
|
```
|
@@ -166,6 +186,8 @@ This client works with any MCP-compatible server, including:
|
|
166
186
|
- [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
|
167
187
|
- Custom servers implementing the MCP protocol
|
168
188
|
|
189
|
+
### Server Implementation Features
|
190
|
+
|
169
191
|
### Server-Sent Events (SSE) Implementation
|
170
192
|
|
171
193
|
The SSE client implementation provides these key features:
|
@@ -174,6 +196,7 @@ The SSE client implementation provides these key features:
|
|
174
196
|
- **Thread safety**: All operations are thread-safe using monitors and synchronized access
|
175
197
|
- **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
|
176
198
|
- **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport
|
199
|
+
- **Streaming support**: Native streaming for real-time updates
|
177
200
|
|
178
201
|
## Requirements
|
179
202
|
|
data/lib/mcp_client/client.rb
CHANGED
@@ -1,13 +1,28 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'logger'
|
4
|
+
|
3
5
|
module MCPClient
|
4
6
|
# MCP Client for integrating with the Model Context Protocol
|
5
7
|
# This is the main entry point for using MCP tools
|
6
8
|
class Client
|
7
|
-
|
9
|
+
# @!attribute [r] servers
|
10
|
+
# @return [Array<MCPClient::ServerBase>] list of servers
|
11
|
+
# @!attribute [r] tool_cache
|
12
|
+
# @return [Hash<String, MCPClient::Tool>] cache of tools by name
|
13
|
+
# @!attribute [r] logger
|
14
|
+
# @return [Logger] logger for client operations
|
15
|
+
attr_reader :servers, :tool_cache, :logger
|
8
16
|
|
9
|
-
|
10
|
-
|
17
|
+
# Initialize a new MCPClient::Client
|
18
|
+
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
19
|
+
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
20
|
+
def initialize(mcp_server_configs: [], logger: nil)
|
21
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
22
|
+
@servers = mcp_server_configs.map do |config|
|
23
|
+
@logger.debug("Creating server with config: #{config.inspect}")
|
24
|
+
MCPClient::ServerFactory.create(config)
|
25
|
+
end
|
11
26
|
@tool_cache = {}
|
12
27
|
end
|
13
28
|
|
@@ -38,6 +53,9 @@ module MCPClient
|
|
38
53
|
|
39
54
|
raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found" unless tool
|
40
55
|
|
56
|
+
# Validate parameters against tool schema
|
57
|
+
validate_params!(tool, parameters)
|
58
|
+
|
41
59
|
# Find the server that owns this tool
|
42
60
|
server = find_server_for_tool(tool)
|
43
61
|
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
|
@@ -46,15 +64,21 @@ module MCPClient
|
|
46
64
|
end
|
47
65
|
|
48
66
|
# Convert MCP tools to OpenAI function specifications
|
67
|
+
# @param tool_names [Array<String>, nil] optional list of tool names to include, nil means all tools
|
49
68
|
# @return [Array<Hash>] OpenAI function specifications
|
50
|
-
def to_openai_tools
|
51
|
-
list_tools
|
69
|
+
def to_openai_tools(tool_names: nil)
|
70
|
+
tools = list_tools
|
71
|
+
tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
|
72
|
+
tools.map(&:to_openai_tool)
|
52
73
|
end
|
53
74
|
|
54
75
|
# Convert MCP tools to Anthropic Claude tool specifications
|
76
|
+
# @param tool_names [Array<String>, nil] optional list of tool names to include, nil means all tools
|
55
77
|
# @return [Array<Hash>] Anthropic Claude tool specifications
|
56
|
-
def to_anthropic_tools
|
57
|
-
list_tools
|
78
|
+
def to_anthropic_tools(tool_names: nil)
|
79
|
+
tools = list_tools
|
80
|
+
tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
|
81
|
+
tools.map(&:to_anthropic_tool)
|
58
82
|
end
|
59
83
|
|
60
84
|
# Clean up all server connections
|
@@ -62,8 +86,82 @@ module MCPClient
|
|
62
86
|
servers.each(&:cleanup)
|
63
87
|
end
|
64
88
|
|
89
|
+
# Clear the cached tools so that next list_tools will fetch fresh data
|
90
|
+
# @return [void]
|
91
|
+
def clear_cache
|
92
|
+
@tool_cache.clear
|
93
|
+
end
|
94
|
+
|
95
|
+
# Find all tools whose name matches the given pattern (String or Regexp)
|
96
|
+
# @param pattern [String, Regexp] pattern to match tool names
|
97
|
+
# @return [Array<MCPClient::Tool>] matching tools
|
98
|
+
def find_tools(pattern)
|
99
|
+
rx = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern)}/
|
100
|
+
list_tools.select { |t| t.name.match(rx) }
|
101
|
+
end
|
102
|
+
|
103
|
+
# Find the first tool whose name matches the given pattern
|
104
|
+
# @param pattern [String, Regexp] pattern to match tool names
|
105
|
+
# @return [MCPClient::Tool, nil]
|
106
|
+
def find_tool(pattern)
|
107
|
+
find_tools(pattern).first
|
108
|
+
end
|
109
|
+
|
110
|
+
# Call multiple tools in batch
|
111
|
+
# @param calls [Array<Hash>] array of calls in the form { name: tool_name, parameters: {...} }
|
112
|
+
# @return [Array<Object>] array of results for each tool invocation
|
113
|
+
def call_tools(calls)
|
114
|
+
calls.map do |call|
|
115
|
+
name = call[:name] || call['name']
|
116
|
+
params = call[:parameters] || call['parameters'] || {}
|
117
|
+
call_tool(name, params)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Stream call of a specific tool by name with the given parameters.
|
122
|
+
# Returns an Enumerator yielding streaming updates if supported.
|
123
|
+
# @param tool_name [String] the name of the tool to call
|
124
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
125
|
+
# @return [Enumerator] streaming enumerator or single-value enumerator
|
126
|
+
def call_tool_streaming(tool_name, parameters)
|
127
|
+
tools = list_tools
|
128
|
+
tool = tools.find { |t| t.name == tool_name }
|
129
|
+
raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found" unless tool
|
130
|
+
|
131
|
+
# Validate parameters against tool schema
|
132
|
+
validate_params!(tool, parameters)
|
133
|
+
# Find the server that owns this tool
|
134
|
+
server = find_server_for_tool(tool)
|
135
|
+
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
|
136
|
+
|
137
|
+
if server.respond_to?(:call_tool_streaming)
|
138
|
+
server.call_tool_streaming(tool_name, parameters)
|
139
|
+
else
|
140
|
+
Enumerator.new do |yielder|
|
141
|
+
yielder << server.call_tool(tool_name, parameters)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
65
146
|
private
|
66
147
|
|
148
|
+
# Validate parameters against tool JSON schema (checks required properties)
|
149
|
+
# @param tool [MCPClient::Tool] tool definition with schema
|
150
|
+
# @param parameters [Hash] parameters to validate
|
151
|
+
# @raise [MCPClient::Errors::ValidationError] when required params are missing
|
152
|
+
def validate_params!(tool, parameters)
|
153
|
+
schema = tool.schema
|
154
|
+
return unless schema.is_a?(Hash)
|
155
|
+
|
156
|
+
required = schema['required'] || schema[:required]
|
157
|
+
return unless required.is_a?(Array)
|
158
|
+
|
159
|
+
missing = required.map(&:to_s) - parameters.keys.map(&:to_s)
|
160
|
+
return unless missing.any?
|
161
|
+
|
162
|
+
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
163
|
+
end
|
164
|
+
|
67
165
|
def find_server_for_tool(tool)
|
68
166
|
servers.find do |server|
|
69
167
|
server.list_tools.any? { |t| t.name == tool.name }
|
data/lib/mcp_client/errors.rb
CHANGED
@@ -14,7 +14,10 @@ module MCPClient
|
|
14
14
|
MCPClient::ServerSSE.new(
|
15
15
|
base_url: config[:base_url],
|
16
16
|
headers: config[:headers] || {},
|
17
|
-
read_timeout: config[:read_timeout] || 30
|
17
|
+
read_timeout: config[:read_timeout] || 30,
|
18
|
+
retries: config[:retries] || 0,
|
19
|
+
retry_backoff: config[:retry_backoff] || 1,
|
20
|
+
logger: config[:logger]
|
18
21
|
)
|
19
22
|
else
|
20
23
|
raise ArgumentError, "Unknown server type: #{config[:type]}"
|
@@ -5,6 +5,7 @@ require 'net/http'
|
|
5
5
|
require 'json'
|
6
6
|
require 'openssl'
|
7
7
|
require 'monitor'
|
8
|
+
require 'logger'
|
8
9
|
|
9
10
|
module MCPClient
|
10
11
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
@@ -15,8 +16,14 @@ module MCPClient
|
|
15
16
|
# @param base_url [String] The base URL of the MCP server
|
16
17
|
# @param headers [Hash] Additional headers to include in requests
|
17
18
|
# @param read_timeout [Integer] Read timeout in seconds (default: 30)
|
18
|
-
|
19
|
+
# @param retries [Integer] number of retry attempts on transient errors
|
20
|
+
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
21
|
+
# @param logger [Logger, nil] optional logger
|
22
|
+
def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
|
19
23
|
super()
|
24
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
25
|
+
@max_retries = retries
|
26
|
+
@retry_backoff = retry_backoff
|
20
27
|
@base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
|
21
28
|
@headers = headers.merge({
|
22
29
|
'Accept' => 'text/event-stream',
|
@@ -37,6 +44,16 @@ module MCPClient
|
|
37
44
|
@connection_cv = @mutex.new_cond
|
38
45
|
end
|
39
46
|
|
47
|
+
# Stream tool call fallback for SSE transport (yields single result)
|
48
|
+
# @param tool_name [String]
|
49
|
+
# @param parameters [Hash]
|
50
|
+
# @return [Enumerator]
|
51
|
+
def call_tool_streaming(tool_name, parameters)
|
52
|
+
Enumerator.new do |yielder|
|
53
|
+
yielder << call_tool(tool_name, parameters)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
40
57
|
# List all tools available from the MCP server
|
41
58
|
# @return [Array<MCPClient::Tool>] list of available tools
|
42
59
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
@@ -201,6 +218,7 @@ module MCPClient
|
|
201
218
|
end
|
202
219
|
|
203
220
|
response.read_body do |chunk|
|
221
|
+
@logger.debug("SSE chunk received: #{chunk.inspect}")
|
204
222
|
process_sse_chunk(chunk.dup)
|
205
223
|
end
|
206
224
|
end
|
@@ -219,6 +237,7 @@ module MCPClient
|
|
219
237
|
# Process an SSE chunk from the server
|
220
238
|
# @param chunk [String] the chunk to process
|
221
239
|
def process_sse_chunk(chunk)
|
240
|
+
@logger.debug("Processing SSE chunk: #{chunk.inspect}")
|
222
241
|
local_buffer = nil
|
223
242
|
|
224
243
|
@mutex.synchronize do
|
@@ -278,6 +297,7 @@ module MCPClient
|
|
278
297
|
# @param event_data [String] the event data to parse
|
279
298
|
# @return [Hash, nil] the parsed event, or nil if the event is invalid
|
280
299
|
def parse_sse_event(event_data)
|
300
|
+
@logger.debug("Parsing SSE event data: #{event_data.inspect}")
|
281
301
|
event = { event: 'message', data: '', id: nil }
|
282
302
|
data_lines = []
|
283
303
|
|
@@ -295,6 +315,7 @@ module MCPClient
|
|
295
315
|
end
|
296
316
|
|
297
317
|
event[:data] = data_lines.join("\n")
|
318
|
+
@logger.debug("Parsed SSE event: #{event.inspect}")
|
298
319
|
event[:data].empty? ? nil : event
|
299
320
|
end
|
300
321
|
|
@@ -331,10 +352,31 @@ module MCPClient
|
|
331
352
|
raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
|
332
353
|
end
|
333
354
|
|
355
|
+
# Helper: execute block with retry/backoff for transient errors
|
356
|
+
# @yield block to execute
|
357
|
+
# @return result of block
|
358
|
+
def with_retry
|
359
|
+
attempts = 0
|
360
|
+
begin
|
361
|
+
yield
|
362
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ServerError, IOError, Errno::ETIMEDOUT,
|
363
|
+
Errno::ECONNRESET => e
|
364
|
+
attempts += 1
|
365
|
+
if attempts <= @max_retries
|
366
|
+
delay = @retry_backoff * (2**(attempts - 1))
|
367
|
+
@logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
|
368
|
+
sleep(delay)
|
369
|
+
retry
|
370
|
+
end
|
371
|
+
raise
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
334
375
|
# Send a JSON-RPC request to the server and wait for result
|
335
376
|
# @param request [Hash] the JSON-RPC request
|
336
377
|
# @return [Hash] the result of the request
|
337
378
|
def send_jsonrpc_request(request)
|
379
|
+
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
338
380
|
uri = URI.parse(@base_url)
|
339
381
|
rpc_http = Net::HTTP.new(uri.host, uri.port)
|
340
382
|
|
@@ -367,6 +409,7 @@ module MCPClient
|
|
367
409
|
.each { |k, v| http_request[k] = v }
|
368
410
|
|
369
411
|
response = http.request(http_request)
|
412
|
+
@logger.debug("Received JSON-RPC response: #{response.code} #{response.body}")
|
370
413
|
|
371
414
|
unless response.is_a?(Net::HTTPSuccess)
|
372
415
|
raise MCPClient::Errors::ServerError, "Server returned error: #{response.code} #{response.message}"
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require 'open3'
|
4
4
|
require 'json'
|
5
5
|
require_relative 'version'
|
6
|
+
require 'logger'
|
6
7
|
|
7
8
|
module MCPClient
|
8
9
|
# JSON-RPC implementation of MCP server over stdio.
|
@@ -13,7 +14,10 @@ module MCPClient
|
|
13
14
|
READ_TIMEOUT = 15
|
14
15
|
|
15
16
|
# @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
|
16
|
-
|
17
|
+
# @param retries [Integer] number of retry attempts on transient errors
|
18
|
+
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
19
|
+
# @param logger [Logger, nil] optional logger
|
20
|
+
def initialize(command:, retries: 0, retry_backoff: 1, logger: nil)
|
17
21
|
super()
|
18
22
|
@command = command.is_a?(Array) ? command.join(' ') : command
|
19
23
|
@mutex = Mutex.new
|
@@ -21,6 +25,9 @@ module MCPClient
|
|
21
25
|
@next_id = 1
|
22
26
|
@pending = {}
|
23
27
|
@initialized = false
|
28
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
29
|
+
@max_retries = retries
|
30
|
+
@retry_backoff = retry_backoff
|
24
31
|
end
|
25
32
|
|
26
33
|
# Connect to the MCP server by launching the command process via stdout/stdin
|
@@ -49,6 +56,7 @@ module MCPClient
|
|
49
56
|
# @param line [String] line of output to parse
|
50
57
|
def handle_line(line)
|
51
58
|
msg = JSON.parse(line)
|
59
|
+
@logger.debug("Received line: #{line.chomp}")
|
52
60
|
id = msg['id']
|
53
61
|
return unless id
|
54
62
|
|
@@ -173,6 +181,7 @@ module MCPClient
|
|
173
181
|
end
|
174
182
|
|
175
183
|
def send_request(req)
|
184
|
+
@logger.debug("Sending JSONRPC request: #{req.to_json}")
|
176
185
|
@stdin.puts(req.to_json)
|
177
186
|
rescue StandardError => e
|
178
187
|
raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
|
@@ -194,5 +203,15 @@ module MCPClient
|
|
194
203
|
msg
|
195
204
|
end
|
196
205
|
end
|
206
|
+
|
207
|
+
# Stream tool call fallback for stdio transport (yields single result)
|
208
|
+
# @param tool_name [String]
|
209
|
+
# @param parameters [Hash]
|
210
|
+
# @return [Enumerator]
|
211
|
+
def call_tool_streaming(tool_name, parameters)
|
212
|
+
Enumerator.new do |yielder|
|
213
|
+
yielder << call_tool(tool_name, parameters)
|
214
|
+
end
|
215
|
+
end
|
197
216
|
end
|
198
217
|
end
|
data/lib/mcp_client/version.rb
CHANGED