ruby-mcp-client 0.9.0 → 1.0.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 +228 -1227
- data/lib/mcp_client/audio_content.rb +46 -0
- data/lib/mcp_client/client.rb +432 -37
- data/lib/mcp_client/elicitation_validator.rb +262 -0
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +9 -1
- data/lib/mcp_client/json_rpc_common.rb +7 -3
- data/lib/mcp_client/resource.rb +8 -0
- data/lib/mcp_client/resource_link.rb +63 -0
- data/lib/mcp_client/root.rb +63 -0
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +46 -6
- data/lib/mcp_client/server_sse.rb +133 -5
- data/lib/mcp_client/server_stdio.rb +143 -0
- data/lib/mcp_client/server_streamable_http.rb +148 -5
- data/lib/mcp_client/task.rb +127 -0
- data/lib/mcp_client/tool.rb +73 -9
- data/lib/mcp_client/version.rb +2 -2
- data/lib/mcp_client.rb +344 -4
- metadata +9 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Representation of MCP audio content (MCP 2025-11-25)
|
|
5
|
+
# Used for base64-encoded audio data in messages and tool results
|
|
6
|
+
class AudioContent
|
|
7
|
+
# @!attribute [r] data
|
|
8
|
+
# @return [String] base64-encoded audio data
|
|
9
|
+
# @!attribute [r] mime_type
|
|
10
|
+
# @return [String] MIME type of the audio (e.g., 'audio/wav', 'audio/mpeg', 'audio/ogg')
|
|
11
|
+
# @!attribute [r] annotations
|
|
12
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
|
13
|
+
attr_reader :data, :mime_type, :annotations
|
|
14
|
+
|
|
15
|
+
# Initialize audio content
|
|
16
|
+
# @param data [String] base64-encoded audio data
|
|
17
|
+
# @param mime_type [String] MIME type of the audio
|
|
18
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
|
19
|
+
def initialize(data:, mime_type:, annotations: nil)
|
|
20
|
+
raise ArgumentError, 'AudioContent requires data' if data.nil? || data.empty?
|
|
21
|
+
raise ArgumentError, 'AudioContent requires mime_type' if mime_type.nil? || mime_type.empty?
|
|
22
|
+
|
|
23
|
+
@data = data
|
|
24
|
+
@mime_type = mime_type
|
|
25
|
+
@annotations = annotations
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create an AudioContent instance from JSON data
|
|
29
|
+
# @param data [Hash] JSON data from MCP server
|
|
30
|
+
# @return [MCPClient::AudioContent] audio content instance
|
|
31
|
+
def self.from_json(json_data)
|
|
32
|
+
new(
|
|
33
|
+
data: json_data['data'],
|
|
34
|
+
mime_type: json_data['mimeType'],
|
|
35
|
+
annotations: json_data['annotations']
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get the decoded audio content
|
|
40
|
+
# @return [String] decoded binary audio data
|
|
41
|
+
def content
|
|
42
|
+
require 'base64'
|
|
43
|
+
Base64.decode64(@data)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/mcp_client/client.rb
CHANGED
|
@@ -16,13 +16,17 @@ module MCPClient
|
|
|
16
16
|
# @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
|
|
17
17
|
# @!attribute [r] logger
|
|
18
18
|
# @return [Logger] logger for client operations
|
|
19
|
-
|
|
19
|
+
# @!attribute [r] roots
|
|
20
|
+
# @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
|
|
21
|
+
attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
|
|
20
22
|
|
|
21
23
|
# Initialize a new MCPClient::Client
|
|
22
24
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
|
23
25
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
24
26
|
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
25
|
-
|
|
27
|
+
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
|
|
28
|
+
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
|
|
29
|
+
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
|
|
26
30
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
27
31
|
@logger.progname = self.class.name
|
|
28
32
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
|
@@ -37,6 +41,10 @@ module MCPClient
|
|
|
37
41
|
@notification_listeners = []
|
|
38
42
|
# Elicitation handler (MCP 2025-06-18)
|
|
39
43
|
@elicitation_handler = elicitation_handler
|
|
44
|
+
# Sampling handler (MCP 2025-11-25)
|
|
45
|
+
@sampling_handler = sampling_handler
|
|
46
|
+
# Roots (MCP 2025-06-18)
|
|
47
|
+
@roots = normalize_roots(roots)
|
|
40
48
|
# Register default and user-defined notification handlers on each server
|
|
41
49
|
@servers.each do |server|
|
|
42
50
|
server.on_notification do |method, params|
|
|
@@ -49,6 +57,10 @@ module MCPClient
|
|
|
49
57
|
if server.respond_to?(:on_elicitation_request)
|
|
50
58
|
server.on_elicitation_request(&method(:handle_elicitation_request))
|
|
51
59
|
end
|
|
60
|
+
# Register roots list handler on each server (MCP 2025-06-18)
|
|
61
|
+
server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
|
|
62
|
+
# Register sampling handler on each server (MCP 2025-11-25)
|
|
63
|
+
server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
|
|
52
64
|
end
|
|
53
65
|
end
|
|
54
66
|
|
|
@@ -330,6 +342,16 @@ module MCPClient
|
|
|
330
342
|
@notification_listeners << block
|
|
331
343
|
end
|
|
332
344
|
|
|
345
|
+
# Set the roots for this client (MCP 2025-06-18)
|
|
346
|
+
# When roots are changed, a notification is sent to all connected servers
|
|
347
|
+
# @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
|
|
348
|
+
# @return [void]
|
|
349
|
+
def roots=(new_roots)
|
|
350
|
+
@roots = normalize_roots(new_roots)
|
|
351
|
+
# Notify servers that roots have changed
|
|
352
|
+
notify_roots_changed
|
|
353
|
+
end
|
|
354
|
+
|
|
333
355
|
# Find a server by name
|
|
334
356
|
# @param name [String] the name of the server to find
|
|
335
357
|
# @return [MCPClient::ServerBase, nil] the server with the given name, or nil if not found
|
|
@@ -353,8 +375,10 @@ module MCPClient
|
|
|
353
375
|
end
|
|
354
376
|
|
|
355
377
|
# Call multiple tools in batch
|
|
356
|
-
# @param calls [Array<Hash>] array of
|
|
357
|
-
#
|
|
378
|
+
# @param calls [Array<Hash>] array of call hashes with keys:
|
|
379
|
+
# - name: tool name (required)
|
|
380
|
+
# - parameters: tool parameters (optional, default empty hash)
|
|
381
|
+
# - server: server name for routing (optional)
|
|
358
382
|
# @return [Array<Object>] array of results for each tool invocation
|
|
359
383
|
def call_tools(calls)
|
|
360
384
|
calls.map do |call|
|
|
@@ -459,6 +483,100 @@ module MCPClient
|
|
|
459
483
|
srv.rpc_notify(method, params)
|
|
460
484
|
end
|
|
461
485
|
|
|
486
|
+
# Request completion suggestions from a server (MCP 2025-06-18)
|
|
487
|
+
# @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
|
|
488
|
+
# @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
|
|
489
|
+
# @param context [Hash, nil] optional context for the completion (MCP 2025-11-25),
|
|
490
|
+
# e.g., { 'arguments' => { 'arg1' => 'value1' } } for previously-resolved arguments
|
|
491
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
492
|
+
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
493
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
494
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
495
|
+
def complete(ref:, argument:, context: nil, server: nil)
|
|
496
|
+
srv = select_server(server)
|
|
497
|
+
srv.complete(ref: ref, argument: argument, context: context)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Create a new task on a server (MCP 2025-11-25)
|
|
501
|
+
# Tasks represent long-running operations that can report progress
|
|
502
|
+
# @param method [String] the method to execute as a task
|
|
503
|
+
# @param params [Hash] parameters for the task method
|
|
504
|
+
# @param progress_token [String, nil] optional token for receiving progress notifications
|
|
505
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
506
|
+
# @return [MCPClient::Task] the created task
|
|
507
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
508
|
+
# @raise [MCPClient::Errors::TaskError] if task creation fails
|
|
509
|
+
def create_task(method, params: {}, progress_token: nil, server: nil)
|
|
510
|
+
srv = select_server(server)
|
|
511
|
+
rpc_params = { method: method, params: params }
|
|
512
|
+
rpc_params[:progressToken] = progress_token if progress_token
|
|
513
|
+
|
|
514
|
+
begin
|
|
515
|
+
result = srv.rpc_request('tasks/create', rpc_params)
|
|
516
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
517
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
518
|
+
raise MCPClient::Errors::TaskError, "Error creating task: #{e.message}"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Get the current state of a task (MCP 2025-11-25)
|
|
523
|
+
# @param task_id [String] the ID of the task to query
|
|
524
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
525
|
+
# @return [MCPClient::Task] the task with current state
|
|
526
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
527
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
528
|
+
# @raise [MCPClient::Errors::TaskError] if retrieving the task fails
|
|
529
|
+
def get_task(task_id, server: nil)
|
|
530
|
+
srv = select_server(server)
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
result = srv.rpc_request('tasks/get', { id: task_id })
|
|
534
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
535
|
+
rescue MCPClient::Errors::ServerError => e
|
|
536
|
+
if e.message.include?('not found') || e.message.include?('unknown task')
|
|
537
|
+
raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
541
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
542
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Cancel a running task (MCP 2025-11-25)
|
|
547
|
+
# @param task_id [String] the ID of the task to cancel
|
|
548
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
549
|
+
# @return [MCPClient::Task] the task with updated (cancelled) state
|
|
550
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
551
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
552
|
+
# @raise [MCPClient::Errors::TaskError] if cancellation fails
|
|
553
|
+
def cancel_task(task_id, server: nil)
|
|
554
|
+
srv = select_server(server)
|
|
555
|
+
|
|
556
|
+
begin
|
|
557
|
+
result = srv.rpc_request('tasks/cancel', { id: task_id })
|
|
558
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
559
|
+
rescue MCPClient::Errors::ServerError => e
|
|
560
|
+
if e.message.include?('not found') || e.message.include?('unknown task')
|
|
561
|
+
raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
|
|
565
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
566
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Set the logging level on all connected servers (MCP 2025-06-18)
|
|
571
|
+
# To set on a specific server, use: client.find_server('name').log_level = 'debug'
|
|
572
|
+
# @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
|
|
573
|
+
# 'critical', 'alert', 'emergency')
|
|
574
|
+
# @return [Array<Hash>] results from servers
|
|
575
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
576
|
+
def log_level=(level)
|
|
577
|
+
@servers.map { |srv| srv.log_level = level }
|
|
578
|
+
end
|
|
579
|
+
|
|
462
580
|
private
|
|
463
581
|
|
|
464
582
|
# Process incoming JSON-RPC notifications with default handlers
|
|
@@ -480,12 +598,43 @@ module MCPClient
|
|
|
480
598
|
when 'notifications/resources/list_changed'
|
|
481
599
|
logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
|
|
482
600
|
@resource_cache.clear
|
|
601
|
+
when 'notifications/message'
|
|
602
|
+
# MCP 2025-06-18: Handle logging messages from server
|
|
603
|
+
handle_log_message(server_id, params)
|
|
483
604
|
else
|
|
484
605
|
# Log unknown notification types for debugging purposes
|
|
485
606
|
logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
|
|
486
607
|
end
|
|
487
608
|
end
|
|
488
609
|
|
|
610
|
+
# Handle logging message notification from server (MCP 2025-06-18)
|
|
611
|
+
# @param server_id [String] server identifier for log prefix
|
|
612
|
+
# @param params [Hash] log message params (level, logger, data)
|
|
613
|
+
# @return [void]
|
|
614
|
+
def handle_log_message(server_id, params)
|
|
615
|
+
level = params['level'] || 'info'
|
|
616
|
+
logger_name = params['logger']
|
|
617
|
+
data = params['data']
|
|
618
|
+
|
|
619
|
+
# Format the message
|
|
620
|
+
prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
|
|
621
|
+
message = data.is_a?(String) ? data : data.inspect
|
|
622
|
+
|
|
623
|
+
# Map MCP log levels to Ruby Logger levels
|
|
624
|
+
case level.to_s.downcase
|
|
625
|
+
when 'debug'
|
|
626
|
+
logger.debug("#{prefix} #{message}")
|
|
627
|
+
when 'info', 'notice'
|
|
628
|
+
logger.info("#{prefix} #{message}")
|
|
629
|
+
when 'warning'
|
|
630
|
+
logger.warn("#{prefix} #{message}")
|
|
631
|
+
when 'error', 'critical', 'alert', 'emergency'
|
|
632
|
+
logger.error("#{prefix} #{message}")
|
|
633
|
+
else
|
|
634
|
+
logger.info("#{prefix} [#{level}] #{message}")
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
489
638
|
# Select a server based on index, name, type, or instance
|
|
490
639
|
# @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
491
640
|
# @return [MCPClient::ServerBase]
|
|
@@ -529,7 +678,17 @@ module MCPClient
|
|
|
529
678
|
required = schema['required'] || schema[:required]
|
|
530
679
|
return unless required.is_a?(Array)
|
|
531
680
|
|
|
681
|
+
properties = schema['properties'] || schema[:properties] || {}
|
|
682
|
+
|
|
532
683
|
missing = required.map(&:to_s) - parameters.keys.map(&:to_s)
|
|
684
|
+
|
|
685
|
+
# Exclude required params that have a default value in the schema,
|
|
686
|
+
# since the server will apply the default.
|
|
687
|
+
missing = missing.reject do |param|
|
|
688
|
+
prop = properties[param] || properties[param.to_sym]
|
|
689
|
+
prop.is_a?(Hash) && (prop.key?('default') || prop.key?(:default))
|
|
690
|
+
end
|
|
691
|
+
|
|
533
692
|
return unless missing.any?
|
|
534
693
|
|
|
535
694
|
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
|
@@ -606,8 +765,9 @@ module MCPClient
|
|
|
606
765
|
end
|
|
607
766
|
end
|
|
608
767
|
|
|
609
|
-
# Handle elicitation request from server (MCP 2025-
|
|
610
|
-
#
|
|
768
|
+
# Handle elicitation request from server (MCP 2025-11-25)
|
|
769
|
+
# Supports both form mode (structured data) and URL mode (out-of-band interaction).
|
|
770
|
+
# @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
|
|
611
771
|
# @param params [Hash] the elicitation parameters
|
|
612
772
|
# @return [Hash] the elicitation response
|
|
613
773
|
def handle_elicitation_request(_request_id, params)
|
|
@@ -617,43 +777,17 @@ module MCPClient
|
|
|
617
777
|
return { 'action' => 'decline' }
|
|
618
778
|
end
|
|
619
779
|
|
|
780
|
+
mode = params['mode'] || 'form'
|
|
620
781
|
message = params['message']
|
|
621
|
-
schema = params['schema'] || params['requestedSchema']
|
|
622
|
-
metadata = params['metadata']
|
|
623
782
|
|
|
624
783
|
begin
|
|
625
|
-
|
|
626
|
-
|
|
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)
|
|
784
|
+
result = if mode == 'url'
|
|
785
|
+
handle_url_elicitation(params, message)
|
|
633
786
|
else
|
|
634
|
-
|
|
787
|
+
handle_form_elicitation(params, message)
|
|
635
788
|
end
|
|
636
789
|
|
|
637
|
-
|
|
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
|
|
790
|
+
format_elicitation_response(result, params)
|
|
657
791
|
rescue StandardError => e
|
|
658
792
|
@logger.error("Elicitation handler error: #{e.message}")
|
|
659
793
|
@logger.debug(e.backtrace.join("\n"))
|
|
@@ -661,6 +795,103 @@ module MCPClient
|
|
|
661
795
|
end
|
|
662
796
|
end
|
|
663
797
|
|
|
798
|
+
# Handle form mode elicitation (MCP 2025-11-25)
|
|
799
|
+
# @param params [Hash] the elicitation parameters
|
|
800
|
+
# @param message [String] the human-readable message
|
|
801
|
+
# @return [Object] handler result
|
|
802
|
+
def handle_form_elicitation(params, message)
|
|
803
|
+
schema = params['requestedSchema'] || params['schema']
|
|
804
|
+
metadata = params['metadata']
|
|
805
|
+
|
|
806
|
+
# Validate schema if present
|
|
807
|
+
if schema
|
|
808
|
+
schema_errors = ElicitationValidator.validate_schema(schema)
|
|
809
|
+
@logger.warn("Elicitation schema validation warnings: #{schema_errors.join('; ')}") unless schema_errors.empty?
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Call the user-defined handler
|
|
813
|
+
case @elicitation_handler.arity
|
|
814
|
+
when 0
|
|
815
|
+
@elicitation_handler.call
|
|
816
|
+
when 1
|
|
817
|
+
@elicitation_handler.call(message)
|
|
818
|
+
when 2, -1
|
|
819
|
+
@elicitation_handler.call(message, schema)
|
|
820
|
+
else
|
|
821
|
+
@elicitation_handler.call(message, schema, metadata)
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Handle URL mode elicitation (MCP 2025-11-25)
|
|
826
|
+
# @param params [Hash] the elicitation parameters
|
|
827
|
+
# @param message [String] the human-readable message
|
|
828
|
+
# @return [Object] handler result
|
|
829
|
+
def handle_url_elicitation(params, message)
|
|
830
|
+
url = params['url']
|
|
831
|
+
elicitation_id = params['elicitationId']
|
|
832
|
+
|
|
833
|
+
# Call handler with URL-mode specific params
|
|
834
|
+
case @elicitation_handler.arity
|
|
835
|
+
when 0
|
|
836
|
+
@elicitation_handler.call
|
|
837
|
+
when 1
|
|
838
|
+
@elicitation_handler.call(message)
|
|
839
|
+
when 2, -1
|
|
840
|
+
@elicitation_handler.call(message, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id })
|
|
841
|
+
else
|
|
842
|
+
@elicitation_handler.call(message, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id },
|
|
843
|
+
params['metadata'])
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# Format and validate the elicitation response
|
|
848
|
+
# @param result [Object] handler result
|
|
849
|
+
# @param params [Hash] original request params (for schema validation)
|
|
850
|
+
# @return [Hash] formatted response
|
|
851
|
+
def format_elicitation_response(result, params)
|
|
852
|
+
response = case result
|
|
853
|
+
when Hash
|
|
854
|
+
if result['action']
|
|
855
|
+
normalised_action_response(result)
|
|
856
|
+
elsif result[:action]
|
|
857
|
+
{
|
|
858
|
+
'action' => result[:action].to_s,
|
|
859
|
+
'content' => result[:content]
|
|
860
|
+
}.compact.then { |payload| normalised_action_response(payload) }
|
|
861
|
+
else
|
|
862
|
+
{ 'action' => 'accept', 'content' => result }
|
|
863
|
+
end
|
|
864
|
+
when nil
|
|
865
|
+
{ 'action' => 'cancel' }
|
|
866
|
+
else
|
|
867
|
+
{ 'action' => 'accept', 'content' => result }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Validate content against schema for form mode accept responses
|
|
871
|
+
validate_elicitation_content(response, params)
|
|
872
|
+
|
|
873
|
+
response
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Validate elicitation response content against the requestedSchema
|
|
877
|
+
# @param response [Hash] the formatted response
|
|
878
|
+
# @param params [Hash] original request params
|
|
879
|
+
# @return [void]
|
|
880
|
+
def validate_elicitation_content(response, params)
|
|
881
|
+
return unless response['action'] == 'accept' && response['content'].is_a?(Hash)
|
|
882
|
+
|
|
883
|
+
mode = params['mode'] || 'form'
|
|
884
|
+
return unless mode == 'form'
|
|
885
|
+
|
|
886
|
+
schema = params['requestedSchema'] || params['schema']
|
|
887
|
+
return unless schema.is_a?(Hash)
|
|
888
|
+
|
|
889
|
+
errors = ElicitationValidator.validate_content(response['content'], schema)
|
|
890
|
+
return if errors.empty?
|
|
891
|
+
|
|
892
|
+
@logger.warn("Elicitation content validation warnings: #{errors.join('; ')}")
|
|
893
|
+
end
|
|
894
|
+
|
|
664
895
|
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
665
896
|
# Falls back to accept for unknown action values.
|
|
666
897
|
def normalised_action_response(result)
|
|
@@ -670,5 +901,169 @@ module MCPClient
|
|
|
670
901
|
@logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
|
|
671
902
|
result.merge('action' => 'accept')
|
|
672
903
|
end
|
|
904
|
+
|
|
905
|
+
# Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
|
|
906
|
+
# @param roots [Array<MCPClient::Root, Hash>, nil] the roots to normalize
|
|
907
|
+
# @return [Array<MCPClient::Root>] normalized roots array
|
|
908
|
+
def normalize_roots(roots)
|
|
909
|
+
return [] if roots.nil?
|
|
910
|
+
|
|
911
|
+
roots.map do |root|
|
|
912
|
+
case root
|
|
913
|
+
when MCPClient::Root
|
|
914
|
+
root
|
|
915
|
+
when Hash
|
|
916
|
+
MCPClient::Root.from_json(root)
|
|
917
|
+
else
|
|
918
|
+
raise ArgumentError, "Invalid root type: #{root.class}. Expected MCPClient::Root or Hash."
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# Handle roots/list request from server (MCP 2025-06-18)
|
|
924
|
+
# @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
|
|
925
|
+
# @param _params [Hash] the request parameters (unused)
|
|
926
|
+
# @return [Hash] the roots list response
|
|
927
|
+
def handle_roots_list_request(_request_id, _params)
|
|
928
|
+
{ 'roots' => @roots.map(&:to_h) }
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Send notification to all servers that roots have changed (MCP 2025-06-18)
|
|
932
|
+
# @return [void]
|
|
933
|
+
def notify_roots_changed
|
|
934
|
+
@servers.each do |server|
|
|
935
|
+
server.rpc_notify('notifications/roots/list_changed', {})
|
|
936
|
+
rescue StandardError => e
|
|
937
|
+
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
|
|
938
|
+
@logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Handle sampling/createMessage request from server (MCP 2025-11-25)
|
|
943
|
+
# @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
|
|
944
|
+
# @param params [Hash] the sampling parameters
|
|
945
|
+
# @return [Hash] the sampling response (role, content, model, stopReason)
|
|
946
|
+
def handle_sampling_request(_request_id, params)
|
|
947
|
+
# If no handler is configured, return an error
|
|
948
|
+
unless @sampling_handler
|
|
949
|
+
@logger.warn('Received sampling request but no handler configured')
|
|
950
|
+
return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
messages = params['messages'] || []
|
|
954
|
+
model_preferences = normalize_model_preferences(params['modelPreferences'])
|
|
955
|
+
system_prompt = params['systemPrompt']
|
|
956
|
+
max_tokens = params['maxTokens']
|
|
957
|
+
include_context = params['includeContext']
|
|
958
|
+
temperature = params['temperature']
|
|
959
|
+
stop_sequences = params['stopSequences']
|
|
960
|
+
metadata = params['metadata']
|
|
961
|
+
|
|
962
|
+
begin
|
|
963
|
+
# Call the user-defined handler with parameters based on arity
|
|
964
|
+
result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
|
|
965
|
+
include_context, temperature, stop_sequences, metadata)
|
|
966
|
+
|
|
967
|
+
# Validate and format response
|
|
968
|
+
validate_sampling_response(result)
|
|
969
|
+
rescue StandardError => e
|
|
970
|
+
@logger.error("Sampling handler error: #{e.message}")
|
|
971
|
+
@logger.debug(e.backtrace.join("\n"))
|
|
972
|
+
{ 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Call sampling handler with appropriate arity
|
|
977
|
+
# @param messages [Array] the messages
|
|
978
|
+
# @param model_preferences [Hash, nil] normalized model preferences
|
|
979
|
+
# @param system_prompt [String, nil] system prompt
|
|
980
|
+
# @param max_tokens [Integer, nil] max tokens
|
|
981
|
+
# @param include_context [String, nil] context inclusion setting
|
|
982
|
+
# @param temperature [Float, nil] temperature
|
|
983
|
+
# @param stop_sequences [Array, nil] stop sequences
|
|
984
|
+
# @param metadata [Hash, nil] metadata
|
|
985
|
+
# @return [Hash] the handler result
|
|
986
|
+
def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
|
|
987
|
+
include_context, temperature, stop_sequences, metadata)
|
|
988
|
+
arity = @sampling_handler.arity
|
|
989
|
+
# Normalize negative arity (optional params) to minimum required args
|
|
990
|
+
arity = -(arity + 1) if arity.negative?
|
|
991
|
+
case arity
|
|
992
|
+
when 0
|
|
993
|
+
@sampling_handler.call
|
|
994
|
+
when 1
|
|
995
|
+
@sampling_handler.call(messages)
|
|
996
|
+
when 2
|
|
997
|
+
@sampling_handler.call(messages, model_preferences)
|
|
998
|
+
when 3
|
|
999
|
+
@sampling_handler.call(messages, model_preferences, system_prompt)
|
|
1000
|
+
when 4
|
|
1001
|
+
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
|
|
1002
|
+
else
|
|
1003
|
+
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens,
|
|
1004
|
+
{ 'includeContext' => include_context, 'temperature' => temperature,
|
|
1005
|
+
'stopSequences' => stop_sequences, 'metadata' => metadata })
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
|
|
1010
|
+
# Ensures hints is an array of hashes with 'name', and priority values are clamped to 0.0..1.0
|
|
1011
|
+
# @param prefs [Hash, nil] raw modelPreferences from request
|
|
1012
|
+
# @return [Hash, nil] normalized modelPreferences or nil
|
|
1013
|
+
def normalize_model_preferences(prefs)
|
|
1014
|
+
return nil if prefs.nil?
|
|
1015
|
+
return nil unless prefs.is_a?(Hash)
|
|
1016
|
+
|
|
1017
|
+
normalized = {}
|
|
1018
|
+
|
|
1019
|
+
# Normalize hints: array of { 'name' => String }
|
|
1020
|
+
if prefs['hints']
|
|
1021
|
+
normalized['hints'] = Array(prefs['hints']).filter_map do |hint|
|
|
1022
|
+
next nil unless hint.is_a?(Hash) && hint['name']
|
|
1023
|
+
|
|
1024
|
+
{ 'name' => hint['name'].to_s }
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Normalize priority values (0.0 to 1.0)
|
|
1029
|
+
%w[costPriority speedPriority intelligencePriority].each do |key|
|
|
1030
|
+
next unless prefs.key?(key)
|
|
1031
|
+
|
|
1032
|
+
value = prefs[key]
|
|
1033
|
+
normalized[key] = value.is_a?(Numeric) ? value.to_f.clamp(0.0, 1.0) : nil
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
normalized
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Validate sampling response from handler (MCP 2025-11-25)
|
|
1040
|
+
# @param result [Hash] the result from the sampling handler
|
|
1041
|
+
# @return [Hash] validated sampling response
|
|
1042
|
+
def validate_sampling_response(result)
|
|
1043
|
+
return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
|
|
1044
|
+
|
|
1045
|
+
# Convert symbol keys to string keys
|
|
1046
|
+
result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
|
|
1047
|
+
|
|
1048
|
+
# Ensure required fields are present
|
|
1049
|
+
unless result.is_a?(Hash) && result['content']
|
|
1050
|
+
return {
|
|
1051
|
+
'role' => 'assistant',
|
|
1052
|
+
'content' => { 'type' => 'text', 'text' => result.to_s },
|
|
1053
|
+
'model' => 'unknown',
|
|
1054
|
+
'stopReason' => 'endTurn'
|
|
1055
|
+
}
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# Set defaults for missing fields
|
|
1059
|
+
result['role'] ||= 'assistant'
|
|
1060
|
+
result['model'] ||= 'unknown'
|
|
1061
|
+
result['stopReason'] ||= 'endTurn'
|
|
1062
|
+
|
|
1063
|
+
# Normalize content if it's a string
|
|
1064
|
+
result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
|
|
1065
|
+
|
|
1066
|
+
result
|
|
1067
|
+
end
|
|
673
1068
|
end
|
|
674
1069
|
end
|