ruby-mcp-client 0.7.1 → 0.7.2

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: e71d5420a77345079ef2da077240e6a578900c3acfd2c16c904b0a2bf84a8bb7
4
+ data.tar.gz: ea4f41ebef3d5898d3dd0786d50d08dbbd19057bbbd5b7aa8cc046d5a1608d7c
5
5
  SHA512:
6
- metadata.gz: da1d04e7aee8207ff32a2db24df14c2b832b8f5fd6acf36c6b8f22b86cf802d99de5514a608a6336f1973949a34a8829c8f71a5b2dadc68ebff50ed7d4d23bc7
7
- data.tar.gz: e75630074326e81fddbc006f3bcd8b848c474792ac73c2e9f883722b49b0aeb2da31bb824263234599b279974c7c2445ce6d4d06766a0e447753e8060b248d8f
6
+ metadata.gz: a837ba7337913a453e5ae2567c67fed453b5e1cab3712705689c74b2efff27fa5c257750c834b5ac5bfff04333f08f1999b6813982d0c548ee92f4992c4d6aad
7
+ data.tar.gz: e10f2262b5dd10f0323dbbf870daa9f2d25d2d9f6a7f0c053870a8e0a5453ed9c5ed2c667211dfcf094c4fd56e7c12fbc1407c358e955fb14b4cbf4601312226
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
 
@@ -333,6 +335,48 @@ sse_client.cleanup
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
@@ -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
@@ -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 Streamable HTTP transport
@@ -41,9 +42,7 @@ module MCPClient
41
42
  def initialize(base_url:, **options)
42
43
  opts = default_options.merge(options)
43
44
  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" }
45
+ initialize_logger(opts[:logger])
47
46
 
48
47
  @max_retries = opts[:retries]
49
48
  @retry_backoff = opts[:retry_backoff]
@@ -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.2'
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.2
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-07-14 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