ruby-mcp-client 0.9.1 → 1.0.1
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 +42 -6
- data/lib/mcp_client/audio_content.rb +46 -0
- data/lib/mcp_client/auth/oauth_provider.rb +29 -6
- data/lib/mcp_client/auth.rb +68 -8
- data/lib/mcp_client/client.rb +265 -54
- data/lib/mcp_client/elicitation_validator.rb +262 -0
- data/lib/mcp_client/errors.rb +6 -0
- data/lib/mcp_client/http_transport_base.rb +2 -0
- data/lib/mcp_client/json_rpc_common.rb +3 -3
- data/lib/mcp_client/resource.rb +8 -0
- data/lib/mcp_client/resource_link.rb +63 -0
- data/lib/mcp_client/server_http.rb +17 -7
- data/lib/mcp_client/server_sse.rb +9 -6
- data/lib/mcp_client/server_stdio.rb +9 -6
- data/lib/mcp_client/server_streamable_http.rb +23 -10
- 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 +27 -0
- metadata +8 -4
data/lib/mcp_client/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module MCPClient
|
|
|
25
25
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
26
26
|
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
27
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-
|
|
28
|
+
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
|
|
29
29
|
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
|
|
30
30
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
31
31
|
@logger.progname = self.class.name
|
|
@@ -41,7 +41,7 @@ module MCPClient
|
|
|
41
41
|
@notification_listeners = []
|
|
42
42
|
# Elicitation handler (MCP 2025-06-18)
|
|
43
43
|
@elicitation_handler = elicitation_handler
|
|
44
|
-
# Sampling handler (MCP 2025-
|
|
44
|
+
# Sampling handler (MCP 2025-11-25)
|
|
45
45
|
@sampling_handler = sampling_handler
|
|
46
46
|
# Roots (MCP 2025-06-18)
|
|
47
47
|
@roots = normalize_roots(roots)
|
|
@@ -59,7 +59,7 @@ module MCPClient
|
|
|
59
59
|
end
|
|
60
60
|
# Register roots list handler on each server (MCP 2025-06-18)
|
|
61
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-
|
|
62
|
+
# Register sampling handler on each server (MCP 2025-11-25)
|
|
63
63
|
server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
@@ -486,13 +486,85 @@ module MCPClient
|
|
|
486
486
|
# Request completion suggestions from a server (MCP 2025-06-18)
|
|
487
487
|
# @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
|
|
488
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
|
|
489
491
|
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
490
492
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
491
493
|
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
492
494
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
493
|
-
def complete(ref:, argument:, server: nil)
|
|
495
|
+
def complete(ref:, argument:, context: nil, server: nil)
|
|
494
496
|
srv = select_server(server)
|
|
495
|
-
srv.complete(ref: ref, argument: argument)
|
|
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
|
|
496
568
|
end
|
|
497
569
|
|
|
498
570
|
# Set the logging level on all connected servers (MCP 2025-06-18)
|
|
@@ -606,7 +678,17 @@ module MCPClient
|
|
|
606
678
|
required = schema['required'] || schema[:required]
|
|
607
679
|
return unless required.is_a?(Array)
|
|
608
680
|
|
|
681
|
+
properties = schema['properties'] || schema[:properties] || {}
|
|
682
|
+
|
|
609
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
|
+
|
|
610
692
|
return unless missing.any?
|
|
611
693
|
|
|
612
694
|
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
|
@@ -683,7 +765,8 @@ module MCPClient
|
|
|
683
765
|
end
|
|
684
766
|
end
|
|
685
767
|
|
|
686
|
-
# Handle elicitation request from server (MCP 2025-
|
|
768
|
+
# Handle elicitation request from server (MCP 2025-11-25)
|
|
769
|
+
# Supports both form mode (structured data) and URL mode (out-of-band interaction).
|
|
687
770
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
|
|
688
771
|
# @param params [Hash] the elicitation parameters
|
|
689
772
|
# @return [Hash] the elicitation response
|
|
@@ -694,43 +777,17 @@ module MCPClient
|
|
|
694
777
|
return { 'action' => 'decline' }
|
|
695
778
|
end
|
|
696
779
|
|
|
780
|
+
mode = params['mode'] || 'form'
|
|
697
781
|
message = params['message']
|
|
698
|
-
schema = params['schema'] || params['requestedSchema']
|
|
699
|
-
metadata = params['metadata']
|
|
700
782
|
|
|
701
783
|
begin
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
when 0
|
|
705
|
-
@elicitation_handler.call
|
|
706
|
-
when 1
|
|
707
|
-
@elicitation_handler.call(message)
|
|
708
|
-
when 2, -1
|
|
709
|
-
@elicitation_handler.call(message, schema)
|
|
784
|
+
result = if mode == 'url'
|
|
785
|
+
handle_url_elicitation(params, message)
|
|
710
786
|
else
|
|
711
|
-
|
|
787
|
+
handle_form_elicitation(params, message)
|
|
712
788
|
end
|
|
713
789
|
|
|
714
|
-
|
|
715
|
-
case result
|
|
716
|
-
when Hash
|
|
717
|
-
if result['action']
|
|
718
|
-
normalised_action_response(result)
|
|
719
|
-
elsif result[:action]
|
|
720
|
-
# Convert symbol keys to strings
|
|
721
|
-
{
|
|
722
|
-
'action' => result[:action].to_s,
|
|
723
|
-
'content' => result[:content]
|
|
724
|
-
}.compact.then { |payload| normalised_action_response(payload) }
|
|
725
|
-
else
|
|
726
|
-
# Assume it's content for an accept action
|
|
727
|
-
{ 'action' => 'accept', 'content' => result }
|
|
728
|
-
end
|
|
729
|
-
when nil
|
|
730
|
-
{ 'action' => 'cancel' }
|
|
731
|
-
else
|
|
732
|
-
{ 'action' => 'accept', 'content' => result }
|
|
733
|
-
end
|
|
790
|
+
format_elicitation_response(result, params)
|
|
734
791
|
rescue StandardError => e
|
|
735
792
|
@logger.error("Elicitation handler error: #{e.message}")
|
|
736
793
|
@logger.debug(e.backtrace.join("\n"))
|
|
@@ -738,6 +795,103 @@ module MCPClient
|
|
|
738
795
|
end
|
|
739
796
|
end
|
|
740
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
|
+
|
|
741
895
|
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
742
896
|
# Falls back to accept for unknown action values.
|
|
743
897
|
def normalised_action_response(result)
|
|
@@ -785,9 +939,9 @@ module MCPClient
|
|
|
785
939
|
end
|
|
786
940
|
end
|
|
787
941
|
|
|
788
|
-
# Handle sampling/createMessage request from server (MCP 2025-
|
|
942
|
+
# Handle sampling/createMessage request from server (MCP 2025-11-25)
|
|
789
943
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
|
|
790
|
-
# @param params [Hash] the sampling parameters
|
|
944
|
+
# @param params [Hash] the sampling parameters
|
|
791
945
|
# @return [Hash] the sampling response (role, content, model, stopReason)
|
|
792
946
|
def handle_sampling_request(_request_id, params)
|
|
793
947
|
# If no handler is configured, return an error
|
|
@@ -797,24 +951,18 @@ module MCPClient
|
|
|
797
951
|
end
|
|
798
952
|
|
|
799
953
|
messages = params['messages'] || []
|
|
800
|
-
model_preferences = params['modelPreferences']
|
|
954
|
+
model_preferences = normalize_model_preferences(params['modelPreferences'])
|
|
801
955
|
system_prompt = params['systemPrompt']
|
|
802
956
|
max_tokens = params['maxTokens']
|
|
957
|
+
include_context = params['includeContext']
|
|
958
|
+
temperature = params['temperature']
|
|
959
|
+
stop_sequences = params['stopSequences']
|
|
960
|
+
metadata = params['metadata']
|
|
803
961
|
|
|
804
962
|
begin
|
|
805
|
-
# Call the user-defined handler
|
|
806
|
-
result =
|
|
807
|
-
|
|
808
|
-
@sampling_handler.call
|
|
809
|
-
when 1
|
|
810
|
-
@sampling_handler.call(messages)
|
|
811
|
-
when 2
|
|
812
|
-
@sampling_handler.call(messages, model_preferences)
|
|
813
|
-
when 3
|
|
814
|
-
@sampling_handler.call(messages, model_preferences, system_prompt)
|
|
815
|
-
else
|
|
816
|
-
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
|
|
817
|
-
end
|
|
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)
|
|
818
966
|
|
|
819
967
|
# Validate and format response
|
|
820
968
|
validate_sampling_response(result)
|
|
@@ -825,7 +973,70 @@ module MCPClient
|
|
|
825
973
|
end
|
|
826
974
|
end
|
|
827
975
|
|
|
828
|
-
#
|
|
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)
|
|
829
1040
|
# @param result [Hash] the result from the sampling handler
|
|
830
1041
|
# @return [Hash] validated sampling response
|
|
831
1042
|
def validate_sampling_response(result)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Validates elicitation schemas and content per MCP 2025-11-25 spec.
|
|
5
|
+
# Schemas are restricted to flat objects with primitive property types:
|
|
6
|
+
# string (with optional enum, pattern, format, minLength, maxLength)
|
|
7
|
+
# number / integer (with optional minimum, maximum)
|
|
8
|
+
# boolean
|
|
9
|
+
# array (multi-select enum only, with items containing enum or anyOf)
|
|
10
|
+
module ElicitationValidator
|
|
11
|
+
# Allowed primitive types for schema properties
|
|
12
|
+
PRIMITIVE_TYPES = %w[string number integer boolean].freeze
|
|
13
|
+
|
|
14
|
+
# Allowed string formats per MCP spec
|
|
15
|
+
STRING_FORMATS = %w[email uri date date-time].freeze
|
|
16
|
+
|
|
17
|
+
# Validate that a requestedSchema conforms to MCP elicitation constraints.
|
|
18
|
+
# Returns an array of error messages (empty if valid).
|
|
19
|
+
# @param schema [Hash] the requestedSchema
|
|
20
|
+
# @return [Array<String>] validation errors
|
|
21
|
+
def self.validate_schema(schema)
|
|
22
|
+
errors = []
|
|
23
|
+
return errors unless schema.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
unless schema['type'] == 'object'
|
|
26
|
+
errors << "Schema type must be 'object', got '#{schema['type']}'"
|
|
27
|
+
return errors
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
properties = schema['properties']
|
|
31
|
+
return errors unless properties.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
properties.each do |name, prop|
|
|
34
|
+
errors.concat(validate_property(name, prop))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate a single property definition.
|
|
41
|
+
# @param name [String] property name
|
|
42
|
+
# @param prop [Hash] property schema
|
|
43
|
+
# @return [Array<String>] validation errors
|
|
44
|
+
def self.validate_property(name, prop)
|
|
45
|
+
errors = []
|
|
46
|
+
return errors unless prop.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
type = prop['type']
|
|
49
|
+
|
|
50
|
+
if type == 'array'
|
|
51
|
+
errors.concat(validate_array_property(name, prop))
|
|
52
|
+
elsif PRIMITIVE_TYPES.include?(type)
|
|
53
|
+
errors.concat(validate_primitive_property(name, prop))
|
|
54
|
+
else
|
|
55
|
+
errors << "Property '#{name}' has unsupported type '#{type}'"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
errors
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate a primitive property (string, number, integer, boolean).
|
|
62
|
+
# @param name [String] property name
|
|
63
|
+
# @param prop [Hash] property schema
|
|
64
|
+
# @return [Array<String>] validation errors
|
|
65
|
+
def self.validate_primitive_property(name, prop)
|
|
66
|
+
errors = []
|
|
67
|
+
type = prop['type']
|
|
68
|
+
|
|
69
|
+
case type
|
|
70
|
+
when 'string'
|
|
71
|
+
if prop['format'] && !STRING_FORMATS.include?(prop['format'])
|
|
72
|
+
errors << "Property '#{name}' has unsupported format '#{prop['format']}'"
|
|
73
|
+
end
|
|
74
|
+
errors << "Property '#{name}' enum must be an array" if prop['enum'] && !prop['enum'].is_a?(Array)
|
|
75
|
+
when 'number', 'integer'
|
|
76
|
+
if prop.key?('minimum') && !prop['minimum'].is_a?(Numeric)
|
|
77
|
+
errors << "Property '#{name}' minimum must be numeric"
|
|
78
|
+
end
|
|
79
|
+
if prop.key?('maximum') && !prop['maximum'].is_a?(Numeric)
|
|
80
|
+
errors << "Property '#{name}' maximum must be numeric"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
errors
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Validate an array property (multi-select enum only).
|
|
88
|
+
# @param name [String] property name
|
|
89
|
+
# @param prop [Hash] property schema
|
|
90
|
+
# @return [Array<String>] validation errors
|
|
91
|
+
def self.validate_array_property(name, prop)
|
|
92
|
+
errors = []
|
|
93
|
+
items = prop['items']
|
|
94
|
+
|
|
95
|
+
unless items.is_a?(Hash)
|
|
96
|
+
errors << "Property '#{name}' array type requires 'items' definition"
|
|
97
|
+
return errors
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
has_enum = items['enum'].is_a?(Array)
|
|
101
|
+
has_any_of = items['anyOf'].is_a?(Array)
|
|
102
|
+
|
|
103
|
+
errors << "Property '#{name}' array items must have 'enum' or 'anyOf'" unless has_enum || has_any_of
|
|
104
|
+
|
|
105
|
+
errors
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validate content against a requestedSchema.
|
|
109
|
+
# Returns an array of error messages (empty if valid).
|
|
110
|
+
# @param content [Hash] the response content
|
|
111
|
+
# @param schema [Hash] the requestedSchema
|
|
112
|
+
# @return [Array<String>] validation errors
|
|
113
|
+
def self.validate_content(content, schema)
|
|
114
|
+
errors = []
|
|
115
|
+
return errors unless content.is_a?(Hash) && schema.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
properties = schema['properties'] || {}
|
|
118
|
+
required = Array(schema['required'])
|
|
119
|
+
|
|
120
|
+
# Check required fields
|
|
121
|
+
required.each do |field|
|
|
122
|
+
field_s = field.to_s
|
|
123
|
+
errors << "Missing required field '#{field_s}'" unless content.key?(field_s) || content.key?(field_s.to_sym)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Validate each provided field
|
|
127
|
+
content.each do |field, value|
|
|
128
|
+
prop = properties[field.to_s]
|
|
129
|
+
next unless prop.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
errors.concat(validate_value(field.to_s, value, prop))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
errors
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate a single value against its property schema.
|
|
138
|
+
# @param field [String] field name
|
|
139
|
+
# @param value [Object] the value to validate
|
|
140
|
+
# @param prop [Hash] property schema
|
|
141
|
+
# @return [Array<String>] validation errors
|
|
142
|
+
def self.validate_value(field, value, prop)
|
|
143
|
+
errors = []
|
|
144
|
+
type = prop['type']
|
|
145
|
+
|
|
146
|
+
case type
|
|
147
|
+
when 'string'
|
|
148
|
+
errors.concat(validate_string_value(field, value, prop))
|
|
149
|
+
when 'number', 'integer'
|
|
150
|
+
errors.concat(validate_number_value(field, value, prop))
|
|
151
|
+
when 'boolean'
|
|
152
|
+
errors << "Field '#{field}' must be a boolean" unless [true, false].include?(value)
|
|
153
|
+
when 'array'
|
|
154
|
+
errors.concat(validate_array_value(field, value, prop))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
errors
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validate a string value against its property schema.
|
|
161
|
+
# @param field [String] field name
|
|
162
|
+
# @param value [Object] the value
|
|
163
|
+
# @param prop [Hash] property schema
|
|
164
|
+
# @return [Array<String>] validation errors
|
|
165
|
+
def self.validate_string_value(field, value, prop)
|
|
166
|
+
errors = []
|
|
167
|
+
|
|
168
|
+
unless value.is_a?(String)
|
|
169
|
+
errors << "Field '#{field}' must be a string"
|
|
170
|
+
return errors
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if prop['enum'].is_a?(Array) && !prop['enum'].include?(value)
|
|
174
|
+
errors << "Field '#{field}' must be one of: #{prop['enum'].join(', ')}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if prop['oneOf'].is_a?(Array)
|
|
178
|
+
allowed = prop['oneOf'].map { |o| o['const'] }
|
|
179
|
+
errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if prop['pattern']
|
|
183
|
+
begin
|
|
184
|
+
unless value.match?(Regexp.new(prop['pattern']))
|
|
185
|
+
errors << "Field '#{field}' must match pattern '#{prop['pattern']}'"
|
|
186
|
+
end
|
|
187
|
+
rescue RegexpError
|
|
188
|
+
# Skip pattern validation if the pattern is invalid
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if prop['minLength'] && value.length < prop['minLength']
|
|
193
|
+
errors << "Field '#{field}' must be at least #{prop['minLength']} characters"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if prop['maxLength'] && value.length > prop['maxLength']
|
|
197
|
+
errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
errors
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Validate a number value against its property schema.
|
|
204
|
+
# @param field [String] field name
|
|
205
|
+
# @param value [Object] the value
|
|
206
|
+
# @param prop [Hash] property schema
|
|
207
|
+
# @return [Array<String>] validation errors
|
|
208
|
+
def self.validate_number_value(field, value, prop)
|
|
209
|
+
errors = []
|
|
210
|
+
|
|
211
|
+
unless value.is_a?(Numeric)
|
|
212
|
+
errors << "Field '#{field}' must be a number"
|
|
213
|
+
return errors
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
errors << "Field '#{field}' must be an integer" if prop['type'] == 'integer' && !value.is_a?(Integer)
|
|
217
|
+
|
|
218
|
+
errors << "Field '#{field}' must be >= #{prop['minimum']}" if prop['minimum'] && value < prop['minimum']
|
|
219
|
+
|
|
220
|
+
errors << "Field '#{field}' must be <= #{prop['maximum']}" if prop['maximum'] && value > prop['maximum']
|
|
221
|
+
|
|
222
|
+
errors
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Validate an array value against its property schema (multi-select enum).
|
|
226
|
+
# @param field [String] field name
|
|
227
|
+
# @param value [Object] the value
|
|
228
|
+
# @param prop [Hash] property schema
|
|
229
|
+
# @return [Array<String>] validation errors
|
|
230
|
+
def self.validate_array_value(field, value, prop)
|
|
231
|
+
errors = []
|
|
232
|
+
|
|
233
|
+
unless value.is_a?(Array)
|
|
234
|
+
errors << "Field '#{field}' must be an array"
|
|
235
|
+
return errors
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
items = prop['items'] || {}
|
|
239
|
+
allowed = if items['enum'].is_a?(Array)
|
|
240
|
+
items['enum']
|
|
241
|
+
elsif items['anyOf'].is_a?(Array)
|
|
242
|
+
items['anyOf'].map { |o| o['const'] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if allowed
|
|
246
|
+
value.each do |v|
|
|
247
|
+
errors << "Field '#{field}' contains invalid value '#{v}'" unless allowed.include?(v)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if prop['minItems'] && value.length < prop['minItems']
|
|
252
|
+
errors << "Field '#{field}' must have at least #{prop['minItems']} items"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if prop['maxItems'] && value.length > prop['maxItems']
|
|
256
|
+
errors << "Field '#{field}' must have at most #{prop['maxItems']} items"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
errors
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -50,5 +50,11 @@ module MCPClient
|
|
|
50
50
|
|
|
51
51
|
# Raised when transport type cannot be determined from target URL/command
|
|
52
52
|
class TransportDetectionError < MCPError; end
|
|
53
|
+
|
|
54
|
+
# Raised when a task is not found
|
|
55
|
+
class TaskNotFound < MCPError; end
|
|
56
|
+
|
|
57
|
+
# Raised when there's an error creating or managing a task
|
|
58
|
+
class TaskError < MCPError; end
|
|
53
59
|
end
|
|
54
60
|
end
|