ruby-mcp-client 0.8.1 → 0.9.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 +345 -3
- data/lib/mcp_client/auth/browser_oauth.rb +424 -0
- data/lib/mcp_client/auth/oauth_provider.rb +131 -19
- data/lib/mcp_client/auth.rb +1 -1
- data/lib/mcp_client/client.rb +73 -1
- data/lib/mcp_client/json_rpc_common.rb +3 -1
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +8 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +11 -0
- data/lib/mcp_client/server_sse.rb +126 -0
- data/lib/mcp_client/server_stdio.rb +100 -1
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +8 -1
- data/lib/mcp_client/server_streamable_http.rb +132 -6
- data/lib/mcp_client/tool.rb +40 -4
- data/lib/mcp_client/version.rb +2 -2
- metadata +3 -2
data/lib/mcp_client/client.rb
CHANGED
|
@@ -21,7 +21,8 @@ module MCPClient
|
|
|
21
21
|
# Initialize a new MCPClient::Client
|
|
22
22
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
|
23
23
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
24
|
-
|
|
24
|
+
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
25
|
+
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil)
|
|
25
26
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
26
27
|
@logger.progname = self.class.name
|
|
27
28
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
|
@@ -34,6 +35,8 @@ module MCPClient
|
|
|
34
35
|
@resource_cache = {}
|
|
35
36
|
# JSON-RPC notification listeners
|
|
36
37
|
@notification_listeners = []
|
|
38
|
+
# Elicitation handler (MCP 2025-06-18)
|
|
39
|
+
@elicitation_handler = elicitation_handler
|
|
37
40
|
# Register default and user-defined notification handlers on each server
|
|
38
41
|
@servers.each do |server|
|
|
39
42
|
server.on_notification do |method, params|
|
|
@@ -42,6 +45,10 @@ module MCPClient
|
|
|
42
45
|
# Invoke user-defined listeners
|
|
43
46
|
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
|
44
47
|
end
|
|
48
|
+
# Register elicitation handler on each server
|
|
49
|
+
if server.respond_to?(:on_elicitation_request)
|
|
50
|
+
server.on_elicitation_request(&method(:handle_elicitation_request))
|
|
51
|
+
end
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -598,5 +605,70 @@ module MCPClient
|
|
|
598
605
|
"Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
|
|
599
606
|
end
|
|
600
607
|
end
|
|
608
|
+
|
|
609
|
+
# Handle elicitation request from server (MCP 2025-06-18)
|
|
610
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
611
|
+
# @param params [Hash] the elicitation parameters
|
|
612
|
+
# @return [Hash] the elicitation response
|
|
613
|
+
def handle_elicitation_request(_request_id, params)
|
|
614
|
+
# If no handler is configured, decline the request
|
|
615
|
+
unless @elicitation_handler
|
|
616
|
+
@logger.warn('Received elicitation request but no handler configured, declining')
|
|
617
|
+
return { 'action' => 'decline' }
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
message = params['message']
|
|
621
|
+
schema = params['schema'] || params['requestedSchema']
|
|
622
|
+
metadata = params['metadata']
|
|
623
|
+
|
|
624
|
+
begin
|
|
625
|
+
# Call the user-defined handler
|
|
626
|
+
result = case @elicitation_handler.arity
|
|
627
|
+
when 0
|
|
628
|
+
@elicitation_handler.call
|
|
629
|
+
when 1
|
|
630
|
+
@elicitation_handler.call(message)
|
|
631
|
+
when 2, -1
|
|
632
|
+
@elicitation_handler.call(message, schema)
|
|
633
|
+
else
|
|
634
|
+
@elicitation_handler.call(message, schema, metadata)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Validate and format response
|
|
638
|
+
case result
|
|
639
|
+
when Hash
|
|
640
|
+
if result['action']
|
|
641
|
+
normalised_action_response(result)
|
|
642
|
+
elsif result[:action]
|
|
643
|
+
# Convert symbol keys to strings
|
|
644
|
+
{
|
|
645
|
+
'action' => result[:action].to_s,
|
|
646
|
+
'content' => result[:content]
|
|
647
|
+
}.compact.then { |payload| normalised_action_response(payload) }
|
|
648
|
+
else
|
|
649
|
+
# Assume it's content for an accept action
|
|
650
|
+
{ 'action' => 'accept', 'content' => result }
|
|
651
|
+
end
|
|
652
|
+
when nil
|
|
653
|
+
{ 'action' => 'cancel' }
|
|
654
|
+
else
|
|
655
|
+
{ 'action' => 'accept', 'content' => result }
|
|
656
|
+
end
|
|
657
|
+
rescue StandardError => e
|
|
658
|
+
@logger.error("Elicitation handler error: #{e.message}")
|
|
659
|
+
@logger.debug(e.backtrace.join("\n"))
|
|
660
|
+
{ 'action' => 'decline' }
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
665
|
+
# Falls back to accept for unknown action values.
|
|
666
|
+
def normalised_action_response(result)
|
|
667
|
+
action = result['action']
|
|
668
|
+
return result if %w[accept decline cancel].include?(action)
|
|
669
|
+
|
|
670
|
+
@logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
|
|
671
|
+
result.merge('action' => 'accept')
|
|
672
|
+
end
|
|
601
673
|
end
|
|
602
674
|
end
|
|
@@ -64,7 +64,9 @@ module MCPClient
|
|
|
64
64
|
def initialization_params
|
|
65
65
|
{
|
|
66
66
|
'protocolVersion' => MCPClient::PROTOCOL_VERSION,
|
|
67
|
-
'capabilities' => {
|
|
67
|
+
'capabilities' => {
|
|
68
|
+
'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
|
|
69
|
+
},
|
|
68
70
|
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
|
69
71
|
}
|
|
70
72
|
end
|
|
@@ -76,7 +76,8 @@ module MCPClient
|
|
|
76
76
|
retries: config[:retries] || 3,
|
|
77
77
|
retry_backoff: config[:retry_backoff] || 1,
|
|
78
78
|
name: config[:name],
|
|
79
|
-
logger: logger
|
|
79
|
+
logger: logger,
|
|
80
|
+
oauth_provider: config[:oauth_provider]
|
|
80
81
|
)
|
|
81
82
|
end
|
|
82
83
|
|
|
@@ -95,7 +96,8 @@ module MCPClient
|
|
|
95
96
|
retries: config[:retries] || 3,
|
|
96
97
|
retry_backoff: config[:retry_backoff] || 1,
|
|
97
98
|
name: config[:name],
|
|
98
|
-
logger: logger
|
|
99
|
+
logger: logger,
|
|
100
|
+
oauth_provider: config[:oauth_provider]
|
|
99
101
|
)
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -12,6 +12,14 @@ module MCPClient
|
|
|
12
12
|
# Implementation of MCP server that communicates via HTTP requests/responses
|
|
13
13
|
# Useful for communicating with MCP servers that support HTTP-based transport
|
|
14
14
|
# without Server-Sent Events streaming
|
|
15
|
+
#
|
|
16
|
+
# @note Elicitation Support (MCP 2025-06-18)
|
|
17
|
+
# This transport does NOT support server-initiated elicitation requests.
|
|
18
|
+
# The HTTP transport uses a pure request-response architecture where only the client
|
|
19
|
+
# can initiate requests. For elicitation support, use one of these transports instead:
|
|
20
|
+
# - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
|
|
21
|
+
# - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
|
|
22
|
+
# - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
|
|
15
23
|
class ServerHTTP < ServerBase
|
|
16
24
|
require_relative 'server_http/json_rpc_transport'
|
|
17
25
|
|
|
@@ -31,6 +31,7 @@ module MCPClient
|
|
|
31
31
|
data = JSON.parse(event[:data])
|
|
32
32
|
|
|
33
33
|
return if process_error_in_message(data)
|
|
34
|
+
return if process_server_request?(data)
|
|
34
35
|
return if process_notification?(data)
|
|
35
36
|
|
|
36
37
|
process_response?(data)
|
|
@@ -58,6 +59,16 @@ module MCPClient
|
|
|
58
59
|
true
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
# Process a JSON-RPC request from server (has both id AND method)
|
|
63
|
+
# @param data [Hash] the parsed JSON payload
|
|
64
|
+
# @return [Boolean] true if we saw & handled a server request
|
|
65
|
+
def process_server_request?(data)
|
|
66
|
+
return false unless data['method'] && data.key?('id')
|
|
67
|
+
|
|
68
|
+
handle_server_request(data)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
61
72
|
# Process a JSON-RPC notification (no id => notification)
|
|
62
73
|
# @param data [Hash] the parsed JSON payload
|
|
63
74
|
# @return [Boolean] true if we saw & handled a notification
|
|
@@ -11,6 +11,12 @@ require 'faraday/follow_redirects'
|
|
|
11
11
|
module MCPClient
|
|
12
12
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
|
13
13
|
# Useful for communicating with remote MCP servers over HTTP
|
|
14
|
+
#
|
|
15
|
+
# @note Elicitation Support (MCP 2025-06-18)
|
|
16
|
+
# This transport FULLY supports server-initiated elicitation requests via bidirectional
|
|
17
|
+
# JSON-RPC. The server sends elicitation/create requests via the SSE stream, and the
|
|
18
|
+
# client responds via HTTP POST to the RPC endpoint. This provides full elicitation
|
|
19
|
+
# capability for remote servers.
|
|
14
20
|
class ServerSSE < ServerBase
|
|
15
21
|
require_relative 'server_sse/sse_parser'
|
|
16
22
|
require_relative 'server_sse/json_rpc_transport'
|
|
@@ -98,6 +104,7 @@ module MCPClient
|
|
|
98
104
|
# Time of last activity
|
|
99
105
|
@last_activity_time = Time.now
|
|
100
106
|
@activity_timer_thread = nil
|
|
107
|
+
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Stream tool call fallback for SSE transport (yields single result)
|
|
@@ -409,6 +416,125 @@ module MCPClient
|
|
|
409
416
|
end
|
|
410
417
|
end
|
|
411
418
|
|
|
419
|
+
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
420
|
+
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
421
|
+
# @return [void]
|
|
422
|
+
def on_elicitation_request(&block)
|
|
423
|
+
@elicitation_request_callback = block
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
427
|
+
# @param msg [Hash] the JSON-RPC request message
|
|
428
|
+
# @return [void]
|
|
429
|
+
def handle_server_request(msg)
|
|
430
|
+
request_id = msg['id']
|
|
431
|
+
method = msg['method']
|
|
432
|
+
params = msg['params'] || {}
|
|
433
|
+
|
|
434
|
+
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
435
|
+
|
|
436
|
+
case method
|
|
437
|
+
when 'elicitation/create'
|
|
438
|
+
handle_elicitation_create(request_id, params)
|
|
439
|
+
else
|
|
440
|
+
# Unknown request method, send error response
|
|
441
|
+
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
442
|
+
end
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
@logger.error("Error handling server request: #{e.message}")
|
|
445
|
+
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
449
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
450
|
+
# @param params [Hash] the elicitation parameters
|
|
451
|
+
# @return [void]
|
|
452
|
+
def handle_elicitation_create(request_id, params)
|
|
453
|
+
# If no callback is registered, decline the request
|
|
454
|
+
unless @elicitation_request_callback
|
|
455
|
+
@logger.warn('Received elicitation request but no callback registered, declining')
|
|
456
|
+
send_elicitation_response(request_id, { 'action' => 'decline' })
|
|
457
|
+
return
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Call the registered callback
|
|
461
|
+
result = @elicitation_request_callback.call(request_id, params)
|
|
462
|
+
|
|
463
|
+
# Send the response back to the server
|
|
464
|
+
send_elicitation_response(request_id, result)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
|
|
468
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
469
|
+
# @param result [Hash] the elicitation result (action and optional content)
|
|
470
|
+
# @return [void]
|
|
471
|
+
def send_elicitation_response(request_id, result)
|
|
472
|
+
ensure_initialized
|
|
473
|
+
|
|
474
|
+
response = {
|
|
475
|
+
'jsonrpc' => '2.0',
|
|
476
|
+
'id' => request_id,
|
|
477
|
+
'result' => result
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
# Send response via HTTP POST to the RPC endpoint
|
|
481
|
+
post_jsonrpc_response(response)
|
|
482
|
+
rescue StandardError => e
|
|
483
|
+
@logger.error("Error sending elicitation response: #{e.message}")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Send error response back to server via HTTP POST (MCP 2025-06-18)
|
|
487
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
488
|
+
# @param code [Integer] the error code
|
|
489
|
+
# @param message [String] the error message
|
|
490
|
+
# @return [void]
|
|
491
|
+
def send_error_response(request_id, code, message)
|
|
492
|
+
ensure_initialized
|
|
493
|
+
|
|
494
|
+
response = {
|
|
495
|
+
'jsonrpc' => '2.0',
|
|
496
|
+
'id' => request_id,
|
|
497
|
+
'error' => {
|
|
498
|
+
'code' => code,
|
|
499
|
+
'message' => message
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# Send response via HTTP POST to the RPC endpoint
|
|
504
|
+
post_jsonrpc_response(response)
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
@logger.error("Error sending error response: #{e.message}")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Post a JSON-RPC response message to the server via HTTP
|
|
510
|
+
# @param response [Hash] the JSON-RPC response
|
|
511
|
+
# @return [void]
|
|
512
|
+
# @private
|
|
513
|
+
def post_jsonrpc_response(response)
|
|
514
|
+
unless @rpc_endpoint
|
|
515
|
+
@logger.error('Cannot send response: RPC endpoint not available')
|
|
516
|
+
return
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Use the same connection pattern as post_json_rpc_request
|
|
520
|
+
uri = URI.parse(@base_url)
|
|
521
|
+
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
522
|
+
@rpc_conn ||= create_json_rpc_connection(base)
|
|
523
|
+
|
|
524
|
+
json_body = JSON.generate(response)
|
|
525
|
+
|
|
526
|
+
@rpc_conn.post do |req|
|
|
527
|
+
req.url @rpc_endpoint
|
|
528
|
+
req.headers['Content-Type'] = 'application/json'
|
|
529
|
+
@headers.each { |k, v| req.headers[k] = v }
|
|
530
|
+
req.body = json_body
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
@logger.debug("Sent response via HTTP POST: #{json_body}")
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
@logger.error("Failed to send response via HTTP POST: #{e.message}")
|
|
536
|
+
end
|
|
537
|
+
|
|
412
538
|
private
|
|
413
539
|
|
|
414
540
|
# Start the SSE thread to listen for events
|
|
@@ -46,6 +46,7 @@ module MCPClient
|
|
|
46
46
|
@retry_backoff = retry_backoff
|
|
47
47
|
@read_timeout = read_timeout
|
|
48
48
|
@env = env || {}
|
|
49
|
+
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
# Server info from the initialize response
|
|
@@ -95,12 +96,20 @@ module MCPClient
|
|
|
95
96
|
def handle_line(line)
|
|
96
97
|
msg = JSON.parse(line)
|
|
97
98
|
@logger.debug("Received line: #{line.chomp}")
|
|
99
|
+
|
|
100
|
+
# Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
|
|
101
|
+
if msg['method'] && msg.key?('id')
|
|
102
|
+
handle_server_request(msg)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
98
106
|
# Dispatch JSON-RPC notifications (no id, has method)
|
|
99
107
|
if msg['method'] && !msg.key?('id')
|
|
100
108
|
@notification_callback&.call(msg['method'], msg['params'])
|
|
101
109
|
return
|
|
102
110
|
end
|
|
103
|
-
|
|
111
|
+
|
|
112
|
+
# Handle standard JSON-RPC responses (has id, no method)
|
|
104
113
|
id = msg['id']
|
|
105
114
|
return unless id
|
|
106
115
|
|
|
@@ -331,6 +340,96 @@ module MCPClient
|
|
|
331
340
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
332
341
|
end
|
|
333
342
|
|
|
343
|
+
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
344
|
+
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
345
|
+
# @return [void]
|
|
346
|
+
def on_elicitation_request(&block)
|
|
347
|
+
@elicitation_request_callback = block
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
351
|
+
# @param msg [Hash] the JSON-RPC request message
|
|
352
|
+
# @return [void]
|
|
353
|
+
def handle_server_request(msg)
|
|
354
|
+
request_id = msg['id']
|
|
355
|
+
method = msg['method']
|
|
356
|
+
params = msg['params'] || {}
|
|
357
|
+
|
|
358
|
+
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
359
|
+
|
|
360
|
+
case method
|
|
361
|
+
when 'elicitation/create'
|
|
362
|
+
handle_elicitation_create(request_id, params)
|
|
363
|
+
else
|
|
364
|
+
# Unknown request method, send error response
|
|
365
|
+
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
366
|
+
end
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
@logger.error("Error handling server request: #{e.message}")
|
|
369
|
+
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
373
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
374
|
+
# @param params [Hash] the elicitation parameters
|
|
375
|
+
# @return [void]
|
|
376
|
+
def handle_elicitation_create(request_id, params)
|
|
377
|
+
# If no callback is registered, decline the request
|
|
378
|
+
unless @elicitation_request_callback
|
|
379
|
+
@logger.warn('Received elicitation request but no callback registered, declining')
|
|
380
|
+
send_elicitation_response(request_id, { 'action' => 'decline' })
|
|
381
|
+
return
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Call the registered callback
|
|
385
|
+
result = @elicitation_request_callback.call(request_id, params)
|
|
386
|
+
|
|
387
|
+
# Send the response back to the server
|
|
388
|
+
send_elicitation_response(request_id, result)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Send elicitation response back to server (MCP 2025-06-18)
|
|
392
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
393
|
+
# @param result [Hash] the elicitation result (action and optional content)
|
|
394
|
+
# @return [void]
|
|
395
|
+
def send_elicitation_response(request_id, result)
|
|
396
|
+
response = {
|
|
397
|
+
'jsonrpc' => '2.0',
|
|
398
|
+
'id' => request_id,
|
|
399
|
+
'result' => result
|
|
400
|
+
}
|
|
401
|
+
send_message(response)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Send error response back to server (MCP 2025-06-18)
|
|
405
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
406
|
+
# @param code [Integer] the error code
|
|
407
|
+
# @param message [String] the error message
|
|
408
|
+
# @return [void]
|
|
409
|
+
def send_error_response(request_id, code, message)
|
|
410
|
+
response = {
|
|
411
|
+
'jsonrpc' => '2.0',
|
|
412
|
+
'id' => request_id,
|
|
413
|
+
'error' => {
|
|
414
|
+
'code' => code,
|
|
415
|
+
'message' => message
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
send_message(response)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Send a JSON-RPC message to the server
|
|
422
|
+
# @param message [Hash] the message to send
|
|
423
|
+
# @return [void]
|
|
424
|
+
def send_message(message)
|
|
425
|
+
json = JSON.generate(message)
|
|
426
|
+
@stdin.puts(json)
|
|
427
|
+
@stdin.flush
|
|
428
|
+
@logger.debug("Sent message: #{json}")
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
@logger.error("Error sending message: #{e.message}")
|
|
431
|
+
end
|
|
432
|
+
|
|
334
433
|
# Clean up the server connection
|
|
335
434
|
# Closes all stdio handles and terminates any running processes and threads
|
|
336
435
|
# @return [void]
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../http_transport_base'
|
|
4
4
|
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require 'stringio'
|
|
7
|
+
|
|
5
8
|
module MCPClient
|
|
6
9
|
class ServerStreamableHTTP
|
|
7
10
|
# JSON-RPC request/notification plumbing for Streamable HTTP transport
|
|
@@ -23,8 +26,12 @@ module MCPClient
|
|
|
23
26
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
24
27
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
25
28
|
def parse_response(response)
|
|
26
|
-
body = response.body
|
|
29
|
+
body = response.body
|
|
27
30
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
31
|
+
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
32
|
+
|
|
33
|
+
body = Zlib::GzipReader.new(StringIO.new(body)).read if content_encoding.include?('gzip')
|
|
34
|
+
body = body&.strip
|
|
28
35
|
|
|
29
36
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
30
37
|
data = if content_type.include?('text/event-stream')
|
|
@@ -9,9 +9,9 @@ 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 (MCP 2025-
|
|
12
|
+
# Implementation of MCP server that communicates via Streamable HTTP transport (MCP 2025-06-18)
|
|
13
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-
|
|
14
|
+
# Compliant with MCP specification version 2025-06-18
|
|
15
15
|
#
|
|
16
16
|
# Key features:
|
|
17
17
|
# - Supports server-sent events (SSE) for real-time notifications
|
|
@@ -91,7 +91,7 @@ module MCPClient
|
|
|
91
91
|
@headers = opts[:headers].merge({
|
|
92
92
|
'Content-Type' => 'application/json',
|
|
93
93
|
'Accept' => 'text/event-stream, application/json',
|
|
94
|
-
'Accept-Encoding' => 'gzip
|
|
94
|
+
'Accept-Encoding' => 'gzip',
|
|
95
95
|
'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}",
|
|
96
96
|
'Cache-Control' => 'no-cache'
|
|
97
97
|
})
|
|
@@ -116,6 +116,7 @@ module MCPClient
|
|
|
116
116
|
@events_connection = nil
|
|
117
117
|
@events_thread = nil
|
|
118
118
|
@buffer = '' # Buffer for partial SSE event data
|
|
119
|
+
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
# Connect to the MCP server over Streamable HTTP
|
|
@@ -451,6 +452,13 @@ module MCPClient
|
|
|
451
452
|
end
|
|
452
453
|
end
|
|
453
454
|
|
|
455
|
+
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
456
|
+
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
457
|
+
# @return [void]
|
|
458
|
+
def on_elicitation_request(&block)
|
|
459
|
+
@elicitation_request_callback = block
|
|
460
|
+
end
|
|
461
|
+
|
|
454
462
|
private
|
|
455
463
|
|
|
456
464
|
def perform_initialize
|
|
@@ -755,12 +763,12 @@ module MCPClient
|
|
|
755
763
|
# Handle ping requests from server (keepalive mechanism)
|
|
756
764
|
if message['method'] == 'ping' && message.key?('id')
|
|
757
765
|
handle_ping_request(message['id'])
|
|
766
|
+
elsif message['method'] && message.key?('id')
|
|
767
|
+
# Handle server-to-client requests (MCP 2025-06-18)
|
|
768
|
+
handle_server_request(message)
|
|
758
769
|
elsif message['method'] && !message.key?('id')
|
|
759
770
|
# Handle server notifications (messages without id)
|
|
760
771
|
@notification_callback&.call(message['method'], message['params'])
|
|
761
|
-
elsif message.key?('id')
|
|
762
|
-
# This might be a server-to-client request (future MCP versions)
|
|
763
|
-
@logger.warn("Received unhandled server request: #{message['method']}")
|
|
764
772
|
end
|
|
765
773
|
rescue JSON::ParserError => e
|
|
766
774
|
@logger.error("Invalid JSON in server message: #{e.message}")
|
|
@@ -796,5 +804,123 @@ module MCPClient
|
|
|
796
804
|
@logger.error("Failed to send pong response: #{e.message}")
|
|
797
805
|
end
|
|
798
806
|
end
|
|
807
|
+
|
|
808
|
+
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
809
|
+
# @param msg [Hash] the JSON-RPC request message
|
|
810
|
+
# @return [void]
|
|
811
|
+
def handle_server_request(msg)
|
|
812
|
+
request_id = msg['id']
|
|
813
|
+
method = msg['method']
|
|
814
|
+
params = msg['params'] || {}
|
|
815
|
+
|
|
816
|
+
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
817
|
+
|
|
818
|
+
case method
|
|
819
|
+
when 'elicitation/create'
|
|
820
|
+
handle_elicitation_create(request_id, params)
|
|
821
|
+
else
|
|
822
|
+
# Unknown request method, send error response
|
|
823
|
+
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
824
|
+
end
|
|
825
|
+
rescue StandardError => e
|
|
826
|
+
@logger.error("Error handling server request: #{e.message}")
|
|
827
|
+
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
831
|
+
# @param request_id [String, Integer] the JSON-RPC request ID (used as elicitationId)
|
|
832
|
+
# @param params [Hash] the elicitation parameters
|
|
833
|
+
# @return [void]
|
|
834
|
+
def handle_elicitation_create(request_id, params)
|
|
835
|
+
# The request_id is the elicitationId per MCP spec
|
|
836
|
+
elicitation_id = request_id
|
|
837
|
+
|
|
838
|
+
# If no callback is registered, decline the request
|
|
839
|
+
unless @elicitation_request_callback
|
|
840
|
+
@logger.warn('Received elicitation request but no callback registered, declining')
|
|
841
|
+
send_elicitation_response(elicitation_id, { 'action' => 'decline' })
|
|
842
|
+
return
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Call the registered callback
|
|
846
|
+
result = @elicitation_request_callback.call(request_id, params)
|
|
847
|
+
|
|
848
|
+
# Send the response back to the server
|
|
849
|
+
send_elicitation_response(elicitation_id, result)
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
|
|
853
|
+
# For streamable HTTP, this is sent as a JSON-RPC request (not response)
|
|
854
|
+
# because HTTP is unidirectional.
|
|
855
|
+
# @param elicitation_id [String] the elicitation ID from the server
|
|
856
|
+
# @param result [Hash] the elicitation result (action and optional content)
|
|
857
|
+
# @return [void]
|
|
858
|
+
def send_elicitation_response(elicitation_id, result)
|
|
859
|
+
params = {
|
|
860
|
+
'elicitationId' => elicitation_id,
|
|
861
|
+
'action' => result['action']
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
# Only include content if present (typically for 'accept' action)
|
|
865
|
+
params['content'] = result['content'] if result['content']
|
|
866
|
+
|
|
867
|
+
request = {
|
|
868
|
+
'jsonrpc' => '2.0',
|
|
869
|
+
'method' => 'elicitation/response',
|
|
870
|
+
'params' => params
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
# Send as a JSON-RPC request via HTTP POST
|
|
874
|
+
post_jsonrpc_response(request)
|
|
875
|
+
rescue StandardError => e
|
|
876
|
+
@logger.error("Error sending elicitation response: #{e.message}")
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Send error response back to server via HTTP POST (MCP 2025-06-18)
|
|
880
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
881
|
+
# @param code [Integer] the error code
|
|
882
|
+
# @param message [String] the error message
|
|
883
|
+
# @return [void]
|
|
884
|
+
def send_error_response(request_id, code, message)
|
|
885
|
+
response = {
|
|
886
|
+
'jsonrpc' => '2.0',
|
|
887
|
+
'id' => request_id,
|
|
888
|
+
'error' => {
|
|
889
|
+
'code' => code,
|
|
890
|
+
'message' => message
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
# Send response via HTTP POST to the endpoint
|
|
895
|
+
post_jsonrpc_response(response)
|
|
896
|
+
rescue StandardError => e
|
|
897
|
+
@logger.error("Error sending error response: #{e.message}")
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# Post a JSON-RPC response message to the server via HTTP
|
|
901
|
+
# @param response [Hash] the JSON-RPC response
|
|
902
|
+
# @return [void]
|
|
903
|
+
# @private
|
|
904
|
+
def post_jsonrpc_response(response)
|
|
905
|
+
# Send response in a separate thread to avoid blocking event processing
|
|
906
|
+
Thread.new do
|
|
907
|
+
conn = http_connection
|
|
908
|
+
json_body = JSON.generate(response)
|
|
909
|
+
|
|
910
|
+
resp = conn.post(@endpoint) do |req|
|
|
911
|
+
@headers.each { |k, v| req.headers[k] = v }
|
|
912
|
+
req.headers['Mcp-Session-Id'] = @session_id if @session_id
|
|
913
|
+
req.body = json_body
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
if resp.success?
|
|
917
|
+
@logger.debug("Sent JSON-RPC response: #{json_body}")
|
|
918
|
+
else
|
|
919
|
+
@logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
|
|
920
|
+
end
|
|
921
|
+
rescue StandardError => e
|
|
922
|
+
@logger.error("Failed to send JSON-RPC response: #{e.message}")
|
|
923
|
+
end
|
|
924
|
+
end
|
|
799
925
|
end
|
|
800
926
|
end
|