ruby-mcp-client 0.7.1 → 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: 94f8597a5cddfa1f86b6e09401b652d2c7aa3bd4eefc8771712b7c03d99772a8
4
- data.tar.gz: b303e3056719fea1a62cf4eeff76156e6e3ed68d134d48aa3ce969e8095d85be
3
+ metadata.gz: fd6b4269c644ed783b7d574e5a60b26ef0c749c3fe0a58fc86b4d7d5d8786a0d
4
+ data.tar.gz: 58eb4935ec413db8478dc29983f5baa68d7c28627968ffb5189d1b9754090a51
5
5
  SHA512:
6
- metadata.gz: da1d04e7aee8207ff32a2db24df14c2b832b8f5fd6acf36c6b8f22b86cf802d99de5514a608a6336f1973949a34a8829c8f71a5b2dadc68ebff50ed7d4d23bc7
7
- data.tar.gz: e75630074326e81fddbc006f3bcd8b848c474792ac73c2e9f883722b49b0aeb2da31bb824263234599b279974c7c2445ce6d4d06766a0e447753e8060b248d8f
6
+ metadata.gz: ee86e5f1e58fb98df9a9a7e715049eb8d7ae5f182430060633c9f97d7b2268d1a02859403ace69f6a400344cffad80dd9238169a638e27491a6bc99ce70fb462
7
+ data.tar.gz: 27e82788b84bc0887c273ab3c1277180e85f4930f413500fab4dbf2a7474dcb151bf510ec2509caf7a95bf53311dee587e65d8826b226e7ef164d9acd640582a
data/README.md CHANGED
@@ -47,6 +47,8 @@ This Ruby MCP Client implements key features from the latest MCP specification (
47
47
  ### Implemented Features
48
48
  - **OAuth 2.1 Authorization Framework** - Complete authentication with PKCE, dynamic client registration, server discovery, and runtime configuration
49
49
  - **Streamable HTTP Transport** - Enhanced transport with Server-Sent Event formatted responses and session management
50
+ - **HTTP Redirect Support** - Automatic redirect handling for both SSE and HTTP transports with configurable limits
51
+ - **FastMCP Compatibility** - Full compatibility with FastMCP servers including proper line ending handling
50
52
 
51
53
  ## Usage
52
54
 
@@ -286,12 +288,12 @@ require 'logger'
286
288
  logger = Logger.new($stdout)
287
289
  logger.level = Logger::INFO
288
290
 
289
- # 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
290
292
  # First run: npx @playwright/mcp@latest --port 8931
291
- sse_client = MCPClient.create_client(
293
+ mcp_client = MCPClient.create_client(
292
294
  mcp_server_configs: [
293
- MCPClient.sse_config(
294
- base_url: 'http://localhost:8931/sse',
295
+ MCPClient.streamable_http_config(
296
+ base_url: 'http://localhost:8931/mcp',
295
297
  read_timeout: 30, # Timeout in seconds for request fulfillment
296
298
  ping: 10, # Send ping after 10 seconds of inactivity
297
299
  # Connection closes automatically after inactivity (2.5x ping interval)
@@ -302,37 +304,79 @@ sse_client = MCPClient.create_client(
302
304
  )
303
305
 
304
306
  # List available tools
305
- tools = sse_client.list_tools
307
+ tools = mcp_client.list_tools
306
308
 
307
309
  # Launch a browser
308
- result = sse_client.call_tool('browser_install', {})
309
- 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' })
310
312
  # No browser ID needed with these tool names
311
313
 
312
314
  # Create a new page
313
- page_result = sse_client.call_tool('browser_tab_new', {})
315
+ page_result = mcp_client.call_tool('browser_tab', {action: 'create'})
314
316
  # No page ID needed with these tool names
315
317
 
316
318
  # Navigate to a website
317
- sse_client.call_tool('browser_navigate', { url: 'https://example.com' })
319
+ mcp_client.call_tool('browser_navigate', { url: 'https://example.com' })
318
320
 
319
321
  # Get page title
320
- title_result = sse_client.call_tool('browser_snapshot', {})
322
+ title_result = mcp_client.call_tool('browser_snapshot', {})
321
323
  puts "Page snapshot: #{title_result}"
322
324
 
323
325
  # Take a screenshot
324
- screenshot_result = sse_client.call_tool('browser_take_screenshot', {})
326
+ screenshot_result = mcp_client.call_tool('browser_take_screenshot', {})
325
327
 
326
328
  # Ping the server to verify connectivity
327
- ping_result = sse_client.ping
329
+ ping_result = mcp_client.ping
328
330
  puts "Ping successful: #{ping_result.inspect}"
329
331
 
330
332
  # Clean up
331
- sse_client.cleanup
333
+ mcp_client.cleanup
332
334
  ```
333
335
 
334
336
  See `examples/mcp_sse_server_example.rb` for the full Playwright SSE example.
335
337
 
338
+ ### FastMCP Example
339
+
340
+ The repository includes a complete FastMCP server example that demonstrates the Ruby MCP client working with a Python FastMCP server:
341
+
342
+ ```ruby
343
+ # Start the FastMCP server
344
+ # python examples/echo_server.py
345
+
346
+ # Run the Ruby client
347
+ # bundle exec ruby examples/echo_server_client.rb
348
+
349
+ require 'mcp_client'
350
+
351
+ # Connect to FastMCP server
352
+ client = MCPClient.create_client(
353
+ mcp_server_configs: [
354
+ MCPClient.sse_config(
355
+ base_url: 'http://127.0.0.1:8000/sse/',
356
+ read_timeout: 30
357
+ )
358
+ ]
359
+ )
360
+
361
+ # List available tools
362
+ tools = client.list_tools
363
+ puts "Found #{tools.length} tools:"
364
+ tools.each { |tool| puts "- #{tool.name}: #{tool.description}" }
365
+
366
+ # Use the tools
367
+ result = client.call_tool('echo', { message: 'Hello from Ruby!' })
368
+ result = client.call_tool('reverse', { text: 'FastMCP rocks!' })
369
+
370
+ client.cleanup
371
+ ```
372
+
373
+ The FastMCP example includes:
374
+ - **`echo_server.py`** - A Python FastMCP server with 4 interactive tools
375
+ - **`echo_server_client.rb`** - Ruby client demonstrating all features
376
+ - **`README_ECHO_SERVER.md`** - Complete setup and usage instructions
377
+
378
+ This example showcases redirect support, proper line ending handling, and seamless integration between Ruby and Python MCP implementations.
379
+
336
380
  ### Integration Examples
337
381
 
338
382
  The repository includes examples for integrating with popular AI APIs:
@@ -421,6 +465,7 @@ Complete examples can be found in the `examples/` directory:
421
465
  - `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
422
466
  - `gemini_ai_mcp.rb` - Integration with Google Vertex AI and Gemini models
423
467
  - `mcp_sse_server_example.rb` - SSE transport with Playwright MCP
468
+ - `echo_server.py` & `echo_server_client.rb` - FastMCP server example with full setup
424
469
 
425
470
  ## MCP Server Compatibility
426
471
 
@@ -428,6 +473,7 @@ This client works with any MCP-compatible server, including:
428
473
 
429
474
  - [@modelcontextprotocol/server-filesystem](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem) - File system access
430
475
  - [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
476
+ - [FastMCP](https://github.com/jlowin/fastmcp) - Python framework for building MCP servers
431
477
  - Custom servers implementing the MCP protocol
432
478
 
433
479
  ### Server Definition Files
@@ -439,7 +485,7 @@ You can define MCP server configurations in JSON files for easier management:
439
485
  "mcpServers": {
440
486
  "playwright": {
441
487
  "type": "sse",
442
- "url": "http://localhost:8931/sse",
488
+ "url": "http://localhost:8931/mcp",
443
489
  "headers": {
444
490
  "Authorization": "Bearer TOKEN"
445
491
  }
@@ -471,7 +517,7 @@ A simpler example used in the Playwright demo (found in `examples/sample_server_
471
517
  {
472
518
  "mcpServers": {
473
519
  "playwright": {
474
- "url": "http://localhost:8931/sse",
520
+ "url": "http://localhost:8931/mcp",
475
521
  "headers": {},
476
522
  "comment": "Local Playwright MCP Server running on port 8931"
477
523
  }
@@ -645,6 +691,7 @@ For complete OAuth documentation, see [OAUTH.md](OAUTH.md).
645
691
  The SSE client implementation provides these key features:
646
692
 
647
693
  - **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts and retries
694
+ - **Automatic redirect support**: Follows HTTP redirects up to 3 hops for seamless server integration
648
695
  - **Advanced connection management**:
649
696
  - **Inactivity tracking**: Monitors connection activity to detect idle connections
650
697
  - **Automatic ping**: Sends ping requests after a configurable period of inactivity (default: 10 seconds)
@@ -674,6 +721,7 @@ The SSE client implementation provides these key features:
674
721
  The HTTP transport provides a simpler, stateless communication mechanism for MCP servers:
675
722
 
676
723
  - **Request/Response Model**: Standard HTTP request/response cycle for each JSON-RPC call
724
+ - **Automatic redirect support**: Follows HTTP redirects up to 3 hops for seamless server integration
677
725
  - **JSON-Only Responses**: Accepts only `application/json` responses (no SSE support)
678
726
  - **Session Support**: Automatic session header (`Mcp-Session-Id`) capture and injection for session-based MCP servers
679
727
  - **Session Termination**: Proper session cleanup with HTTP DELETE requests during connection teardown
@@ -27,7 +27,7 @@ module MCPClient
27
27
 
28
28
  result = {}
29
29
  servers_data.each do |server_name, config|
30
- next unless validate_server_config(config, server_name)
30
+ next unless valid_server_config?(config, server_name)
31
31
 
32
32
  server_config = process_server_config(config, server_name)
33
33
  next unless server_config
@@ -66,7 +66,7 @@ module MCPClient
66
66
  # @param config [Object] server configuration to validate
67
67
  # @param server_name [String] name of the server
68
68
  # @return [Boolean] true if valid, false otherwise
69
- def validate_server_config(config, server_name)
69
+ def valid_server_config?(config, server_name)
70
70
  return true if config.is_a?(Hash)
71
71
 
72
72
  @logger.warn("Configuration for server '#{server_name}' is not an object; skipping.")
@@ -86,11 +86,11 @@ module MCPClient
86
86
  when 'stdio'
87
87
  parse_stdio_config(clean, config, server_name)
88
88
  when 'sse'
89
- return nil unless parse_sse_config(clean, config, server_name)
89
+ return nil unless parse_sse_config?(clean, config, server_name)
90
90
  when 'streamable_http'
91
- return nil unless parse_streamable_http_config(clean, config, server_name)
91
+ return nil unless parse_streamable_http_config?(clean, config, server_name)
92
92
  when 'http'
93
- return nil unless parse_http_config(clean, config, server_name)
93
+ return nil unless parse_http_config?(clean, config, server_name)
94
94
  else
95
95
  @logger.warn("Unrecognized type '#{type}' for server '#{server_name}'; skipping.")
96
96
  return nil
@@ -164,7 +164,7 @@ module MCPClient
164
164
  # @param config [Hash] raw configuration from JSON
165
165
  # @param server_name [String] name of the server for error reporting
166
166
  # @return [Boolean] true if parsing succeeded, false if required elements are missing
167
- def parse_sse_config(clean, config, server_name)
167
+ def parse_sse_config?(clean, config, server_name)
168
168
  # URL is required
169
169
  source = config['url']
170
170
  unless source
@@ -192,7 +192,7 @@ module MCPClient
192
192
  # @param config [Hash] raw configuration from JSON
193
193
  # @param server_name [String] name of the server for error reporting
194
194
  # @return [Boolean] true if parsing succeeded, false if required elements are missing
195
- def parse_streamable_http_config(clean, config, server_name)
195
+ def parse_streamable_http_config?(clean, config, server_name)
196
196
  # URL is required
197
197
  source = config['url']
198
198
  unless source
@@ -225,7 +225,7 @@ module MCPClient
225
225
  # @param config [Hash] raw configuration from JSON
226
226
  # @param server_name [String] name of the server for error reporting
227
227
  # @return [Boolean] true if parsing succeeded, false if required elements are missing
228
- def parse_http_config(clean, config, server_name)
228
+ def parse_http_config?(clean, config, server_name)
229
229
  # URL is required
230
230
  source = config['url']
231
231
  unless source
@@ -119,7 +119,7 @@ module MCPClient
119
119
  # @return [Hash] the initialization parameters
120
120
  def initialization_params
121
121
  {
122
- 'protocolVersion' => MCPClient::HTTP_PROTOCOL_VERSION,
122
+ 'protocolVersion' => MCPClient::PROTOCOL_VERSION,
123
123
  'capabilities' => {},
124
124
  'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
125
125
  }
@@ -77,5 +77,23 @@ module MCPClient
77
77
  def on_notification(&block)
78
78
  @notification_callback = block
79
79
  end
80
+
81
+ protected
82
+
83
+ # Initialize logger with proper formatter handling
84
+ # Preserves custom formatter if logger is provided, otherwise sets a default formatter
85
+ # @param logger [Logger, nil] custom logger to use, or nil to create a default one
86
+ # @return [Logger] the configured logger
87
+ def initialize_logger(logger)
88
+ if logger
89
+ @logger = logger
90
+ @logger.progname = self.class.name
91
+ else
92
+ @logger = Logger.new($stdout, level: Logger::WARN)
93
+ @logger.progname = self.class.name
94
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
95
+ end
96
+ @logger
97
+ end
80
98
  end
81
99
  end
@@ -6,6 +6,7 @@ require 'monitor'
6
6
  require 'logger'
7
7
  require 'faraday'
8
8
  require 'faraday/retry'
9
+ require 'faraday/follow_redirects'
9
10
 
10
11
  module MCPClient
11
12
  # Implementation of MCP server that communicates via HTTP requests/responses
@@ -49,9 +50,7 @@ module MCPClient
49
50
  def initialize(base_url:, **options)
50
51
  opts = default_options.merge(options)
51
52
  super(name: opts[:name])
52
- @logger = opts[:logger] || Logger.new($stdout, level: Logger::WARN)
53
- @logger.progname = self.class.name
54
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
53
+ initialize_logger(opts[:logger])
55
54
 
56
55
  @max_retries = opts[:retries]
57
56
  @retry_backoff = opts[:retry_backoff]
@@ -70,6 +70,13 @@ module MCPClient
70
70
 
71
71
  @server_info = result['serverInfo']
72
72
  @capabilities = result['capabilities']
73
+
74
+ # Send initialized notification to acknowledge completion of initialization
75
+ initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
76
+ post_json_rpc_request(initialized_notification)
77
+
78
+ # Small delay to ensure server processes the notification
79
+ sleep(0.1)
73
80
  end
74
81
 
75
82
  # Send a JSON-RPC request to the server and wait for result
@@ -133,6 +140,7 @@ module MCPClient
133
140
  def create_json_rpc_connection(base_url)
134
141
  Faraday.new(url: base_url) do |f|
135
142
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
143
+ f.response :follow_redirects, limit: 3
136
144
  f.options.open_timeout = @read_timeout
137
145
  f.options.timeout = @read_timeout
138
146
  f.adapter Faraday.default_adapter
@@ -191,6 +191,7 @@ module MCPClient
191
191
  f.options.open_timeout = 10
192
192
  f.options.timeout = nil
193
193
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
194
+ f.response :follow_redirects, limit: 3
194
195
  f.adapter Faraday.default_adapter
195
196
  end
196
197
 
@@ -31,9 +31,9 @@ module MCPClient
31
31
  data = JSON.parse(event[:data])
32
32
 
33
33
  return if process_error_in_message(data)
34
- return if process_notification(data)
34
+ return if process_notification?(data)
35
35
 
36
- process_response(data)
36
+ process_response?(data)
37
37
  rescue MCPClient::Errors::ConnectionError
38
38
  raise
39
39
  rescue JSON::ParserError => e
@@ -61,7 +61,7 @@ module MCPClient
61
61
  # Process a JSON-RPC notification (no id => notification)
62
62
  # @param data [Hash] the parsed JSON payload
63
63
  # @return [Boolean] true if we saw & handled a notification
64
- def process_notification(data)
64
+ def process_notification?(data)
65
65
  return false unless data['method'] && !data.key?('id')
66
66
 
67
67
  @notification_callback&.call(data['method'], data['params'])
@@ -71,7 +71,7 @@ module MCPClient
71
71
  # Process a JSON-RPC response (id => response)
72
72
  # @param data [Hash] the parsed JSON payload
73
73
  # @return [Boolean] true if we saw & handled a response
74
- def process_response(data)
74
+ def process_response?(data)
75
75
  return false unless data['id']
76
76
 
77
77
  @mutex.synchronize do
@@ -6,6 +6,7 @@ require 'monitor'
6
6
  require 'logger'
7
7
  require 'faraday'
8
8
  require 'faraday/retry'
9
+ require 'faraday/follow_redirects'
9
10
 
10
11
  module MCPClient
11
12
  # Implementation of MCP server that communicates via Server-Sent Events (SSE)
@@ -56,13 +57,11 @@ module MCPClient
56
57
  def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
57
58
  retries: 0, retry_backoff: 1, name: nil, logger: nil)
58
59
  super(name: name)
59
- @logger = logger || Logger.new($stdout, level: Logger::WARN)
60
- @logger.progname = self.class.name
61
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
60
+ initialize_logger(logger)
62
61
  @max_retries = retries
63
62
  @retry_backoff = retry_backoff
64
- # Normalize base_url: strip any trailing slash, use exactly as provided
65
- @base_url = base_url.chomp('/')
63
+ # Normalize base_url: preserve trailing slash if explicitly provided for SSE endpoints
64
+ @base_url = base_url
66
65
  @headers = headers.merge({
67
66
  'Accept' => 'text/event-stream',
68
67
  'Cache-Control' => 'no-cache',
@@ -369,53 +368,86 @@ module MCPClient
369
368
  record_activity if chunk.include?('event:')
370
369
 
371
370
  # Check for direct JSON error responses (which aren't proper SSE events)
372
- if chunk.start_with?('{') && chunk.include?('"error"') &&
373
- (chunk.include?('Unauthorized') || chunk.include?('authentication'))
374
- begin
375
- data = JSON.parse(chunk)
376
- if data['error']
377
- error_message = data['error']['message'] || 'Unknown server error'
371
+ handle_json_error_response(chunk)
372
+
373
+ event_buffers = extract_complete_events(chunk)
374
+
375
+ # Process extracted events outside the mutex to avoid deadlocks
376
+ event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
377
+ end
378
+
379
+ # Check if the error represents an authorization error
380
+ # @param error_message [String] The error message from the server
381
+ # @param error_code [Integer, nil] The error code if available
382
+ # @return [Boolean] True if it's an authorization error
383
+ # @private
384
+ def authorization_error?(error_message, error_code)
385
+ return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
386
+ return true if [401, -32_000].include?(error_code)
378
387
 
379
- @mutex.synchronize do
380
- @auth_error = "Authorization failed: #{error_message}"
388
+ false
389
+ end
381
390
 
382
- @connection_established = false
383
- @connection_cv.broadcast
384
- end
391
+ # Handle JSON error responses embedded in SSE chunks
392
+ # @param chunk [String] the chunk to check for JSON errors
393
+ # @return [void]
394
+ # @raise [MCPClient::Errors::ConnectionError] if authentication error is found
395
+ # @private
396
+ def handle_json_error_response(chunk)
397
+ return unless chunk.start_with?('{') && chunk.include?('"error"') &&
398
+ (chunk.include?('Unauthorized') || chunk.include?('authentication'))
385
399
 
386
- raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
400
+ begin
401
+ data = JSON.parse(chunk)
402
+ if data['error']
403
+ error_message = data['error']['message'] || 'Unknown server error'
404
+
405
+ @mutex.synchronize do
406
+ @auth_error = "Authorization failed: #{error_message}"
407
+ @connection_established = false
408
+ @connection_cv.broadcast
387
409
  end
388
- rescue JSON::ParserError
389
- # Not valid JSON, process normally
410
+
411
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
390
412
  end
413
+ rescue JSON::ParserError
414
+ # Not valid JSON, process normally
391
415
  end
416
+ end
392
417
 
418
+ # Extract complete SSE events from the buffer
419
+ # @param chunk [String] the chunk to add to the buffer
420
+ # @return [Array<String>, nil] array of complete events or nil if none
421
+ # @private
422
+ def extract_complete_events(chunk)
393
423
  event_buffers = nil
394
424
  @mutex.synchronize do
395
425
  @buffer += chunk
396
426
 
397
427
  # Extract all complete events from the buffer
428
+ # Handle both Unix (\n\n) and Windows (\r\n\r\n) line endings
398
429
  event_buffers = []
399
- while (event_end = @buffer.index("\n\n"))
400
- event_data = @buffer.slice!(0, event_end + 2)
430
+ while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
431
+ event_data = extract_single_event(event_end)
401
432
  event_buffers << event_data
402
433
  end
403
434
  end
404
-
405
- # Process extracted events outside the mutex to avoid deadlocks
406
- event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
435
+ event_buffers
407
436
  end
408
437
 
409
- # Check if the error represents an authorization error
410
- # @param error_message [String] The error message from the server
411
- # @param error_code [Integer, nil] The error code if available
412
- # @return [Boolean] True if it's an authorization error
438
+ # Extract a single event from the buffer
439
+ # @param event_end [Integer] the position where the event ends
440
+ # @return [String] the extracted event data
413
441
  # @private
414
- def authorization_error?(error_message, error_code)
415
- return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
416
- return true if [401, -32_000].include?(error_code)
417
-
418
- false
442
+ def extract_single_event(event_end)
443
+ # Determine the line ending style and extract accordingly
444
+ crlf_index = @buffer.index("\r\n\r\n")
445
+ lf_index = @buffer.index("\n\n")
446
+ if crlf_index && (lf_index.nil? || crlf_index < lf_index)
447
+ @buffer.slice!(0, event_end + 4) # \r\n\r\n is 4 chars
448
+ else
449
+ @buffer.slice!(0, event_end + 2) # \n\n is 2 chars
450
+ end
419
451
  end
420
452
 
421
453
  # Handle authorization error in SSE message
@@ -39,9 +39,7 @@ module MCPClient
39
39
  @next_id = 1
40
40
  @pending = {}
41
41
  @initialized = false
42
- @logger = logger || Logger.new($stdout, level: Logger::WARN)
43
- @logger.progname = self.class.name
44
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
42
+ initialize_logger(logger)
45
43
  @max_retries = retries
46
44
  @retry_backoff = retry_backoff
47
45
  @read_timeout = read_timeout
@@ -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
@@ -6,11 +6,18 @@ require 'monitor'
6
6
  require 'logger'
7
7
  require 'faraday'
8
8
  require 'faraday/retry'
9
+ require 'faraday/follow_redirects'
9
10
 
10
11
  module MCPClient
11
- # Implementation of MCP server that communicates via Streamable HTTP transport
12
- # This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
13
- # 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
14
21
  class ServerStreamableHTTP < ServerBase
15
22
  require_relative 'server_streamable_http/json_rpc_transport'
16
23
 
@@ -20,6 +27,12 @@ module MCPClient
20
27
  DEFAULT_READ_TIMEOUT = 30
21
28
  DEFAULT_MAX_RETRIES = 3
22
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
+
23
36
  # @!attribute [r] base_url
24
37
  # @return [String] The base URL of the MCP server
25
38
  # @!attribute [r] endpoint
@@ -41,9 +54,7 @@ module MCPClient
41
54
  def initialize(base_url:, **options)
42
55
  opts = default_options.merge(options)
43
56
  super(name: opts[:name])
44
- @logger = opts[:logger] || Logger.new($stdout, level: Logger::WARN)
45
- @logger.progname = self.class.name
46
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
57
+ initialize_logger(opts[:logger])
47
58
 
48
59
  @max_retries = opts[:retries]
49
60
  @retry_backoff = opts[:retry_backoff]
@@ -96,6 +107,11 @@ module MCPClient
96
107
  @session_id = nil
97
108
  @last_event_id = nil
98
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
99
115
  end
100
116
 
101
117
  # Connect to the MCP server over Streamable HTTP
@@ -116,6 +132,9 @@ module MCPClient
116
132
  # Perform MCP initialization handshake
117
133
  perform_initialize
118
134
 
135
+ # Start long-lived GET connection for server events
136
+ start_events_connection
137
+
119
138
  @mutex.synchronize do
120
139
  @connection_established = true
121
140
  @initialized = true
@@ -171,7 +190,8 @@ module MCPClient
171
190
  def call_tool(tool_name, parameters)
172
191
  rpc_request('tools/call', {
173
192
  name: tool_name,
174
- arguments: parameters
193
+ arguments: parameters.except(:_meta),
194
+ **parameters.slice(:_meta)
175
195
  })
176
196
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
177
197
  # Re-raise connection/transport errors directly to match test expectations
@@ -239,28 +259,60 @@ module MCPClient
239
259
  end
240
260
 
241
261
  # Clean up the server connection
242
- # Properly closes HTTP connections and clears cached state
262
+ # Properly closes HTTP connections, stops threads, and clears cached state
243
263
  def cleanup
244
264
  @mutex.synchronize do
245
- # Attempt to terminate session before cleanup
246
- terminate_session if @session_id
265
+ return unless @connection_established || @initialized
266
+
267
+ @logger.info('Cleaning up Streamable HTTP connection')
247
268
 
269
+ # Mark connection as closed to stop reconnection attempts
248
270
  @connection_established = false
249
271
  @initialized = false
250
272
 
251
- @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
252
279
 
253
- # 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
254
289
  @http_conn = nil
290
+ @events_connection = nil
255
291
  @session_id = nil
292
+ @last_event_id = nil
256
293
 
294
+ # Clear cached data
257
295
  @tools = nil
258
296
  @tools_data = nil
297
+ @buffer = ''
298
+
299
+ @logger.info('Cleanup completed')
259
300
  end
260
301
  end
261
302
 
262
303
  private
263
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
+
264
316
  # Default options for server initialization
265
317
  # @return [Hash] Default options
266
318
  def default_options
@@ -328,5 +380,220 @@ module MCPClient
328
380
 
329
381
  raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
330
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
331
598
  end
332
599
  end
@@ -2,11 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.7.1'
5
+ VERSION = '0.7.3'
6
6
 
7
- # JSON-RPC handshake protocol version (date-based)
8
- PROTOCOL_VERSION = '2024-11-05'
9
-
10
- # Protocol version for HTTP and Streamable HTTP transports
11
- HTTP_PROTOCOL_VERSION = '2025-03-26'
7
+ # MCP protocol version (date-based) - unified across all transports
8
+ PROTOCOL_VERSION = '2025-03-26'
12
9
  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.7.1
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-06-20 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
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-follow_redirects
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.3'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: faraday-retry
29
43
  requirement: !ruby/object:Gem::Requirement