Skip to content

Commit b862468

Browse files
committed
Refactor schema validation to reduce duplication and add additionalProperties support
This refactoring extracts common schema validation logic into a base Schema class, eliminating ~100 lines of duplicate code between InputSchema and OutputSchema. It also adds support for the JSON Schema `additionalProperties` keyword to allow fine-grained control over extra properties in tool arguments. Previously `additionalParameters` could be set only on OutputSchema. Key changes: - Created MCP::Tool::Schema base class consolidating: - Schema validation against JSON Schema Draft 4 metaschema - Deep key transformation (string to symbol conversion) - $ref disallowance checking - Common validation methods - Refactored InputSchema to inherit from Schema: - Simplified constructor to accept full schema hash - Changed required fields storage from symbols to strings - Removed ~40 lines of duplicated validation logic - Refactored OutputSchema to inherit from Schema: - Removed ~54 lines of duplicated validation logic - Now shares all common functionality with InputSchema - Updated Tool.input_schema setter: - Simplified to pass full hash directly to InputSchema - Added additionalProperties support: - By default, tools now allow additional properties (JSON Schema default) - Can explicitly set `additionalProperties: false` to disallow extras - Added comprehensive test coverage for both behaviors
1 parent 85b81a6 commit b862468

File tree

8 files changed

+209
-131
lines changed

8 files changed

+209
-131
lines changed

lib/mcp/tool.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ def input_schema(value = NOT_SET)
7575
if value == NOT_SET
7676
input_schema_value
7777
elsif value.is_a?(Hash)
78-
properties = value[:properties] || value["properties"] || {}
79-
required = value[:required] || value["required"] || []
80-
@input_schema_value = InputSchema.new(properties:, required:)
78+
@input_schema_value = InputSchema.new(value)
8179
elsif value.is_a?(InputSchema)
8280
@input_schema_value = value
8381
end

lib/mcp/tool/input_schema.rb

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,28 @@
11
# frozen_string_literal: true
22

3-
require "json-schema"
3+
require_relative "schema"
44

55
module MCP
66
class Tool
7-
class InputSchema
7+
class InputSchema < Schema
88
class ValidationError < StandardError; end
99

10-
attr_reader :properties, :required
11-
12-
def initialize(properties: {}, required: [])
13-
@properties = properties
14-
@required = required.map(&:to_sym)
15-
validate_schema!
16-
end
17-
18-
def ==(other)
19-
other.is_a?(InputSchema) && properties == other.properties && required == other.required
20-
end
21-
22-
def to_h
23-
{ type: "object" }.tap do |hsh|
24-
hsh[:properties] = properties if properties.any?
25-
hsh[:required] = required if required.any?
26-
end
27-
end
28-
2910
def missing_required_arguments?(arguments)
3011
missing_required_arguments(arguments).any?
3112
end
3213

3314
def missing_required_arguments(arguments)
34-
(required - arguments.keys.map(&:to_sym))
15+
return [] unless schema[:required].is_a?(Array)
16+
17+
(schema[:required] - arguments.keys.map(&:to_s))
3518
end
3619

3720
def validate_arguments(arguments)
38-
errors = JSON::Validator.fully_validate(to_h, arguments)
21+
errors = fully_validate(arguments)
3922
if errors.any?
4023
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
4124
end
4225
end
43-
44-
private
45-
46-
def validate_schema!
47-
check_for_refs!
48-
schema = to_h
49-
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
50-
schema_reader = JSON::Schema::Reader.new(
51-
accept_uri: false,
52-
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
53-
)
54-
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
55-
# Converts metaschema to a file URI for cross-platform compatibility
56-
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
57-
metaschema = metaschema_uri.to_s
58-
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
59-
if errors.any?
60-
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
61-
end
62-
end
63-
64-
def check_for_refs!(obj = properties)
65-
case obj
66-
when Hash
67-
if obj.key?("$ref") || obj.key?(:$ref)
68-
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
69-
end
70-
71-
obj.each_value { |value| check_for_refs!(value) }
72-
when Array
73-
obj.each { |item| check_for_refs!(item) }
74-
end
75-
end
7626
end
7727
end
7828
end

lib/mcp/tool/output_schema.rb

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,18 @@
11
# frozen_string_literal: true
22

3-
require "json-schema"
3+
require_relative "schema"
44

55
module MCP
66
class Tool
7-
class OutputSchema
7+
class OutputSchema < Schema
88
class ValidationError < StandardError; end
99

10-
attr_reader :schema
11-
12-
def initialize(schema = {})
13-
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
14-
@schema[:type] ||= "object"
15-
validate_schema!
16-
end
17-
18-
def ==(other)
19-
other.is_a?(OutputSchema) && schema == other.schema
20-
end
21-
22-
def to_h
23-
@schema
24-
end
25-
2610
def validate_result(result)
27-
errors = JSON::Validator.fully_validate(to_h, result)
11+
errors = fully_validate(result)
2812
if errors.any?
2913
raise ValidationError, "Invalid result: #{errors.join(", ")}"
3014
end
3115
end
32-
33-
private
34-
35-
def deep_transform_keys(schema, &block)
36-
case schema
37-
when Hash
38-
schema.each_with_object({}) do |(key, value), result|
39-
if key.casecmp?("$ref")
40-
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
41-
end
42-
43-
result[yield(key)] = deep_transform_keys(value, &block)
44-
end
45-
when Array
46-
schema.map { |e| deep_transform_keys(e, &block) }
47-
else
48-
schema
49-
end
50-
end
51-
52-
def validate_schema!
53-
schema = to_h
54-
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
55-
schema_reader = JSON::Schema::Reader.new(
56-
accept_uri: false,
57-
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
58-
)
59-
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
60-
# Converts metaschema to a file URI for cross-platform compatibility
61-
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
62-
metaschema = metaschema_uri.to_s
63-
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
64-
if errors.any?
65-
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
66-
end
67-
end
6816
end
6917
end
7018
end

lib/mcp/tool/schema.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
require "json-schema"
4+
5+
module MCP
6+
class Tool
7+
class Schema
8+
attr_reader :schema
9+
10+
def initialize(schema = {})
11+
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
12+
@schema[:type] ||= "object"
13+
validate_schema!
14+
end
15+
16+
def ==(other)
17+
other.is_a?(self.class) && schema == other.schema
18+
end
19+
20+
def to_h
21+
@schema
22+
end
23+
24+
private
25+
26+
def fully_validate(data)
27+
JSON::Validator.fully_validate(to_h, data)
28+
end
29+
30+
def deep_transform_keys(schema, &block)
31+
case schema
32+
when Hash
33+
schema.each_with_object({}) do |(key, value), result|
34+
if key.casecmp?("$ref")
35+
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool schemas"
36+
end
37+
38+
result[yield(key)] = deep_transform_keys(value, &block)
39+
end
40+
when Array
41+
schema.map { |e| deep_transform_keys(e, &block) }
42+
else
43+
schema
44+
end
45+
end
46+
47+
def validate_schema!
48+
schema = to_h
49+
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
50+
schema_reader = JSON::Schema::Reader.new(
51+
accept_uri: false,
52+
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
53+
)
54+
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
55+
# Converts metaschema to a file URI for cross-platform compatibility
56+
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
57+
metaschema = metaschema_uri.to_s
58+
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
59+
if errors.any?
60+
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
61+
end
62+
end
63+
end
64+
end
65+
end

test/mcp/server_test.rb

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,62 @@ def call(message:, server_context: nil)
10961096
assert_equal "OK", response[:result][:content][0][:content]
10971097
end
10981098

1099+
test "tools/call allows additional properties by default" do
1100+
server = Server.new(
1101+
tools: [TestTool],
1102+
configuration: Configuration.new(validate_tool_call_arguments: true),
1103+
)
1104+
1105+
response = server.handle(
1106+
{
1107+
jsonrpc: "2.0",
1108+
id: 1,
1109+
method: "tools/call",
1110+
params: {
1111+
name: "test_tool",
1112+
arguments: {
1113+
message: "Hello, world!",
1114+
other_property: "I am allowed",
1115+
},
1116+
},
1117+
},
1118+
)
1119+
1120+
assert_equal "2.0", response[:jsonrpc]
1121+
assert_equal 1, response[:id]
1122+
assert response[:result], "Expected result key in response"
1123+
assert_equal "text", response[:result][:content][0][:type]
1124+
assert_equal "OK", response[:result][:content][0][:content]
1125+
end
1126+
1127+
test "tools/call disallows additional properties when additionalProperties set to false" do
1128+
server = Server.new(
1129+
tools: [TestToolWithAdditionalPropertiesSetToFalse],
1130+
configuration: Configuration.new(validate_tool_call_arguments: true),
1131+
)
1132+
1133+
response = server.handle(
1134+
{
1135+
jsonrpc: "2.0",
1136+
id: 1,
1137+
method: "tools/call",
1138+
params: {
1139+
name: "test_tool_with_additional_properties_set_to_false",
1140+
arguments: {
1141+
message: "Hello, world!",
1142+
extra: 123,
1143+
},
1144+
},
1145+
},
1146+
)
1147+
1148+
assert_equal "2.0", response[:jsonrpc]
1149+
assert_equal 1, response[:id]
1150+
assert_nil response[:error], "Expected no JSON-RPC error"
1151+
assert response[:result][:isError]
1152+
assert_includes response[:result][:content][0][:text], "Invalid arguments"
1153+
end
1154+
10991155
test "tools/call with no args" do
11001156
server = Server.new(tools: [@tool_with_no_args])
11011157

@@ -1123,7 +1179,19 @@ class TestTool < Tool
11231179
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
11241180

11251181
class << self
1126-
def call(message:, server_context: nil)
1182+
def call(server_context: nil, **kwargs)
1183+
Tool::Response.new([{ type: "text", content: "OK" }])
1184+
end
1185+
end
1186+
end
1187+
1188+
class TestToolWithAdditionalPropertiesSetToFalse < Tool
1189+
tool_name "test_tool_with_additional_properties_set_to_false"
1190+
description "a test tool with additionalProperties set to false for testing"
1191+
input_schema({ properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false })
1192+
1193+
class << self
1194+
def call(server_context: nil, **kwargs)
11271195
Tool::Response.new([{ type: "text", content: "OK" }])
11281196
end
11291197
end

0 commit comments

Comments
 (0)