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.
- checksums.yaml +4 -4
- data/README.md +121 -3
- data/lib/mcp_client/auth/oauth_provider.rb +236 -26
- data/lib/mcp_client/auth.rb +40 -9
- data/lib/mcp_client/client.rb +417 -139
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +35 -1
- data/lib/mcp_client/http_transport_base.rb +227 -37
- data/lib/mcp_client/json_rpc_common.rb +126 -13
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +63 -8
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +10 -3
- data/lib/mcp_client/server_sse/sse_parser.rb +46 -9
- data/lib/mcp_client/server_sse.rb +49 -21
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +29 -6
- data/lib/mcp_client/server_stdio.rb +92 -17
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +238 -77
- data/lib/mcp_client/tool.rb +18 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +3 -2
|
@@ -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
|
data/lib/mcp_client/prompt.rb
CHANGED
|
@@ -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
|
data/lib/mcp_client/resource.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/mcp_client/root.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|