ruby-mcp-client 0.7.2 → 0.8.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.
@@ -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
@@ -87,6 +99,10 @@ module MCPClient
87
99
  @read_timeout = opts[:read_timeout]
88
100
  @tools = nil
89
101
  @tools_data = nil
102
+ @prompts = nil
103
+ @prompts_data = nil
104
+ @resources = nil
105
+ @resources_data = nil
90
106
  @request_id = 0
91
107
  @mutex = Monitor.new
92
108
  @connection_established = false
@@ -95,6 +111,11 @@ module MCPClient
95
111
  @session_id = nil
96
112
  @last_event_id = nil
97
113
  @oauth_provider = opts[:oauth_provider]
114
+
115
+ # SSE events connection state
116
+ @events_connection = nil
117
+ @events_thread = nil
118
+ @buffer = '' # Buffer for partial SSE event data
98
119
  end
99
120
 
100
121
  # Connect to the MCP server over Streamable HTTP
@@ -115,6 +136,9 @@ module MCPClient
115
136
  # Perform MCP initialization handshake
116
137
  perform_initialize
117
138
 
139
+ # Start long-lived GET connection for server events
140
+ start_events_connection
141
+
118
142
  @mutex.synchronize do
119
143
  @connection_established = true
120
144
  @initialized = true
@@ -170,7 +194,8 @@ module MCPClient
170
194
  def call_tool(tool_name, parameters)
171
195
  rpc_request('tools/call', {
172
196
  name: tool_name,
173
- arguments: parameters
197
+ arguments: parameters.except(:_meta),
198
+ **parameters.slice(:_meta)
174
199
  })
175
200
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
176
201
  # Re-raise connection/transport errors directly to match test expectations
@@ -190,6 +215,93 @@ module MCPClient
190
215
  end
191
216
  end
192
217
 
218
+ # List all prompts available from the MCP server
219
+ # @return [Array<MCPClient::Prompt>] list of available prompts
220
+ # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
221
+ def list_prompts
222
+ @mutex.synchronize do
223
+ return @prompts if @prompts
224
+ end
225
+
226
+ begin
227
+ ensure_connected
228
+
229
+ prompts_data = request_prompts_list
230
+ @mutex.synchronize do
231
+ @prompts = prompts_data.map do |prompt_data|
232
+ MCPClient::Prompt.from_json(prompt_data, server: self)
233
+ end
234
+ end
235
+
236
+ @mutex.synchronize { @prompts }
237
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
238
+ # Re-raise these errors directly
239
+ raise
240
+ rescue StandardError => e
241
+ raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
242
+ end
243
+ end
244
+
245
+ # Get a prompt with the given parameters
246
+ # @param prompt_name [String] the name of the prompt to get
247
+ # @param parameters [Hash] the parameters to pass to the prompt
248
+ # @return [Object] the result of the prompt (with string keys for backward compatibility)
249
+ # @raise [MCPClient::Errors::PromptGetError] if prompt retrieval fails
250
+ def get_prompt(prompt_name, parameters)
251
+ rpc_request('prompts/get', {
252
+ name: prompt_name,
253
+ arguments: parameters.except(:_meta),
254
+ **parameters.slice(:_meta)
255
+ })
256
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
257
+ # Re-raise connection/transport errors directly
258
+ raise
259
+ rescue StandardError => e
260
+ # For all other errors, wrap in PromptGetError
261
+ raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
262
+ end
263
+
264
+ # List all resources available from the MCP server
265
+ # @return [Array<MCPClient::Resource>] list of available resources
266
+ # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
267
+ def list_resources
268
+ @mutex.synchronize do
269
+ return @resources if @resources
270
+ end
271
+
272
+ begin
273
+ ensure_connected
274
+
275
+ resources_data = request_resources_list
276
+ @mutex.synchronize do
277
+ @resources = resources_data.map do |resource_data|
278
+ MCPClient::Resource.from_json(resource_data, server: self)
279
+ end
280
+ end
281
+
282
+ @mutex.synchronize { @resources }
283
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
284
+ # Re-raise these errors directly
285
+ raise
286
+ rescue StandardError => e
287
+ raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
288
+ end
289
+ end
290
+
291
+ # Read a resource by its URI
292
+ # @param uri [String] the URI of the resource to read
293
+ # @return [Object] the resource contents
294
+ # @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
295
+ def read_resource(uri)
296
+ rpc_request('resources/read', { uri: uri })
297
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
298
+ # Re-raise connection/transport errors directly
299
+ raise
300
+ rescue StandardError => e
301
+ # For all other errors, wrap in ResourceReadError
302
+ raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
303
+ end
304
+
193
305
  # Override apply_request_headers to add session and SSE headers for MCP protocol
194
306
  def apply_request_headers(req, request)
195
307
  super
@@ -238,28 +350,64 @@ module MCPClient
238
350
  end
239
351
 
240
352
  # Clean up the server connection
241
- # Properly closes HTTP connections and clears cached state
353
+ # Properly closes HTTP connections, stops threads, and clears cached state
242
354
  def cleanup
243
355
  @mutex.synchronize do
244
- # Attempt to terminate session before cleanup
245
- terminate_session if @session_id
356
+ return unless @connection_established || @initialized
246
357
 
358
+ @logger.info('Cleaning up Streamable HTTP connection')
359
+
360
+ # Mark connection as closed to stop reconnection attempts
247
361
  @connection_established = false
248
362
  @initialized = false
249
363
 
250
- @logger.debug('Cleaning up Streamable HTTP connection')
364
+ # Attempt to terminate session before cleanup
365
+ begin
366
+ terminate_session if @session_id
367
+ rescue StandardError => e
368
+ @logger.warn("Failed to terminate session: #{e.message}")
369
+ end
370
+
371
+ # Stop events thread gracefully
372
+ if @events_thread&.alive?
373
+ @logger.debug('Stopping events thread...')
374
+ @events_thread.kill
375
+ @events_thread.join(THREAD_JOIN_TIMEOUT)
376
+ end
377
+ @events_thread = nil
251
378
 
252
- # Close HTTP connection if it exists
379
+ # Clear connections and state
253
380
  @http_conn = nil
381
+ @events_connection = nil
254
382
  @session_id = nil
383
+ @last_event_id = nil
255
384
 
385
+ # Clear cached data
256
386
  @tools = nil
257
387
  @tools_data = nil
388
+ @prompts = nil
389
+ @prompts_data = nil
390
+ @resources = nil
391
+ @resources_data = nil
392
+ @buffer = ''
393
+
394
+ @logger.info('Cleanup completed')
258
395
  end
259
396
  end
260
397
 
261
398
  private
262
399
 
400
+ def perform_initialize
401
+ super
402
+ # Send initialized notification to acknowledge completion of initialization
403
+ notification = build_jsonrpc_notification('notifications/initialized', {})
404
+ begin
405
+ send_http_request(notification)
406
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
407
+ raise MCPClient::Errors::TransportError, "Failed to send initialized notification: #{e.message}"
408
+ end
409
+ end
410
+
263
411
  # Default options for server initialization
264
412
  # @return [Hash] Default options
265
413
  def default_options
@@ -327,5 +475,270 @@ module MCPClient
327
475
 
328
476
  raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
329
477
  end
478
+
479
+ # Request the prompts list using JSON-RPC
480
+ # @return [Array<Hash>] the prompts data
481
+ # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
482
+ def request_prompts_list
483
+ @mutex.synchronize do
484
+ return @prompts_data if @prompts_data
485
+ end
486
+
487
+ result = rpc_request('prompts/list')
488
+
489
+ if result.is_a?(Hash) && result['prompts']
490
+ @mutex.synchronize do
491
+ @prompts_data = result['prompts']
492
+ end
493
+ return @mutex.synchronize { @prompts_data.dup }
494
+ elsif result.is_a?(Array) || result
495
+ @mutex.synchronize do
496
+ @prompts_data = result
497
+ end
498
+ return @mutex.synchronize { @prompts_data.dup }
499
+ end
500
+
501
+ raise MCPClient::Errors::PromptGetError, 'Failed to get prompts list from JSON-RPC request'
502
+ end
503
+
504
+ # Request the resources list using JSON-RPC
505
+ # @return [Array<Hash>] the resources data
506
+ # @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
507
+ def request_resources_list
508
+ @mutex.synchronize do
509
+ return @resources_data if @resources_data
510
+ end
511
+
512
+ result = rpc_request('resources/list')
513
+
514
+ if result.is_a?(Hash) && result['resources']
515
+ @mutex.synchronize do
516
+ @resources_data = result['resources']
517
+ end
518
+ return @mutex.synchronize { @resources_data.dup }
519
+ elsif result.is_a?(Array) || result
520
+ @mutex.synchronize do
521
+ @resources_data = result
522
+ end
523
+ return @mutex.synchronize { @resources_data.dup }
524
+ end
525
+
526
+ raise MCPClient::Errors::ResourceReadError, 'Failed to get resources list from JSON-RPC request'
527
+ end
528
+
529
+ # Start the long-lived GET connection for server events
530
+ # Creates a separate thread to maintain SSE connection for server notifications
531
+ # @return [void]
532
+ def start_events_connection
533
+ return if @events_thread&.alive?
534
+
535
+ @logger.info('Starting SSE events connection thread')
536
+ @events_thread = Thread.new do
537
+ Thread.current.name = 'MCP-SSE-Events'
538
+ Thread.current.report_on_exception = false # We handle exceptions internally
539
+
540
+ begin
541
+ handle_events_connection
542
+ rescue StandardError => e
543
+ @logger.error("Events thread crashed: #{e.message}")
544
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
545
+ end
546
+ end
547
+ end
548
+
549
+ # Handle the events connection in a separate thread
550
+ # Maintains a persistent SSE connection for server notifications and ping/pong
551
+ # @return [void]
552
+ def handle_events_connection
553
+ reconnect_delay = SSE_RECONNECT_DELAY
554
+
555
+ loop do
556
+ # Create a Faraday connection specifically for SSE streaming
557
+ # Using net_http adapter for better streaming support
558
+ conn = Faraday.new(url: @base_url) do |f|
559
+ f.request :retry, max: 0 # No automatic retries for SSE stream
560
+ f.options.open_timeout = 10
561
+ f.options.timeout = SSE_CONNECTION_TIMEOUT
562
+ f.adapter :net_http do |http|
563
+ http.read_timeout = SSE_CONNECTION_TIMEOUT
564
+ http.open_timeout = 10
565
+ end
566
+ end
567
+
568
+ @logger.debug("Establishing SSE events connection to #{@endpoint}") if @logger.level <= Logger::DEBUG
569
+
570
+ response = conn.get(@endpoint) do |req|
571
+ apply_events_headers(req)
572
+
573
+ # Handle streaming response with on_data callback
574
+ req.options.on_data = proc do |chunk, _total_bytes|
575
+ if chunk && !chunk.empty?
576
+ @logger.debug("Received event chunk (#{chunk.bytesize} bytes)") if @logger.level <= Logger::DEBUG
577
+ process_event_chunk(chunk)
578
+ end
579
+ end
580
+ end
581
+
582
+ @logger.debug("Events connection completed with status: #{response.status}") if @logger.level <= Logger::DEBUG
583
+
584
+ # Connection closed normally, check if we should reconnect
585
+ break unless @mutex.synchronize { @connection_established }
586
+
587
+ @logger.info('Events connection closed, reconnecting...')
588
+ sleep reconnect_delay
589
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
590
+
591
+ # Intentional shutdown
592
+ rescue Net::ReadTimeout, Faraday::TimeoutError
593
+ # Timeout after inactivity - this is expected for long-lived connections
594
+ break unless @mutex.synchronize { @connection_established }
595
+
596
+ @logger.debug('Events connection timed out after inactivity, reconnecting...')
597
+ sleep reconnect_delay
598
+ rescue Faraday::ConnectionFailed => e
599
+ break unless @mutex.synchronize { @connection_established }
600
+
601
+ @logger.warn("Events connection failed: #{e.message}, retrying in #{reconnect_delay}s...")
602
+ sleep reconnect_delay
603
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
604
+ rescue StandardError => e
605
+ break unless @mutex.synchronize { @connection_established }
606
+
607
+ @logger.error("Unexpected error in events connection: #{e.class} - #{e.message}")
608
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
609
+ sleep reconnect_delay
610
+ reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
611
+ end
612
+ ensure
613
+ @logger.info('Events connection thread terminated')
614
+ end
615
+
616
+ # Apply headers for events connection
617
+ # @param req [Faraday::Request] HTTP request
618
+ def apply_events_headers(req)
619
+ @headers.each { |k, v| req.headers[k] = v }
620
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
621
+ end
622
+
623
+ # Process event chunks from the server
624
+ # Buffers partial chunks and processes complete SSE events
625
+ # @param chunk [String] the chunk to process
626
+ def process_event_chunk(chunk)
627
+ @logger.debug("Processing event chunk: #{chunk.inspect}") if @logger.level <= Logger::DEBUG
628
+
629
+ @mutex.synchronize do
630
+ @buffer += chunk
631
+
632
+ # Extract complete events (SSE format: events end with double newline)
633
+ while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
634
+ event_data = extract_event(event_end)
635
+ parse_and_handle_event(event_data)
636
+ end
637
+ end
638
+ rescue StandardError => e
639
+ @logger.error("Error processing event chunk: #{e.message}")
640
+ @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
641
+ end
642
+
643
+ # Extract a single event from the buffer
644
+ # @param event_end [Integer] the position where the event ends
645
+ # @return [String] the extracted event data
646
+ def extract_event(event_end)
647
+ # Determine the line ending style and extract accordingly
648
+ crlf_index = @buffer.index("\r\n\r\n")
649
+ lf_index = @buffer.index("\n\n")
650
+ if crlf_index && (lf_index.nil? || crlf_index < lf_index)
651
+ @buffer.slice!(0, event_end + 4) # \r\n\r\n is 4 chars
652
+ else
653
+ @buffer.slice!(0, event_end + 2) # \n\n is 2 chars
654
+ end
655
+ end
656
+
657
+ # Parse and handle an SSE event
658
+ # Parses SSE format according to the W3C specification
659
+ # @param event_data [String] the raw event data
660
+ def parse_and_handle_event(event_data)
661
+ event = { event: 'message', data: '', id: nil }
662
+ data_lines = []
663
+
664
+ event_data.each_line do |line|
665
+ line = line.chomp
666
+ next if line.empty? || line.start_with?(':') # Skip empty lines and comments
667
+
668
+ if line.start_with?('event:')
669
+ event[:event] = line[6..].strip
670
+ elsif line.start_with?('data:')
671
+ # SSE allows multiple data lines that should be joined with newlines
672
+ data_lines << line[5..].strip
673
+ elsif line.start_with?('id:')
674
+ # Track event ID for resumability (MCP future enhancement)
675
+ event[:id] = line[3..].strip
676
+ @last_event_id = event[:id]
677
+ elsif line.start_with?('retry:')
678
+ # Server can suggest reconnection delay (in milliseconds)
679
+ retry_ms = line[6..].strip.to_i
680
+ @logger.debug("Server suggested retry delay: #{retry_ms}ms") if @logger.level <= Logger::DEBUG
681
+ end
682
+ end
683
+
684
+ event[:data] = data_lines.join("\n")
685
+
686
+ # Only process non-empty data
687
+ handle_server_message(event[:data]) unless event[:data].empty?
688
+ end
689
+
690
+ # Handle server messages (notifications and requests)
691
+ # Processes ping/pong keepalive and server notifications
692
+ # @param data [String] the JSON data from SSE event
693
+ def handle_server_message(data)
694
+ return if data.empty?
695
+
696
+ begin
697
+ message = JSON.parse(data)
698
+
699
+ # Handle ping requests from server (keepalive mechanism)
700
+ if message['method'] == 'ping' && message.key?('id')
701
+ handle_ping_request(message['id'])
702
+ elsif message['method'] && !message.key?('id')
703
+ # Handle server notifications (messages without id)
704
+ @notification_callback&.call(message['method'], message['params'])
705
+ elsif message.key?('id')
706
+ # This might be a server-to-client request (future MCP versions)
707
+ @logger.warn("Received unhandled server request: #{message['method']}")
708
+ end
709
+ rescue JSON::ParserError => e
710
+ @logger.error("Invalid JSON in server message: #{e.message}")
711
+ @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
712
+ end
713
+ end
714
+
715
+ # Handle ping request from server
716
+ # Sends pong response to maintain session keepalive
717
+ # @param ping_id [Integer, String] the ping request ID
718
+ def handle_ping_request(ping_id)
719
+ pong_response = {
720
+ jsonrpc: '2.0',
721
+ id: ping_id,
722
+ result: {}
723
+ }
724
+
725
+ # Send pong response in a separate thread to avoid blocking event processing
726
+ Thread.new do
727
+ conn = http_connection
728
+ response = conn.post(@endpoint) do |req|
729
+ @headers.each { |k, v| req.headers[k] = v }
730
+ req.headers['Mcp-Session-Id'] = @session_id if @session_id
731
+ req.body = pong_response.to_json
732
+ end
733
+
734
+ if response.success?
735
+ @logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
736
+ else
737
+ @logger.warn("Failed to send pong response: HTTP #{response.status}")
738
+ end
739
+ rescue StandardError => e
740
+ @logger.error("Failed to send pong response: #{e.message}")
741
+ end
742
+ end
330
743
  end
331
744
  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.8.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
8
  PROTOCOL_VERSION = '2025-03-26'
data/lib/mcp_client.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # Load all MCPClient components
4
4
  require_relative 'mcp_client/errors'
5
5
  require_relative 'mcp_client/tool'
6
+ require_relative 'mcp_client/prompt'
7
+ require_relative 'mcp_client/resource'
6
8
  require_relative 'mcp_client/server_base'
7
9
  require_relative 'mcp_client/server_stdio'
8
10
  require_relative 'mcp_client/server_sse'
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.8.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-07-14 00:00:00.000000000 Z
11
+ date: 2025-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -127,6 +127,8 @@ files:
127
127
  - lib/mcp_client/http_transport_base.rb
128
128
  - lib/mcp_client/json_rpc_common.rb
129
129
  - lib/mcp_client/oauth_client.rb
130
+ - lib/mcp_client/prompt.rb
131
+ - lib/mcp_client/resource.rb
130
132
  - lib/mcp_client/server_base.rb
131
133
  - lib/mcp_client/server_factory.rb
132
134
  - lib/mcp_client/server_http.rb