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.
@@ -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-06-18)
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-06-18)
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-06-18)
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-06-18)
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
- # Call the user-defined handler
703
- result = case @elicitation_handler.arity
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
- @elicitation_handler.call(message, schema, metadata)
787
+ handle_form_elicitation(params, message)
712
788
  end
713
789
 
714
- # Validate and format response
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-06-18)
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 (messages, modelPreferences, systemPrompt, maxTokens)
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 = case @sampling_handler.arity
807
- when 0
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
- # Validate sampling response from handler (MCP 2025-06-18)
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
@@ -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