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.
- checksums.yaml +4 -4
- data/README.md +128 -28
- data/lib/mcp_client/client.rb +182 -5
- data/lib/mcp_client/errors.rb +18 -0
- data/lib/mcp_client/prompt.rb +41 -0
- data/lib/mcp_client/resource.rb +61 -0
- data/lib/mcp_client/server_base.rb +27 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +1 -0
- data/lib/mcp_client/server_sse.rb +157 -1
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -0
- data/lib/mcp_client/server_stdio.rb +91 -0
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +26 -12
- data/lib/mcp_client/server_streamable_http.rb +422 -9
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +2 -0
- metadata +4 -2
@@ -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
|
14
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
#
|
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
|
data/lib/mcp_client/version.rb
CHANGED
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.
|
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-
|
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
|