ruby-mcp-client 0.7.2 → 0.7.3

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: e71d5420a77345079ef2da077240e6a578900c3acfd2c16c904b0a2bf84a8bb7
4
- data.tar.gz: ea4f41ebef3d5898d3dd0786d50d08dbbd19057bbbd5b7aa8cc046d5a1608d7c
3
+ metadata.gz: fd6b4269c644ed783b7d574e5a60b26ef0c749c3fe0a58fc86b4d7d5d8786a0d
4
+ data.tar.gz: 58eb4935ec413db8478dc29983f5baa68d7c28627968ffb5189d1b9754090a51
5
5
  SHA512:
6
- metadata.gz: a837ba7337913a453e5ae2567c67fed453b5e1cab3712705689c74b2efff27fa5c257750c834b5ac5bfff04333f08f1999b6813982d0c548ee92f4992c4d6aad
7
- data.tar.gz: e10f2262b5dd10f0323dbbf870daa9f2d25d2d9f6a7f0c053870a8e0a5453ed9c5ed2c667211dfcf094c4fd56e7c12fbc1407c358e955fb14b4cbf4601312226
6
+ metadata.gz: ee86e5f1e58fb98df9a9a7e715049eb8d7ae5f182430060633c9f97d7b2268d1a02859403ace69f6a400344cffad80dd9238169a638e27491a6bc99ce70fb462
7
+ data.tar.gz: 27e82788b84bc0887c273ab3c1277180e85f4930f413500fab4dbf2a7474dcb151bf510ec2509caf7a95bf53311dee587e65d8826b226e7ef164d9acd640582a
data/README.md CHANGED
@@ -288,12 +288,12 @@ require 'logger'
288
288
  logger = Logger.new($stdout)
289
289
  logger.level = Logger::INFO
290
290
 
291
- # Create an MCP client that connects to a Playwright MCP server via SSE
291
+ # Create an MCP client that connects to a Playwright MCP server via Streamable HTTP
292
292
  # First run: npx @playwright/mcp@latest --port 8931
293
- sse_client = MCPClient.create_client(
293
+ mcp_client = MCPClient.create_client(
294
294
  mcp_server_configs: [
295
- MCPClient.sse_config(
296
- base_url: 'http://localhost:8931/sse',
295
+ MCPClient.streamable_http_config(
296
+ base_url: 'http://localhost:8931/mcp',
297
297
  read_timeout: 30, # Timeout in seconds for request fulfillment
298
298
  ping: 10, # Send ping after 10 seconds of inactivity
299
299
  # Connection closes automatically after inactivity (2.5x ping interval)
@@ -304,33 +304,33 @@ sse_client = MCPClient.create_client(
304
304
  )
305
305
 
306
306
  # List available tools
307
- tools = sse_client.list_tools
307
+ tools = mcp_client.list_tools
308
308
 
309
309
  # Launch a browser
310
- result = sse_client.call_tool('browser_install', {})
311
- result = sse_client.call_tool('browser_navigate', { url: 'about:blank' })
310
+ result = mcp_client.call_tool('browser_install', {})
311
+ result = mcp_client.call_tool('browser_navigate', { url: 'about:blank' })
312
312
  # No browser ID needed with these tool names
313
313
 
314
314
  # Create a new page
315
- page_result = sse_client.call_tool('browser_tab_new', {})
315
+ page_result = mcp_client.call_tool('browser_tab', {action: 'create'})
316
316
  # No page ID needed with these tool names
317
317
 
318
318
  # Navigate to a website
319
- sse_client.call_tool('browser_navigate', { url: 'https://example.com' })
319
+ mcp_client.call_tool('browser_navigate', { url: 'https://example.com' })
320
320
 
321
321
  # Get page title
322
- title_result = sse_client.call_tool('browser_snapshot', {})
322
+ title_result = mcp_client.call_tool('browser_snapshot', {})
323
323
  puts "Page snapshot: #{title_result}"
324
324
 
325
325
  # Take a screenshot
326
- screenshot_result = sse_client.call_tool('browser_take_screenshot', {})
326
+ screenshot_result = mcp_client.call_tool('browser_take_screenshot', {})
327
327
 
328
328
  # Ping the server to verify connectivity
329
- ping_result = sse_client.ping
329
+ ping_result = mcp_client.ping
330
330
  puts "Ping successful: #{ping_result.inspect}"
331
331
 
332
332
  # Clean up
333
- sse_client.cleanup
333
+ mcp_client.cleanup
334
334
  ```
335
335
 
336
336
  See `examples/mcp_sse_server_example.rb` for the full Playwright SSE example.
@@ -485,7 +485,7 @@ You can define MCP server configurations in JSON files for easier management:
485
485
  "mcpServers": {
486
486
  "playwright": {
487
487
  "type": "sse",
488
- "url": "http://localhost:8931/sse",
488
+ "url": "http://localhost:8931/mcp",
489
489
  "headers": {
490
490
  "Authorization": "Bearer TOKEN"
491
491
  }
@@ -517,7 +517,7 @@ A simpler example used in the Playwright demo (found in `examples/sample_server_
517
517
  {
518
518
  "mcpServers": {
519
519
  "playwright": {
520
- "url": "http://localhost:8931/sse",
520
+ "url": "http://localhost:8931/mcp",
521
521
  "headers": {},
522
522
  "comment": "Local Playwright MCP Server running on port 8931"
523
523
  }
@@ -45,30 +45,44 @@ module MCPClient
45
45
  # @return [Hash] the parsed JSON data
46
46
  # @raise [MCPClient::Errors::TransportError] if no data found in SSE response
47
47
  def parse_sse_response(sse_body)
48
- # Extract JSON data and event ID from SSE format
48
+ # Extract JSON data from SSE format, processing events separately
49
49
  # SSE format: event: message\nid: 123\ndata: {...}\n\n
50
- data_lines = []
51
- event_id = nil
50
+ events = []
51
+ current_event = { type: nil, data_lines: [], id: nil }
52
52
 
53
53
  sse_body.lines.each do |line|
54
54
  line = line.strip
55
- if line.start_with?('data:')
56
- data_lines << line.sub(/^data:\s*/, '').strip
55
+
56
+ if line.empty?
57
+ # Empty line marks end of an event
58
+ events << current_event.dup if current_event[:type] && !current_event[:data_lines].empty?
59
+ current_event = { type: nil, data_lines: [], id: nil }
60
+ elsif line.start_with?('event:')
61
+ current_event[:type] = line.sub(/^event:\s*/, '').strip
62
+ elsif line.start_with?('data:')
63
+ current_event[:data_lines] << line.sub(/^data:\s*/, '').strip
57
64
  elsif line.start_with?('id:')
58
- event_id = line.sub(/^id:\s*/, '').strip
65
+ current_event[:id] = line.sub(/^id:\s*/, '').strip
59
66
  end
60
67
  end
61
68
 
62
- raise MCPClient::Errors::TransportError, 'No data found in SSE response' if data_lines.empty?
69
+ # Handle last event if no trailing empty line
70
+ events << current_event if current_event[:type] && !current_event[:data_lines].empty?
71
+
72
+ # Find the first 'message' event which contains the JSON-RPC response
73
+ message_event = events.find { |e| e[:type] == 'message' }
74
+
75
+ raise MCPClient::Errors::TransportError, 'No data found in SSE response' unless message_event
76
+ raise MCPClient::Errors::TransportError, 'No data found in message event' if message_event[:data_lines].empty?
63
77
 
64
- # Track the last event ID for resumability
65
- if event_id && !event_id.empty?
66
- @last_event_id = event_id
67
- @logger.debug("Tracking event ID for resumability: #{event_id}")
78
+ # Track the event ID for resumability
79
+ if message_event[:id] && !message_event[:id].empty?
80
+ @last_event_id = message_event[:id]
81
+ @logger.debug("Tracking event ID for resumability: #{message_event[:id]}")
68
82
  end
69
83
 
70
84
  # Join multiline data fields according to SSE spec
71
- json_data = data_lines.join("\n")
85
+ json_data = message_event[:data_lines].join("\n")
72
86
  JSON.parse(json_data)
73
87
  end
74
88
  end
@@ -9,9 +9,15 @@ require 'faraday/retry'
9
9
  require 'faraday/follow_redirects'
10
10
 
11
11
  module MCPClient
12
- # Implementation of MCP server that communicates via Streamable HTTP transport
13
- # This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
14
- # It's designed for servers that support streaming responses over HTTP
12
+ # Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-03-26)
13
+ # This transport uses HTTP POST for RPC calls with optional SSE responses, and GET for event streams
14
+ # Compliant with MCP specification version 2025-03-26
15
+ #
16
+ # Key features:
17
+ # - Supports server-sent events (SSE) for real-time notifications
18
+ # - Handles ping/pong keepalive mechanism
19
+ # - Thread-safe connection management
20
+ # - Automatic reconnection with exponential backoff
15
21
  class ServerStreamableHTTP < ServerBase
16
22
  require_relative 'server_streamable_http/json_rpc_transport'
17
23
 
@@ -21,6 +27,12 @@ module MCPClient
21
27
  DEFAULT_READ_TIMEOUT = 30
22
28
  DEFAULT_MAX_RETRIES = 3
23
29
 
30
+ # SSE connection settings
31
+ SSE_CONNECTION_TIMEOUT = 300 # 5 minutes
32
+ SSE_RECONNECT_DELAY = 1 # Initial reconnect delay in seconds
33
+ SSE_MAX_RECONNECT_DELAY = 30 # Maximum reconnect delay in seconds
34
+ THREAD_JOIN_TIMEOUT = 5 # Timeout for thread cleanup
35
+
24
36
  # @!attribute [r] base_url
25
37
  # @return [String] The base URL of the MCP server
26
38
  # @!attribute [r] endpoint
@@ -95,6 +107,11 @@ module MCPClient
95
107
  @session_id = nil
96
108
  @last_event_id = nil
97
109
  @oauth_provider = opts[:oauth_provider]
110
+
111
+ # SSE events connection state
112
+ @events_connection = nil
113
+ @events_thread = nil
114
+ @buffer = '' # Buffer for partial SSE event data
98
115
  end
99
116
 
100
117
  # Connect to the MCP server over Streamable HTTP
@@ -115,6 +132,9 @@ module MCPClient
115
132
  # Perform MCP initialization handshake
116
133
  perform_initialize
117
134
 
135
+ # Start long-lived GET connection for server events
136
+ start_events_connection
137
+
118
138
  @mutex.synchronize do
119
139
  @connection_established = true
120
140
  @initialized = true
@@ -170,7 +190,8 @@ module MCPClient
170
190
  def call_tool(tool_name, parameters)
171
191
  rpc_request('tools/call', {
172
192
  name: tool_name,
173
- arguments: parameters
193
+ arguments: parameters.except(:_meta),
194
+ **parameters.slice(:_meta)
174
195
  })
175
196
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
176
197
  # Re-raise connection/transport errors directly to match test expectations
@@ -238,28 +259,60 @@ module MCPClient
238
259
  end
239
260
 
240
261
  # Clean up the server connection
241
- # Properly closes HTTP connections and clears cached state
262
+ # Properly closes HTTP connections, stops threads, and clears cached state
242
263
  def cleanup
243
264
  @mutex.synchronize do
244
- # Attempt to terminate session before cleanup
245
- terminate_session if @session_id
265
+ return unless @connection_established || @initialized
266
+
267
+ @logger.info('Cleaning up Streamable HTTP connection')
246
268
 
269
+ # Mark connection as closed to stop reconnection attempts
247
270
  @connection_established = false
248
271
  @initialized = false
249
272
 
250
- @logger.debug('Cleaning up Streamable HTTP connection')
273
+ # Attempt to terminate session before cleanup
274
+ begin
275
+ terminate_session if @session_id
276
+ rescue StandardError => e
277
+ @logger.warn("Failed to terminate session: #{e.message}")
278
+ end
251
279
 
252
- # Close HTTP connection if it exists
280
+ # Stop events thread gracefully
281
+ if @events_thread&.alive?
282
+ @logger.debug('Stopping events thread...')
283
+ @events_thread.kill
284
+ @events_thread.join(THREAD_JOIN_TIMEOUT)
285
+ end
286
+ @events_thread = nil
287
+
288
+ # Clear connections and state
253
289
  @http_conn = nil
290
+ @events_connection = nil
254
291
  @session_id = nil
292
+ @last_event_id = nil
255
293
 
294
+ # Clear cached data
256
295
  @tools = nil
257
296
  @tools_data = nil
297
+ @buffer = ''
298
+
299
+ @logger.info('Cleanup completed')
258
300
  end
259
301
  end
260
302
 
261
303
  private
262
304
 
305
+ def perform_initialize
306
+ super
307
+ # Send initialized notification to acknowledge completion of initialization
308
+ notification = build_jsonrpc_notification('notifications/initialized', {})
309
+ begin
310
+ send_http_request(notification)
311
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
312
+ raise MCPClient::Errors::TransportError, "Failed to send initialized notification: #{e.message}"
313
+ end
314
+ end
315
+
263
316
  # Default options for server initialization
264
317
  # @return [Hash] Default options
265
318
  def default_options
@@ -327,5 +380,220 @@ module MCPClient
327
380
 
328
381
  raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
329
382
  end
383
+
384
+ # Start the long-lived GET connection for server events
385
+ # Creates a separate thread to maintain SSE connection for server notifications
386
+ # @return [void]
387
+ def start_events_connection
388
+ return if @events_thread&.alive?
389
+
390
+ @logger.info('Starting SSE events connection thread')
391
+ @events_thread = Thread.new do
392
+ Thread.current.name = 'MCP-SSE-Events'
393
+ Thread.current.report_on_exception = false # We handle exceptions internally
394
+
395
+ begin
396
+ handle_events_connection
397
+ rescue StandardError => e
398
+ @logger.error("Events thread crashed: #{e.message}")
399
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
400
+ end
401
+ end
402
+ end
403
+
404
+ # Handle the events connection in a separate thread
405
+ # Maintains a persistent SSE connection for server notifications and ping/pong
406
+ # @return [void]
407
+ def handle_events_connection
408
+ reconnect_delay = SSE_RECONNECT_DELAY
409
+
410
+ loop do
411
+ # Create a Faraday connection specifically for SSE streaming
412
+ # Using net_http adapter for better streaming support
413
+ conn = Faraday.new(url: @base_url) do |f|
414
+ f.request :retry, max: 0 # No automatic retries for SSE stream
415
+ f.options.open_timeout = 10
416
+ f.options.timeout = SSE_CONNECTION_TIMEOUT
417
+ f.adapter :net_http do |http|
418
+ http.read_timeout = SSE_CONNECTION_TIMEOUT
419
+ http.open_timeout = 10
420
+ end
421
+ end
422
+
423
+ @logger.debug("Establishing SSE events connection to #{@endpoint}") if @logger.level <= Logger::DEBUG
424
+
425
+ response = conn.get(@endpoint) do |req|
426
+ apply_events_headers(req)
427
+
428
+ # Handle streaming response with on_data callback
429
+ req.options.on_data = proc do |chunk, _total_bytes|
430
+ if chunk && !chunk.empty?
431
+ @logger.debug("Received event chunk (#{chunk.bytesize} bytes)") if @logger.level <= Logger::DEBUG
432
+ process_event_chunk(chunk)
433
+ end
434
+ end
435
+ end
436
+
437
+ @logger.debug("Events connection completed with status: #{response.status}") if @logger.level <= Logger::DEBUG
438
+
439
+ # Connection closed normally, check if we should reconnect
440
+ break unless @mutex.synchronize { @connection_established }
441
+
442
+ @logger.info('Events connection closed, reconnecting...')
443
+ sleep reconnect_delay
444
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
445
+
446
+ # Intentional shutdown
447
+ rescue Net::ReadTimeout, Faraday::TimeoutError
448
+ # Timeout after inactivity - this is expected for long-lived connections
449
+ break unless @mutex.synchronize { @connection_established }
450
+
451
+ @logger.debug('Events connection timed out after inactivity, reconnecting...')
452
+ sleep reconnect_delay
453
+ rescue Faraday::ConnectionFailed => e
454
+ break unless @mutex.synchronize { @connection_established }
455
+
456
+ @logger.warn("Events connection failed: #{e.message}, retrying in #{reconnect_delay}s...")
457
+ sleep reconnect_delay
458
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
459
+ rescue StandardError => e
460
+ break unless @mutex.synchronize { @connection_established }
461
+
462
+ @logger.error("Unexpected error in events connection: #{e.class} - #{e.message}")
463
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
464
+ sleep reconnect_delay
465
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
466
+ end
467
+ ensure
468
+ @logger.info('Events connection thread terminated')
469
+ end
470
+
471
+ # Apply headers for events connection
472
+ # @param req [Faraday::Request] HTTP request
473
+ def apply_events_headers(req)
474
+ @headers.each { |k, v| req.headers[k] = v }
475
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
476
+ end
477
+
478
+ # Process event chunks from the server
479
+ # Buffers partial chunks and processes complete SSE events
480
+ # @param chunk [String] the chunk to process
481
+ def process_event_chunk(chunk)
482
+ @logger.debug("Processing event chunk: #{chunk.inspect}") if @logger.level <= Logger::DEBUG
483
+
484
+ @mutex.synchronize do
485
+ @buffer += chunk
486
+
487
+ # Extract complete events (SSE format: events end with double newline)
488
+ while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
489
+ event_data = extract_event(event_end)
490
+ parse_and_handle_event(event_data)
491
+ end
492
+ end
493
+ rescue StandardError => e
494
+ @logger.error("Error processing event chunk: #{e.message}")
495
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
496
+ end
497
+
498
+ # Extract a single event from the buffer
499
+ # @param event_end [Integer] the position where the event ends
500
+ # @return [String] the extracted event data
501
+ def extract_event(event_end)
502
+ # Determine the line ending style and extract accordingly
503
+ crlf_index = @buffer.index("\r\n\r\n")
504
+ lf_index = @buffer.index("\n\n")
505
+ if crlf_index && (lf_index.nil? || crlf_index < lf_index)
506
+ @buffer.slice!(0, event_end + 4) # \r\n\r\n is 4 chars
507
+ else
508
+ @buffer.slice!(0, event_end + 2) # \n\n is 2 chars
509
+ end
510
+ end
511
+
512
+ # Parse and handle an SSE event
513
+ # Parses SSE format according to the W3C specification
514
+ # @param event_data [String] the raw event data
515
+ def parse_and_handle_event(event_data)
516
+ event = { event: 'message', data: '', id: nil }
517
+ data_lines = []
518
+
519
+ event_data.each_line do |line|
520
+ line = line.chomp
521
+ next if line.empty? || line.start_with?(':') # Skip empty lines and comments
522
+
523
+ if line.start_with?('event:')
524
+ event[:event] = line[6..].strip
525
+ elsif line.start_with?('data:')
526
+ # SSE allows multiple data lines that should be joined with newlines
527
+ data_lines << line[5..].strip
528
+ elsif line.start_with?('id:')
529
+ # Track event ID for resumability (MCP future enhancement)
530
+ event[:id] = line[3..].strip
531
+ @last_event_id = event[:id]
532
+ elsif line.start_with?('retry:')
533
+ # Server can suggest reconnection delay (in milliseconds)
534
+ retry_ms = line[6..].strip.to_i
535
+ @logger.debug("Server suggested retry delay: #{retry_ms}ms") if @logger.level <= Logger::DEBUG
536
+ end
537
+ end
538
+
539
+ event[:data] = data_lines.join("\n")
540
+
541
+ # Only process non-empty data
542
+ handle_server_message(event[:data]) unless event[:data].empty?
543
+ end
544
+
545
+ # Handle server messages (notifications and requests)
546
+ # Processes ping/pong keepalive and server notifications
547
+ # @param data [String] the JSON data from SSE event
548
+ def handle_server_message(data)
549
+ return if data.empty?
550
+
551
+ begin
552
+ message = JSON.parse(data)
553
+
554
+ # Handle ping requests from server (keepalive mechanism)
555
+ if message['method'] == 'ping' && message.key?('id')
556
+ handle_ping_request(message['id'])
557
+ elsif message['method'] && !message.key?('id')
558
+ # Handle server notifications (messages without id)
559
+ @notification_callback&.call(message['method'], message['params'])
560
+ elsif message.key?('id')
561
+ # This might be a server-to-client request (future MCP versions)
562
+ @logger.warn("Received unhandled server request: #{message['method']}")
563
+ end
564
+ rescue JSON::ParserError => e
565
+ @logger.error("Invalid JSON in server message: #{e.message}")
566
+ @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
567
+ end
568
+ end
569
+
570
+ # Handle ping request from server
571
+ # Sends pong response to maintain session keepalive
572
+ # @param ping_id [Integer, String] the ping request ID
573
+ def handle_ping_request(ping_id)
574
+ pong_response = {
575
+ jsonrpc: '2.0',
576
+ id: ping_id,
577
+ result: {}
578
+ }
579
+
580
+ # Send pong response in a separate thread to avoid blocking event processing
581
+ Thread.new do
582
+ conn = http_connection
583
+ response = conn.post(@endpoint) do |req|
584
+ @headers.each { |k, v| req.headers[k] = v }
585
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
586
+ req.body = pong_response.to_json
587
+ end
588
+
589
+ if response.success?
590
+ @logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
591
+ else
592
+ @logger.warn("Failed to send pong response: HTTP #{response.status}")
593
+ end
594
+ rescue StandardError => e
595
+ @logger.error("Failed to send pong response: #{e.message}")
596
+ end
597
+ end
330
598
  end
331
599
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.7.2'
5
+ VERSION = '0.7.3'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
8
  PROTOCOL_VERSION = '2025-03-26'
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.7.2
4
+ version: 0.7.3
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-07-14 00:00:00.000000000 Z
11
+ date: 2025-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday