ruby-mcp-client 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 233c83227145cb76167e75784b7eaecef56828743f65f6256c84062bb2290b1e
4
- data.tar.gz: 4fb49b0cc82f5bd2b07d15ec200da4f9dced0fce52ddfacd4860a5a689152328
3
+ metadata.gz: fe40b5481b8c0b59201585bfe54fb912c71235d4f02ada0c8a32a908155c319d
4
+ data.tar.gz: a378e9f8bd616e92739d371dfb458fc9385a6f2bbc62287cdf1e705742cfa6e0
5
5
  SHA512:
6
- metadata.gz: bdd2b7223f25e4e8516f9bad648854a8e935b823d269762d769a545571960041041082f861a85e559a637e00bb9357541548bd87155bbe9df490c501fb755ade
7
- data.tar.gz: 3aef9c1fd10b846e46cc183c072443946a5aa39bfae43245949204e15e89a132df676af927cdabd4cae9c65e4052d220d4012bf0dadaffeb988cddf2a5ab7cfc
6
+ metadata.gz: 19cb60e6ebf6693a3ee312e592b94e891b568bb67fc675b732d3c32c6452260486fe3f7197a4626e933096cbb52ae4afae8aaed763906fba3041adcadb7ee8ce
7
+ data.tar.gz: c4c9d8a8b7aaf40d277bd20cb7aa50caf1ade083d6569e9825bb6dc53378d1359ef40e7dab84397416df950887b705abca7738179f639bd965ecb23e3d8186ea
data/README.md CHANGED
@@ -55,6 +55,7 @@ client = MCPClient.create_client(
55
55
  read_timeout: 30, # Optional timeout in seconds (default: 30)
56
56
  retries: 3, # Optional number of retry attempts (default: 0)
57
57
  retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
58
+ # Native support for tool streaming via call_tool_streaming method
58
59
  )
59
60
  ]
60
61
  )
@@ -75,7 +76,8 @@ results = client.call_tools([
75
76
  { name: 'tool2', parameters: { key2: 'value2' } }
76
77
  ])
77
78
 
78
- # Stream results (for supported transports like SSE)
79
+ # Stream results (supported by the SSE transport)
80
+ # Returns an Enumerator that yields results as they become available
79
81
  client.call_tool_streaming('streaming_tool', { param: 'value' }).each do |chunk|
80
82
  # Process each chunk as it arrives
81
83
  puts chunk
@@ -85,6 +87,18 @@ end
85
87
  openai_tools = client.to_openai_tools
86
88
  anthropic_tools = client.to_anthropic_tools
87
89
 
90
+ # Register for server notifications
91
+ client.on_notification do |server, method, params|
92
+ puts "Server notification: #{server.class} - #{method} - #{params}"
93
+ # Handle specific notifications based on method name
94
+ # 'notifications/tools/list_changed' is handled automatically by the client
95
+ end
96
+
97
+ # Send custom JSON-RPC requests or notifications
98
+ client.send_rpc('custom_method', params: { key: 'value' }, server: :sse) # Uses specific server
99
+ result = client.send_rpc('another_method', params: { data: 123 }) # Uses first available server
100
+ client.send_notification('status_update', params: { status: 'ready' })
101
+
88
102
  # Clear cached tools to force fresh fetch on next list
89
103
  client.clear_cache
90
104
  # Clean up connections
@@ -192,11 +206,15 @@ This client works with any MCP-compatible server, including:
192
206
 
193
207
  The SSE client implementation provides these key features:
194
208
 
195
- - **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts
209
+ - **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts and retries
196
210
  - **Thread safety**: All operations are thread-safe using monitors and synchronized access
197
211
  - **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
198
- - **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport
199
- - **Streaming support**: Native streaming for real-time updates
212
+ - **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport with initialize handshake
213
+ - **Streaming support**: Native streaming for real-time updates via the `call_tool_streaming` method, which returns an Enumerator for processing results as they arrive
214
+ - **Notification support**: Built-in handling for JSON-RPC notifications with automatic tool cache invalidation and custom notification callback support
215
+ - **Custom RPC methods**: Send any custom JSON-RPC method or notification through `send_rpc` and `send_notification`
216
+ - **Configurable retries**: All RPC requests support configurable retries with exponential backoff
217
+ - **Consistent logging**: Tagged, leveled logging across all components for better debugging
200
218
 
201
219
  ## Requirements
202
220
 
@@ -211,6 +229,16 @@ To implement a compatible MCP server you must:
211
229
  - Respond to `list_tools` requests with a JSON list of tools
212
230
  - Respond to `call_tool` requests by executing the specified tool
213
231
  - Return results (or errors) in JSON format
232
+ - Optionally send JSON-RPC notifications for events like tool updates
233
+
234
+ ### JSON-RPC Notifications
235
+
236
+ The client supports JSON-RPC notifications from the server:
237
+
238
+ - Default notification handler for `notifications/tools/list_changed` to automatically clear the tool cache
239
+ - Custom notification handling via the `on_notification` method
240
+ - Callbacks receive the server instance, method name, and parameters
241
+ - Multiple notification listeners can be registered
214
242
 
215
243
  ## Tool Schema
216
244
 
@@ -19,11 +19,24 @@ module MCPClient
19
19
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
20
20
  def initialize(mcp_server_configs: [], logger: nil)
21
21
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
22
+ @logger.progname = self.class.name
23
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
22
24
  @servers = mcp_server_configs.map do |config|
23
25
  @logger.debug("Creating server with config: #{config.inspect}")
24
26
  MCPClient::ServerFactory.create(config)
25
27
  end
26
28
  @tool_cache = {}
29
+ # JSON-RPC notification listeners
30
+ @notification_listeners = []
31
+ # Register default and user-defined notification handlers on each server
32
+ @servers.each do |server|
33
+ server.on_notification do |method, params|
34
+ # Default handling: clear tool cache on tools list change
35
+ clear_cache if method == 'notifications/tools/list_changed'
36
+ # Invoke user listeners
37
+ @notification_listeners.each { |cb| cb.call(server, method, params) }
38
+ end
39
+ end
27
40
  end
28
41
 
29
42
  # Lists all available tools from all connected MCP servers
@@ -92,6 +105,13 @@ module MCPClient
92
105
  @tool_cache.clear
93
106
  end
94
107
 
108
+ # Register a callback for JSON-RPC notifications from servers
109
+ # @yield [server, method, params]
110
+ # @return [void]
111
+ def on_notification(&block)
112
+ @notification_listeners << block
113
+ end
114
+
95
115
  # Find all tools whose name matches the given pattern (String or Regexp)
96
116
  # @param pattern [String, Regexp] pattern to match tool names
97
117
  # @return [Array<MCPClient::Tool>] matching tools
@@ -27,5 +27,29 @@ module MCPClient
27
27
  def cleanup
28
28
  raise NotImplementedError, 'Subclasses must implement cleanup'
29
29
  end
30
+
31
+ # Send a JSON-RPC request and return the result
32
+ # @param method [String] JSON-RPC method name
33
+ # @param params [Hash] parameters for the request
34
+ # @return [Object] result field from the JSON-RPC response
35
+ # @raise [MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ToolCallError]
36
+ def rpc_request(method, params = {})
37
+ raise NotImplementedError, 'Subclasses must implement rpc_request'
38
+ end
39
+
40
+ # Send a JSON-RPC notification (no response expected)
41
+ # @param method [String] JSON-RPC method name
42
+ # @param params [Hash] parameters for the notification
43
+ # @return [void]
44
+ def rpc_notify(method, params = {})
45
+ raise NotImplementedError, 'Subclasses must implement rpc_notify'
46
+ end
47
+
48
+ # Register a callback to receive JSON-RPC notifications
49
+ # @yield [method, params] invoked when a notification is received
50
+ # @return [void]
51
+ def on_notification(&block)
52
+ @notification_callback = block
53
+ end
30
54
  end
31
55
  end
@@ -9,7 +9,13 @@ module MCPClient
9
9
  def self.create(config)
10
10
  case config[:type]
11
11
  when 'stdio'
12
- MCPClient::ServerStdio.new(command: config[:command])
12
+ MCPClient::ServerStdio.new(
13
+ command: config[:command],
14
+ retries: config[:retries] || 0,
15
+ retry_backoff: config[:retry_backoff] || 1,
16
+ read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
17
+ logger: config[:logger]
18
+ )
13
19
  when 'sse'
14
20
  MCPClient::ServerSSE.new(
15
21
  base_url: config[:base_url],
@@ -11,7 +11,7 @@ module MCPClient
11
11
  # Implementation of MCP server that communicates via Server-Sent Events (SSE)
12
12
  # Useful for communicating with remote MCP servers over HTTP
13
13
  class ServerSSE < ServerBase
14
- attr_reader :base_url, :tools, :session_id, :http_client
14
+ attr_reader :base_url, :tools, :session_id, :http_client, :server_info, :capabilities
15
15
 
16
16
  # @param base_url [String] The base URL of the MCP server
17
17
  # @param headers [Hash] Additional headers to include in requests
@@ -22,6 +22,8 @@ module MCPClient
22
22
  def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
23
23
  super()
24
24
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
25
+ @logger.progname = self.class.name
26
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
25
27
  @max_retries = retries
26
28
  @retry_backoff = retry_backoff
27
29
  @base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
@@ -42,6 +44,7 @@ module MCPClient
42
44
  @sse_connected = false
43
45
  @connection_established = false
44
46
  @connection_cv = @mutex.new_cond
47
+ @initialized = false
45
48
  end
46
49
 
47
50
  # Stream tool call fallback for SSE transport (yields single result)
@@ -64,7 +67,7 @@ module MCPClient
64
67
  return @tools if @tools
65
68
  end
66
69
 
67
- connect
70
+ ensure_initialized
68
71
 
69
72
  begin
70
73
  tools_data = request_tools_list
@@ -93,7 +96,7 @@ module MCPClient
93
96
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
94
97
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
95
98
  def call_tool(tool_name, parameters)
96
- connect
99
+ ensure_initialized
97
100
 
98
101
  begin
99
102
  request_id = @mutex.synchronize { @request_id += 1 }
@@ -179,8 +182,84 @@ module MCPClient
179
182
  end
180
183
  end
181
184
 
185
+ # Generic JSON-RPC request: send method with params and return result
186
+ # @param method [String] JSON-RPC method name
187
+ # @param params [Hash] parameters for the request
188
+ # @return [Object] result from JSON-RPC response
189
+ def rpc_request(method, params = {})
190
+ ensure_initialized
191
+ with_retry do
192
+ request_id = @mutex.synchronize { @request_id += 1 }
193
+ request = { jsonrpc: '2.0', id: request_id, method: method, params: params }
194
+ send_jsonrpc_request(request)
195
+ end
196
+ end
197
+
198
+ # Send a JSON-RPC notification (no response expected)
199
+ # @param method [String] JSON-RPC method name
200
+ # @param params [Hash] parameters for the notification
201
+ # @return [void]
202
+ def rpc_notify(method, params = {})
203
+ ensure_initialized
204
+ url_base = @base_url.sub(%r{/sse/?$}, '')
205
+ uri = URI.parse("#{url_base}/messages?sessionId=#{@session_id}")
206
+ rpc_http = Net::HTTP.new(uri.host, uri.port)
207
+ if uri.scheme == 'https'
208
+ rpc_http.use_ssl = true
209
+ rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
210
+ end
211
+ rpc_http.open_timeout = 10
212
+ rpc_http.read_timeout = @read_timeout
213
+ rpc_http.keep_alive_timeout = 60
214
+ rpc_http.start do |http|
215
+ http_req = Net::HTTP::Post.new(uri)
216
+ http_req.content_type = 'application/json'
217
+ http_req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
218
+ headers = @headers.dup
219
+ headers.except('Accept', 'Cache-Control').each { |k, v| http_req[k] = v }
220
+ response = http.request(http_req)
221
+ unless response.is_a?(Net::HTTPSuccess)
222
+ raise MCPClient::Errors::ServerError, "Notification failed: #{response.code} #{response.message}"
223
+ end
224
+ end
225
+ rescue StandardError => e
226
+ raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
227
+ ensure
228
+ rpc_http.finish if rpc_http&.started?
229
+ end
230
+
182
231
  private
183
232
 
233
+ # Ensure handshake initialization has been performed
234
+ def ensure_initialized
235
+ return if @initialized
236
+
237
+ connect
238
+ perform_initialize
239
+ @initialized = true
240
+ end
241
+
242
+ # Perform JSON-RPC initialize handshake with the MCP server
243
+ def perform_initialize
244
+ request_id = @mutex.synchronize { @request_id += 1 }
245
+ json_rpc_request = {
246
+ jsonrpc: '2.0',
247
+ id: request_id,
248
+ method: 'initialize',
249
+ params: {
250
+ 'protocolVersion' => MCPClient::VERSION,
251
+ 'capabilities' => {},
252
+ 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
253
+ }
254
+ }
255
+ @logger.debug("Performing initialize RPC: #{json_rpc_request}")
256
+ result = send_jsonrpc_request(json_rpc_request)
257
+ return unless result.is_a?(Hash)
258
+
259
+ @server_info = result['serverInfo'] if result.key?('serverInfo')
260
+ @capabilities = result['capabilities'] if result.key?('capabilities')
261
+ end
262
+
184
263
  # Start the SSE thread to listen for events
185
264
  def start_sse_thread
186
265
  return if @sse_thread&.alive?
@@ -272,6 +351,11 @@ module MCPClient
272
351
  when 'message'
273
352
  begin
274
353
  data = JSON.parse(event[:data])
354
+ # Dispatch JSON-RPC notifications (no id, has method)
355
+ if data['method'] && !data.key?('id')
356
+ @notification_callback&.call(data['method'], data['params'])
357
+ return
358
+ end
275
359
 
276
360
  @mutex.synchronize do
277
361
  @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
@@ -16,8 +16,9 @@ module MCPClient
16
16
  # @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
17
17
  # @param retries [Integer] number of retry attempts on transient errors
18
18
  # @param retry_backoff [Numeric] base delay in seconds for exponential backoff
19
+ # @param read_timeout [Numeric] timeout in seconds for reading responses
19
20
  # @param logger [Logger, nil] optional logger
20
- def initialize(command:, retries: 0, retry_backoff: 1, logger: nil)
21
+ def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, logger: nil)
21
22
  super()
22
23
  @command = command.is_a?(Array) ? command.join(' ') : command
23
24
  @mutex = Mutex.new
@@ -26,8 +27,11 @@ module MCPClient
26
27
  @pending = {}
27
28
  @initialized = false
28
29
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
30
+ @logger.progname = self.class.name
31
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
29
32
  @max_retries = retries
30
33
  @retry_backoff = retry_backoff
34
+ @read_timeout = read_timeout
31
35
  end
32
36
 
33
37
  # Connect to the MCP server by launching the command process via stdout/stdin
@@ -57,6 +61,12 @@ module MCPClient
57
61
  def handle_line(line)
58
62
  msg = JSON.parse(line)
59
63
  @logger.debug("Received line: #{line.chomp}")
64
+ # Dispatch JSON-RPC notifications (no id, has method)
65
+ if msg['method'] && !msg.key?('id')
66
+ @notification_callback&.call(msg['method'], msg['params'])
67
+ return
68
+ end
69
+ # Handle standard JSON-RPC responses
60
70
  id = msg['id']
61
71
  return unless id
62
72
 
@@ -188,7 +198,7 @@ module MCPClient
188
198
  end
189
199
 
190
200
  def wait_response(id)
191
- deadline = Time.now + READ_TIMEOUT
201
+ deadline = Time.now + @read_timeout
192
202
  @mutex.synchronize do
193
203
  until @pending.key?(id)
194
204
  remaining = deadline - Time.now
@@ -213,5 +223,45 @@ module MCPClient
213
223
  yielder << call_tool(tool_name, parameters)
214
224
  end
215
225
  end
226
+
227
+ # Generic JSON-RPC request: send method with params and wait for result
228
+ # @param method [String] JSON-RPC method
229
+ # @param params [Hash] parameters for the request
230
+ # @return [Object] result from JSON-RPC response
231
+ def rpc_request(method, params = {})
232
+ ensure_initialized
233
+ attempts = 0
234
+ begin
235
+ req_id = next_id
236
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => method, 'params' => params }
237
+ send_request(req)
238
+ res = wait_response(req_id)
239
+ if (err = res['error'])
240
+ raise MCPClient::Errors::ServerError, err['message']
241
+ end
242
+
243
+ res['result']
244
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
245
+ Errno::ECONNRESET => e
246
+ attempts += 1
247
+ if attempts <= @max_retries
248
+ delay = @retry_backoff * (2**(attempts - 1))
249
+ @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
250
+ sleep(delay)
251
+ retry
252
+ end
253
+ raise
254
+ end
255
+ end
256
+
257
+ # Send a JSON-RPC notification (no response expected)
258
+ # @param method [String] JSON-RPC method
259
+ # @param params [Hash] parameters for the notification
260
+ # @return [void]
261
+ def rpc_notify(method, params = {})
262
+ ensure_initialized
263
+ notif = { 'jsonrpc' => '2.0', 'method' => method, 'params' => params }
264
+ @stdin.puts(notif.to_json)
265
+ end
216
266
  end
217
267
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mcp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Szymon Kurcab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-23 00:00:00.000000000 Z
11
+ date: 2025-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rdoc