ruby-mcp-client 1.1.0 → 2.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.
@@ -20,6 +20,8 @@ module MCPClient
20
20
  # @option options [String, nil] :name Optional name for this server
21
21
  # @option options [Logger, nil] :logger Optional logger
22
22
  # @option options [Object, nil] :storage Storage backend for OAuth tokens and client info
23
+ # @option options [String, nil] :client_id_metadata_url HTTPS URL of this client's
24
+ # Client ID Metadata Document (SEP-991)
23
25
  # @return [ServerHTTP] OAuth-enabled HTTP server
24
26
  def self.create_http_server(server_url:, **options)
25
27
  opts = default_server_options.merge(options)
@@ -29,7 +31,8 @@ module MCPClient
29
31
  redirect_uri: opts[:redirect_uri],
30
32
  scope: opts[:scope],
31
33
  logger: opts[:logger],
32
- storage: opts[:storage]
34
+ storage: opts[:storage],
35
+ client_id_metadata_url: opts[:client_id_metadata_url]
33
36
  )
34
37
 
35
38
  ServerHTTP.new(
@@ -57,7 +60,8 @@ module MCPClient
57
60
  redirect_uri: opts[:redirect_uri],
58
61
  scope: opts[:scope],
59
62
  logger: opts[:logger],
60
- storage: opts[:storage]
63
+ storage: opts[:storage],
64
+ client_id_metadata_url: opts[:client_id_metadata_url]
61
65
  )
62
66
 
63
67
  ServerStreamableHTTP.new(
@@ -120,7 +124,8 @@ module MCPClient
120
124
  retry_backoff: 1,
121
125
  name: nil,
122
126
  logger: nil,
123
- storage: nil
127
+ storage: nil,
128
+ client_id_metadata_url: nil
124
129
  }
125
130
  end
126
131
  end
@@ -5,23 +5,35 @@ module MCPClient
5
5
  class Prompt
6
6
  # @!attribute [r] name
7
7
  # @return [String] the name of the prompt
8
+ # @!attribute [r] title
9
+ # @return [String, nil] optional human-readable name of the prompt for display purposes
8
10
  # @!attribute [r] description
9
11
  # @return [String] the description of the prompt
10
12
  # @!attribute [r] arguments
11
13
  # @return [Hash] the JSON arguments for the prompt
14
+ # @!attribute [r] icons
15
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
16
+ # @!attribute [r] meta
17
+ # @return [Hash, nil] optional `_meta` metadata attached to the prompt (MCP 2025-11-25)
12
18
  # @!attribute [r] server
13
19
  # @return [MCPClient::ServerBase, nil] the server this prompt belongs to
14
- attr_reader :name, :description, :arguments, :server
20
+ attr_reader :name, :title, :description, :arguments, :icons, :meta, :server
15
21
 
16
22
  # Initialize a new prompt
17
23
  # @param name [String] the name of the prompt
18
24
  # @param description [String] the description of the prompt
19
25
  # @param arguments [Hash] the JSON arguments for the prompt
26
+ # @param title [String, nil] optional human-readable name of the prompt for display purposes
27
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
28
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the prompt (MCP 2025-11-25)
20
29
  # @param server [MCPClient::ServerBase, nil] the server this prompt belongs to
21
- def initialize(name:, description:, arguments: {}, server: nil)
30
+ def initialize(name:, description:, arguments: {}, title: nil, icons: nil, meta: nil, server: nil)
22
31
  @name = name
32
+ @title = title
23
33
  @description = description
24
34
  @arguments = arguments
35
+ @icons = icons
36
+ @meta = meta
25
37
  @server = server
26
38
  end
27
39
 
@@ -32,8 +44,11 @@ module MCPClient
32
44
  def self.from_json(data, server: nil)
33
45
  new(
34
46
  name: data['name'],
47
+ title: data['title'],
35
48
  description: data['description'],
36
49
  arguments: data['arguments'] || {},
50
+ icons: data['icons'],
51
+ meta: data['_meta'],
37
52
  server: server
38
53
  )
39
54
  end
@@ -17,9 +17,13 @@ module MCPClient
17
17
  # @return [Integer, nil] optional size in bytes
18
18
  # @!attribute [r] annotations
19
19
  # @return [Hash, nil] optional annotations that provide hints to clients
20
+ # @!attribute [r] icons
21
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
22
+ # @!attribute [r] meta
23
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource (MCP 2025-11-25)
20
24
  # @!attribute [r] server
21
25
  # @return [MCPClient::ServerBase, nil] the server this resource belongs to
22
- attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :server
26
+ attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :icons, :meta, :server
23
27
 
24
28
  # Initialize a new resource
25
29
  # @param uri [String] unique identifier for the resource
@@ -29,8 +33,12 @@ module MCPClient
29
33
  # @param mime_type [String, nil] optional MIME type
30
34
  # @param size [Integer, nil] optional size in bytes
31
35
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
36
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
37
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource (MCP 2025-11-25)
32
38
  # @param server [MCPClient::ServerBase, nil] the server this resource belongs to
33
- def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, server: nil)
39
+ # rubocop:disable Metrics/ParameterLists
40
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil,
41
+ icons: nil, meta: nil, server: nil)
34
42
  @uri = uri
35
43
  @name = name
36
44
  @title = title
@@ -38,8 +46,11 @@ module MCPClient
38
46
  @mime_type = mime_type
39
47
  @size = size
40
48
  @annotations = annotations
49
+ @icons = icons
50
+ @meta = meta
41
51
  @server = server
42
52
  end
53
+ # rubocop:enable Metrics/ParameterLists
43
54
 
44
55
  # Return the lastModified annotation value (ISO 8601 timestamp string)
45
56
  # @return [String, nil] the lastModified timestamp, or nil if not set
@@ -62,6 +73,8 @@ module MCPClient
62
73
  mime_type: data['mimeType'],
63
74
  size: data['size'],
64
75
  annotations: data['annotations'],
76
+ icons: data['icons'],
77
+ meta: data['_meta'],
65
78
  server: server
66
79
  )
67
80
  end
@@ -18,7 +18,9 @@ module MCPClient
18
18
  # @return [String, nil] base64-encoded binary content (mutually exclusive with text)
19
19
  # @!attribute [r] annotations
20
20
  # @return [Hash, nil] optional annotations that provide hints to clients
21
- attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations
21
+ # @!attribute [r] meta
22
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource contents (MCP 2025-11-25)
23
+ attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations, :meta
22
24
 
23
25
  # Initialize resource content
24
26
  # @param uri [String] unique identifier for the resource
@@ -28,7 +30,8 @@ module MCPClient
28
30
  # @param text [String, nil] text content (mutually exclusive with blob)
29
31
  # @param blob [String, nil] base64-encoded binary content (mutually exclusive with text)
30
32
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
31
- def initialize(uri:, name:, title: nil, mime_type: nil, text: nil, blob: nil, annotations: nil)
33
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource contents (MCP 2025-11-25)
34
+ def initialize(uri:, name:, title: nil, mime_type: nil, text: nil, blob: nil, annotations: nil, meta: nil)
32
35
  raise ArgumentError, 'ResourceContent cannot have both text and blob' if text && blob
33
36
  raise ArgumentError, 'ResourceContent must have either text or blob' if !text && !blob
34
37
 
@@ -39,6 +42,7 @@ module MCPClient
39
42
  @text = text
40
43
  @blob = blob
41
44
  @annotations = annotations
45
+ @meta = meta
42
46
  end
43
47
 
44
48
  # Create a ResourceContent instance from JSON data
@@ -52,7 +56,8 @@ module MCPClient
52
56
  mime_type: data['mimeType'],
53
57
  text: data['text'],
54
58
  blob: data['blob'],
55
- annotations: data['annotations']
59
+ annotations: data['annotations'],
60
+ meta: data['_meta']
56
61
  )
57
62
  end
58
63
 
@@ -19,7 +19,11 @@ module MCPClient
19
19
  # @return [String, nil] optional display title for the resource
20
20
  # @!attribute [r] size
21
21
  # @return [Integer, nil] optional size of the resource in bytes
22
- attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size
22
+ # @!attribute [r] icons
23
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
24
+ # @!attribute [r] meta
25
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource link (MCP 2025-11-25)
26
+ attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size, :icons, :meta
23
27
 
24
28
  # Initialize a resource link
25
29
  # @param uri [String] URI of the linked resource
@@ -29,7 +33,11 @@ module MCPClient
29
33
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
30
34
  # @param title [String, nil] optional display title for the resource
31
35
  # @param size [Integer, nil] optional size of the resource in bytes
32
- def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil)
36
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
37
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource link (MCP 2025-11-25)
38
+ # rubocop:disable Metrics/ParameterLists
39
+ def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil,
40
+ icons: nil, meta: nil)
33
41
  @uri = uri
34
42
  @name = name
35
43
  @description = description
@@ -37,7 +45,10 @@ module MCPClient
37
45
  @annotations = annotations
38
46
  @title = title
39
47
  @size = size
48
+ @icons = icons
49
+ @meta = meta
40
50
  end
51
+ # rubocop:enable Metrics/ParameterLists
41
52
 
42
53
  # Create a ResourceLink instance from JSON data
43
54
  # @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
@@ -50,7 +61,9 @@ module MCPClient
50
61
  mime_type: data['mimeType'],
51
62
  annotations: data['annotations'],
52
63
  title: data['title'],
53
- size: data['size']
64
+ size: data['size'],
65
+ icons: data['icons'],
66
+ meta: data['_meta']
54
67
  )
55
68
  end
56
69
 
@@ -16,9 +16,13 @@ module MCPClient
16
16
  # @return [String, nil] optional MIME type for resources created from this template
17
17
  # @!attribute [r] annotations
18
18
  # @return [Hash, nil] optional annotations that provide hints to clients
19
+ # @!attribute [r] icons
20
+ # @return [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25, SEP-973)
21
+ # @!attribute [r] meta
22
+ # @return [Hash, nil] optional `_meta` metadata attached to the resource template (MCP 2025-11-25)
19
23
  # @!attribute [r] server
20
24
  # @return [MCPClient::ServerBase, nil] the server this resource template belongs to
21
- attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :server
25
+ attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :icons, :meta, :server
22
26
 
23
27
  # Initialize a new resource template
24
28
  # @param uri_template [String] URI template following RFC 6570
@@ -27,16 +31,23 @@ module MCPClient
27
31
  # @param description [String, nil] optional description
28
32
  # @param mime_type [String, nil] optional MIME type
29
33
  # @param annotations [Hash, nil] optional annotations that provide hints to clients
34
+ # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
35
+ # @param meta [Hash, nil] optional `_meta` metadata attached to the resource template (MCP 2025-11-25)
30
36
  # @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
31
- def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil, server: nil)
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil,
39
+ icons: nil, meta: nil, server: nil)
32
40
  @uri_template = uri_template
33
41
  @name = name
34
42
  @title = title
35
43
  @description = description
36
44
  @mime_type = mime_type
37
45
  @annotations = annotations
46
+ @icons = icons
47
+ @meta = meta
38
48
  @server = server
39
49
  end
50
+ # rubocop:enable Metrics/ParameterLists
40
51
 
41
52
  # Create a ResourceTemplate instance from JSON data
42
53
  # @param data [Hash] JSON data from MCP server
@@ -50,6 +61,8 @@ module MCPClient
50
61
  description: data['description'],
51
62
  mime_type: data['mimeType'],
52
63
  annotations: data['annotations'],
64
+ icons: data['icons'],
65
+ meta: data['_meta'],
53
66
  server: server
54
67
  )
55
68
  end
@@ -1,26 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module MCPClient
4
6
  # Represents an MCP Root - a URI that defines a boundary where servers can operate
5
7
  # Roots are declared by clients to inform servers about relevant resources and their locations
6
8
  class Root
7
- attr_reader :uri, :name
9
+ attr_reader :uri, :name, :meta
8
10
 
9
11
  # Create a new Root
10
- # @param uri [String] The URI for the root (typically file:// URI)
12
+ # @param uri [String] The URI for the root. Per the MCP specification this
13
+ # MUST be a file:// URI ("This **MUST** be a `file://` URI in the current
14
+ # specification" - client/roots.mdx, 2025-11-25)
11
15
  # @param name [String, nil] Optional human-readable name for display purposes
12
- def initialize(uri:, name: nil)
16
+ # @param meta [Hash, nil] Optional _meta field attached to the root (schema.ts Root._meta)
17
+ # @raise [ArgumentError] if uri is not a valid file:// URI or contains '..' path segments
18
+ def initialize(uri:, name: nil, meta: nil)
19
+ validate_uri!(uri)
20
+ # Root._meta is an object of arbitrary keys per the schema
21
+ raise ArgumentError, "Root _meta must be a Hash, got #{meta.class}" if meta && !meta.is_a?(Hash)
22
+
13
23
  @uri = uri
14
24
  @name = name
25
+ @meta = meta
15
26
  end
16
27
 
17
28
  # Create a Root from a JSON hash
18
- # @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
29
+ # @param json [Hash] The JSON hash with 'uri' and optional 'name' and '_meta' keys
19
30
  # @return [Root]
31
+ # @raise [ArgumentError] if the uri is missing or not a valid file:// URI
20
32
  def self.from_json(json)
21
33
  new(
22
34
  uri: json['uri'] || json[:uri],
23
- name: json['name'] || json[:name]
35
+ name: json['name'] || json[:name],
36
+ meta: json['_meta'] || json[:_meta]
24
37
  )
25
38
  end
26
39
 
@@ -29,6 +42,7 @@ module MCPClient
29
42
  def to_h
30
43
  result = { 'uri' => @uri }
31
44
  result['name'] = @name if @name
45
+ result['_meta'] = @meta if @meta
32
46
  result
33
47
  end
34
48
 
@@ -42,13 +56,13 @@ module MCPClient
42
56
  def ==(other)
43
57
  return false unless other.is_a?(Root)
44
58
 
45
- uri == other.uri && name == other.name
59
+ uri == other.uri && name == other.name && meta == other.meta
46
60
  end
47
61
 
48
62
  alias eql? ==
49
63
 
50
64
  def hash
51
- [uri, name].hash
65
+ [uri, name, meta].hash
52
66
  end
53
67
 
54
68
  # String representation
@@ -59,5 +73,45 @@ module MCPClient
59
73
  def inspect
60
74
  "#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
61
75
  end
76
+
77
+ private
78
+
79
+ # Validate that the uri is a well-formed file:// URI without path traversal.
80
+ # Spec (client/roots.mdx, 2025-11-25): the root uri "MUST be a `file://` URI
81
+ # in the current specification", and clients "MUST ... Validate all root
82
+ # URIs to prevent path traversal".
83
+ # @param uri [Object] the uri to validate
84
+ # @return [void]
85
+ # @raise [ArgumentError] if the uri is invalid
86
+ def validate_uri!(uri)
87
+ raise ArgumentError, 'Root uri must be a String, got nil' if uri.nil?
88
+ raise ArgumentError, "Root uri must be a String, got #{uri.class}" unless uri.is_a?(String)
89
+
90
+ # The schema requires the literal file:// form ("must start with
91
+ # file://"), not merely a file scheme — file:relative and file:/path
92
+ # forms are rejected.
93
+ unless uri.downcase.start_with?('file://')
94
+ raise ArgumentError,
95
+ "Root uri must be a file:// URI (MCP spec: 'This MUST be a file:// URI'), got: #{uri.inspect}"
96
+ end
97
+
98
+ parsed = parse_uri(uri)
99
+ # Decode before the traversal check so percent-encoded segments
100
+ # (%2e%2e) cannot smuggle a '..' past validation.
101
+ decoded_path = URI::DEFAULT_PARSER.unescape(parsed.path.to_s)
102
+ return unless decoded_path.split('/').include?('..')
103
+
104
+ raise ArgumentError, "Root uri must not contain '..' path traversal segments, got: #{uri.inspect}"
105
+ end
106
+
107
+ # Parse a URI string, converting parse errors to ArgumentError
108
+ # @param uri [String] the uri string to parse
109
+ # @return [URI::Generic]
110
+ # @raise [ArgumentError] if the uri cannot be parsed
111
+ def parse_uri(uri)
112
+ URI.parse(uri)
113
+ rescue URI::InvalidURIError => e
114
+ raise ArgumentError, "Root uri is not a valid URI: #{uri.inspect} (#{e.message})"
115
+ end
62
116
  end
63
117
  end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Self-contained JSON Schema validator used to check a tool call result's
5
+ # structuredContent against the tool's declared outputSchema (MCP 2025-11-25
6
+ # server/tools spec: "Clients SHOULD validate structured results against this
7
+ # schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).
8
+ #
9
+ # Only the common JSON Schema keywords are supported:
10
+ # - type (single value or array of values), enum, const
11
+ # - properties, required (objects)
12
+ # - items, minItems, maxItems (arrays)
13
+ # - minLength, maxLength, pattern (strings)
14
+ # - minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
15
+ #
16
+ # The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not,
17
+ # conditional keywords, additionalProperties, format assertions, ...) is out
18
+ # of scope: unrecognized keywords are ignored rather than misapplied, so
19
+ # validation is best-effort — it may accept data a full validator would
20
+ # reject, but it does not reject data that conforms to the schema. So that
21
+ # this gap is never silent, {.unsupported_keywords} reports which unapplied
22
+ # validation keywords a schema uses; callers surface them as a warning.
23
+ module SchemaValidator
24
+ # JSON Schema 2020-12 keywords that affect validation but that this
25
+ # validator does not evaluate: applicator/reference keywords, assertion
26
+ # keywords (multipleOf, uniqueItems, contains bounds, property-count
27
+ # bounds, dependentRequired), and format (asserted by full validators in
28
+ # format-assertion mode). Their presence means validation is partial: data
29
+ # may pass here that a full validator would reject.
30
+ UNSUPPORTED_KEYWORDS = %w[
31
+ $ref $dynamicRef $defs allOf anyOf oneOf not if then else
32
+ additionalProperties patternProperties propertyNames dependentSchemas
33
+ prefixItems contains minContains maxContains uniqueItems
34
+ multipleOf format dependentRequired minProperties maxProperties
35
+ unevaluatedProperties unevaluatedItems
36
+ ].freeze
37
+
38
+ # Keywords whose value is a single subschema to walk.
39
+ SUBSCHEMA_KEYWORDS = %w[
40
+ items contains additionalProperties propertyNames not if then else
41
+ unevaluatedItems unevaluatedProperties
42
+ ].freeze
43
+
44
+ # Keywords whose value is a map of name => subschema.
45
+ SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
46
+
47
+ # Keywords whose value is an array of subschemas.
48
+ SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
49
+
50
+ # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
51
+ # top level or nested in subschemas). Property names that merely look like
52
+ # keywords (e.g. a property called 'not') are not reported, and
53
+ # data-carrying keywords (enum/const/default/examples) are not scanned.
54
+ # @param schema [Object] the JSON schema (string or symbol keys)
55
+ # @return [Array<String>] unique unsupported keywords, in discovery order
56
+ def self.unsupported_keywords(schema)
57
+ found = []
58
+ collect_unsupported_keywords(schema, found)
59
+ found.uniq
60
+ end
61
+
62
+ # Recursively collect unsupported keywords from a schema.
63
+ # @param schema [Object] a (sub)schema; non-Hash values are ignored
64
+ # @param found [Array<String>] accumulator
65
+ # @return [void]
66
+ def self.collect_unsupported_keywords(schema, found)
67
+ return unless schema.is_a?(Hash)
68
+
69
+ schema = schema.transform_keys(&:to_s)
70
+ found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
71
+ schema.each do |keyword, value|
72
+ if SUBSCHEMA_KEYWORDS.include?(keyword)
73
+ collect_unsupported_keywords(value, found)
74
+ elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
75
+ value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
76
+ elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
77
+ value.each { |subschema| collect_unsupported_keywords(subschema, found) }
78
+ end
79
+ end
80
+ end
81
+
82
+ # Validate data against a JSON Schema subset.
83
+ # Schema and data hashes may use string or symbol keys.
84
+ # @param data [Object] the value to validate
85
+ # @param schema [Hash] the JSON schema
86
+ # @param path [String] JSON-pointer-style location used in error messages
87
+ # @return [Array<String>] human-readable validation errors (empty if valid)
88
+ def self.validate(data, schema, path: '#')
89
+ return [] unless schema.is_a?(Hash)
90
+
91
+ schema = schema.transform_keys(&:to_s)
92
+ errors = []
93
+ errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
94
+ errors.concat(validate_enum(data, schema, path))
95
+ case data
96
+ when Hash then errors.concat(validate_object(data, schema, path))
97
+ when Array then errors.concat(validate_array(data, schema, path))
98
+ when String then errors.concat(validate_string(data, schema, path))
99
+ when Numeric then errors.concat(validate_number(data, schema, path))
100
+ end
101
+ errors
102
+ end
103
+
104
+ # Validate the JSON type of a value.
105
+ # @param data [Object] the value
106
+ # @param type [String, Symbol, Array<String, Symbol>] expected type(s)
107
+ # @param path [String] location for error messages
108
+ # @return [Array<String>] validation errors
109
+ def self.validate_type(data, type, path)
110
+ types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
111
+ return [] if types.any? { |t| type_match?(t, data) }
112
+
113
+ ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
114
+ end
115
+
116
+ # Whether a value matches a JSON Schema type name.
117
+ # Unknown type names are not enforced (returns true).
118
+ # @param type [String] the JSON Schema type name
119
+ # @param data [Object] the value
120
+ # @return [Boolean]
121
+ def self.type_match?(type, data)
122
+ case type
123
+ when 'object' then data.is_a?(Hash)
124
+ when 'array' then data.is_a?(Array)
125
+ when 'string' then data.is_a?(String)
126
+ when 'boolean' then data.equal?(true) || data.equal?(false)
127
+ when 'null' then data.nil?
128
+ when 'number' then data.is_a?(Numeric)
129
+ when 'integer' then integer?(data)
130
+ else true
131
+ end
132
+ end
133
+
134
+ # Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a
135
+ # number with a zero fractional part (e.g. 2.0) is a valid integer.
136
+ # @param data [Object] the value
137
+ # @return [Boolean]
138
+ def self.integer?(data)
139
+ return true if data.is_a?(Integer)
140
+ return false unless data.is_a?(Numeric)
141
+
142
+ (data % 1).zero?
143
+ end
144
+
145
+ # The JSON type name of a Ruby value (for error messages).
146
+ # @param data [Object] the value
147
+ # @return [String]
148
+ def self.json_type(data)
149
+ case data
150
+ when nil then 'null'
151
+ when true, false then 'boolean'
152
+ when Integer then 'integer'
153
+ when Numeric then 'number'
154
+ when String then 'string'
155
+ when Array then 'array'
156
+ when Hash then 'object'
157
+ else data.class.name
158
+ end
159
+ end
160
+
161
+ # Validate enum/const membership.
162
+ # @param data [Object] the value
163
+ # @param schema [Hash] string-keyed schema
164
+ # @param path [String] location for error messages
165
+ # @return [Array<String>] validation errors
166
+ def self.validate_enum(data, schema, path)
167
+ errors = []
168
+ if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
169
+ errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
170
+ end
171
+ if schema.key?('const') && schema['const'] != data
172
+ errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
173
+ end
174
+ errors
175
+ end
176
+
177
+ # Validate an object against required/properties.
178
+ # @param data [Hash] the object
179
+ # @param schema [Hash] string-keyed schema
180
+ # @param path [String] location for error messages
181
+ # @return [Array<String>] validation errors
182
+ def self.validate_object(data, schema, path)
183
+ errors = []
184
+ Array(schema['required']).each do |raw_name|
185
+ name = raw_name.to_s
186
+ errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
187
+ end
188
+ properties = schema['properties']
189
+ return errors unless properties.is_a?(Hash)
190
+
191
+ properties.each do |raw_name, prop_schema|
192
+ next unless prop_schema.is_a?(Hash)
193
+
194
+ name = raw_name.to_s
195
+ key = if data.key?(name)
196
+ name
197
+ elsif data.key?(name.to_sym)
198
+ name.to_sym
199
+ end
200
+ next if key.nil?
201
+
202
+ errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}"))
203
+ end
204
+ errors
205
+ end
206
+
207
+ # Validate an array against items/minItems/maxItems.
208
+ # @param data [Array] the array
209
+ # @param schema [Hash] string-keyed schema
210
+ # @param path [String] location for error messages
211
+ # @return [Array<String>] validation errors
212
+ def self.validate_array(data, schema, path)
213
+ errors = []
214
+ min_items = schema['minItems']
215
+ max_items = schema['maxItems']
216
+ if min_items.is_a?(Numeric) && data.length < min_items
217
+ errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
218
+ end
219
+ if max_items.is_a?(Numeric) && data.length > max_items
220
+ errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
221
+ end
222
+ items = schema['items']
223
+ if items.is_a?(Hash)
224
+ data.each_with_index { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
225
+ end
226
+ errors
227
+ end
228
+
229
+ # Validate a string against minLength/maxLength/pattern.
230
+ # @param data [String] the string
231
+ # @param schema [Hash] string-keyed schema
232
+ # @param path [String] location for error messages
233
+ # @return [Array<String>] validation errors
234
+ def self.validate_string(data, schema, path)
235
+ errors = []
236
+ min_length = schema['minLength']
237
+ max_length = schema['maxLength']
238
+ if min_length.is_a?(Numeric) && data.length < min_length
239
+ errors << "#{path}: string is shorter than minLength #{min_length}"
240
+ end
241
+ if max_length.is_a?(Numeric) && data.length > max_length
242
+ errors << "#{path}: string is longer than maxLength #{max_length}"
243
+ end
244
+ errors.concat(validate_pattern(data, schema['pattern'], path))
245
+ errors
246
+ end
247
+
248
+ # Validate a string against a regular-expression pattern.
249
+ # Invalid patterns are not enforced.
250
+ # @param data [String] the string
251
+ # @param pattern [Object] the pattern keyword value
252
+ # @param path [String] location for error messages
253
+ # @return [Array<String>] validation errors
254
+ def self.validate_pattern(data, pattern, path)
255
+ return [] unless pattern.is_a?(String)
256
+ return [] if data.match?(Regexp.new(pattern))
257
+
258
+ ["#{path}: string does not match pattern #{pattern.inspect}"]
259
+ rescue RegexpError
260
+ []
261
+ end
262
+
263
+ # Validate a number against inclusive/exclusive bounds.
264
+ # @param data [Numeric] the number
265
+ # @param schema [Hash] string-keyed schema
266
+ # @param path [String] location for error messages
267
+ # @return [Array<String>] validation errors
268
+ def self.validate_number(data, schema, path)
269
+ errors = []
270
+ minimum = schema['minimum']
271
+ maximum = schema['maximum']
272
+ exclusive_min = schema['exclusiveMinimum']
273
+ exclusive_max = schema['exclusiveMaximum']
274
+ errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
275
+ errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
276
+ if exclusive_min.is_a?(Numeric) && data <= exclusive_min
277
+ errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
278
+ end
279
+ if exclusive_max.is_a?(Numeric) && data >= exclusive_max
280
+ errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
281
+ end
282
+ errors
283
+ end
284
+ end
285
+ end