Skip to content

Commit 8dda58e

Browse files
Merge pull request #43 from tylerrowsell/tool-arg-validation
Validate ToolCall Args against Schema
2 parents ffa06b1 + 8dee60c commit 8dda58e

File tree

8 files changed

+306
-2
lines changed

8 files changed

+306
-2
lines changed

lib/mcp/configuration.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@ module MCP
44
class Configuration
55
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
66

7-
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version
7+
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version, :validate_tool_call_arguments
88

9-
def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil)
9+
def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil,
10+
validate_tool_call_arguments: true)
1011
@exception_reporter = exception_reporter
1112
@instrumentation_callback = instrumentation_callback
1213
@protocol_version = protocol_version
14+
unless validate_tool_call_arguments.is_a?(TrueClass) || validate_tool_call_arguments.is_a?(FalseClass)
15+
raise ArgumentError, "validate_tool_call_arguments must be a boolean"
16+
end
17+
18+
@validate_tool_call_arguments = validate_tool_call_arguments
1319
end
1420

1521
def protocol_version
@@ -36,6 +42,12 @@ def instrumentation_callback?
3642
!@instrumentation_callback.nil?
3743
end
3844

45+
attr_reader :validate_tool_call_arguments
46+
47+
def validate_tool_call_arguments?
48+
!!@validate_tool_call_arguments
49+
end
50+
3951
def merge(other)
4052
return self if other.nil?
4153

@@ -54,11 +66,13 @@ def merge(other)
5466
else
5567
@protocol_version
5668
end
69+
validate_tool_call_arguments = other.validate_tool_call_arguments
5770

5871
Configuration.new(
5972
exception_reporter:,
6073
instrumentation_callback:,
6174
protocol_version:,
75+
validate_tool_call_arguments:,
6276
)
6377
end
6478

lib/mcp/server.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,15 @@ def call_tool(request)
233233
)
234234
end
235235

236+
if configuration.validate_tool_call_arguments && tool.input_schema
237+
begin
238+
tool.input_schema.validate_arguments(arguments)
239+
rescue Tool::InputSchema::ValidationError => e
240+
add_instrumentation_data(error: :invalid_schema)
241+
raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
242+
end
243+
end
244+
236245
begin
237246
call_params = tool_call_parameters(tool)
238247

lib/mcp/tool/input_schema.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
# frozen_string_literal: true
22

3+
require "json-schema"
4+
35
module MCP
46
class Tool
57
class InputSchema
8+
class ValidationError < StandardError; end
9+
610
attr_reader :properties, :required
711

812
def initialize(properties: {}, required: [])
913
@properties = properties
1014
@required = required.map(&:to_sym)
15+
validate_schema!
1116
end
1217

1318
def to_h
@@ -21,6 +26,42 @@ def missing_required_arguments?(arguments)
2126
def missing_required_arguments(arguments)
2227
(required - arguments.keys.map(&:to_sym))
2328
end
29+
30+
def validate_arguments(arguments)
31+
errors = JSON::Validator.fully_validate(to_h, arguments)
32+
if errors.any?
33+
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
34+
end
35+
end
36+
37+
private
38+
39+
def validate_schema!
40+
check_for_refs!
41+
schema = to_h
42+
schema_reader = JSON::Schema::Reader.new(
43+
accept_uri: false,
44+
accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
45+
)
46+
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
47+
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
48+
if errors.any?
49+
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
50+
end
51+
end
52+
53+
def check_for_refs!(obj = properties)
54+
case obj
55+
when Hash
56+
if obj.key?("$ref") || obj.key?(:$ref)
57+
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
58+
end
59+
60+
obj.each_value { |value| check_for_refs!(value) }
61+
when Array
62+
obj.each { |item| check_for_refs!(item) }
63+
end
64+
end
2465
end
2566
end
2667
end

mcp.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
2828
spec.require_paths = ["lib"]
2929

3030
spec.add_dependency("json_rpc_handler", "~> 0.1")
31+
spec.add_dependency("json-schema", "~> 4.1")
3132
spec.add_development_dependency("activesupport")
3233
spec.add_development_dependency("puma", ">= 5.0.0")
3334
spec.add_development_dependency("rack", ">= 2.0.0")

test/mcp/configuration_test.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,45 @@ class ConfigurationTest < ActiveSupport::TestCase
6161
merged = config3.merge(config1)
6262
assert_equal "2025-03-27", merged.protocol_version
6363
end
64+
65+
test "defaults validate_tool_call_arguments to true" do
66+
config = Configuration.new
67+
assert config.validate_tool_call_arguments
68+
end
69+
70+
test "can set validate_tool_call_arguments to false" do
71+
config = Configuration.new(validate_tool_call_arguments: false)
72+
refute config.validate_tool_call_arguments
73+
end
74+
75+
test "validate_tool_call_arguments? returns false when set" do
76+
config = Configuration.new(validate_tool_call_arguments: false)
77+
refute config.validate_tool_call_arguments?
78+
end
79+
80+
test "validate_tool_call_arguments? returns true when not set" do
81+
config = Configuration.new
82+
assert config.validate_tool_call_arguments?
83+
end
84+
85+
test "merge preserves validate_tool_call_arguments from other config" do
86+
config1 = Configuration.new(validate_tool_call_arguments: false)
87+
config2 = Configuration.new
88+
merged = config1.merge(config2)
89+
assert merged.validate_tool_call_arguments?
90+
end
91+
92+
test "merge preserves validate_tool_call_arguments from self when other not set" do
93+
config1 = Configuration.new(validate_tool_call_arguments: false)
94+
config2 = Configuration.new
95+
merged = config2.merge(config1)
96+
refute merged.validate_tool_call_arguments
97+
end
98+
99+
test "raises ArgumentError when validate_tool_call_arguments is not a boolean" do
100+
assert_raises(ArgumentError) do
101+
Configuration.new(validate_tool_call_arguments: "true")
102+
end
103+
end
64104
end
65105
end

test/mcp/server_test.rb

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,5 +839,123 @@ def call(message:, server_context: nil)
839839

840840
refute_includes server_without_resources.capabilities, :resources
841841
end
842+
843+
test "tools/call validates arguments against input schema when validate_tool_call_arguments is true" do
844+
server = Server.new(
845+
tools: [TestTool],
846+
configuration: Configuration.new(validate_tool_call_arguments: true),
847+
)
848+
849+
response = server.handle(
850+
{
851+
jsonrpc: "2.0",
852+
id: 1,
853+
method: "tools/call",
854+
params: {
855+
name: "test_tool",
856+
arguments: { message: 123 },
857+
},
858+
},
859+
)
860+
861+
assert_equal "2.0", response[:jsonrpc]
862+
assert_equal 1, response[:id]
863+
assert_equal(-32603, response[:error][:code])
864+
assert_includes response[:error][:data], "Invalid arguments"
865+
end
866+
867+
test "tools/call skips argument validation when validate_tool_call_arguments is false" do
868+
server = Server.new(
869+
tools: [TestTool],
870+
configuration: Configuration.new(validate_tool_call_arguments: false),
871+
)
872+
873+
response = server.handle(
874+
{
875+
jsonrpc: "2.0",
876+
id: 1,
877+
method: "tools/call",
878+
params: {
879+
name: "test_tool",
880+
arguments: { message: 123 },
881+
},
882+
},
883+
)
884+
885+
assert_equal "2.0", response[:jsonrpc]
886+
assert_equal 1, response[:id]
887+
assert response[:result], "Expected result key in response"
888+
assert_equal "text", response[:result][:content][0][:type]
889+
assert_equal "OK", response[:result][:content][0][:content]
890+
end
891+
892+
test "tools/call validates arguments with complex types" do
893+
server = Server.new(
894+
tools: [ComplexTypesTool],
895+
configuration: Configuration.new(validate_tool_call_arguments: true),
896+
)
897+
898+
response = server.handle(
899+
{
900+
jsonrpc: "2.0",
901+
id: 1,
902+
method: "tools/call",
903+
params: {
904+
name: "complex_types_tool",
905+
arguments: {
906+
numbers: [1, 2, 3],
907+
strings: ["a", "b", "c"],
908+
objects: [{ name: "test" }],
909+
},
910+
},
911+
},
912+
)
913+
914+
assert_equal "2.0", response[:jsonrpc]
915+
assert_equal 1, response[:id]
916+
assert response[:result], "Expected result key in response"
917+
assert_equal "text", response[:result][:content][0][:type]
918+
assert_equal "OK", response[:result][:content][0][:content]
919+
end
920+
921+
class TestTool < Tool
922+
tool_name "test_tool"
923+
description "a test tool for testing"
924+
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
925+
926+
class << self
927+
def call(message:, server_context: nil)
928+
Tool::Response.new([{ type: "text", content: "OK" }])
929+
end
930+
end
931+
end
932+
933+
class ComplexTypesTool < Tool
934+
tool_name "complex_types_tool"
935+
description "a test tool with complex types"
936+
input_schema({
937+
properties: {
938+
numbers: { type: "array", items: { type: "number" } },
939+
strings: { type: "array", items: { type: "string" } },
940+
objects: {
941+
type: "array",
942+
items: {
943+
type: "object",
944+
properties: {
945+
name: { type: "string" },
946+
},
947+
required: ["name"],
948+
},
949+
},
950+
},
951+
required: ["numbers", "strings", "objects"],
952+
})
953+
954+
class << self
955+
def call(numbers:, strings:, objects:, server_context: nil)
956+
Tool::Response.new([{ type: "text", content: "OK" }])
957+
end
958+
end
959+
end
842960
end
843961
end

test/mcp/tool/input_schema_test.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "test_helper"
4+
require "mcp/tool/input_schema"
45

56
module MCP
67
class Tool
@@ -27,6 +28,51 @@ class InputSchemaTest < ActiveSupport::TestCase
2728
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
2829
assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" })
2930
end
31+
32+
test "valid schema initialization" do
33+
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
34+
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: [:foo] }, schema.to_h)
35+
end
36+
37+
test "invalid schema raises argument error" do
38+
assert_raises(ArgumentError) do
39+
InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo])
40+
end
41+
end
42+
43+
test "validate arguments with valid data" do
44+
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
45+
assert_nil(schema.validate_arguments({ foo: "bar" }))
46+
end
47+
48+
test "validate arguments with invalid data" do
49+
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
50+
assert_raises(InputSchema::ValidationError) do
51+
schema.validate_arguments({ foo: 123 })
52+
end
53+
end
54+
55+
test "unexpected errors bubble up from validate_arguments" do
56+
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
57+
58+
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
59+
assert_raises(RuntimeError) do
60+
schema.validate_arguments({ foo: "bar" })
61+
end
62+
end
63+
end
64+
65+
test "rejects schemas with $ref references" do
66+
assert_raises(ArgumentError) do
67+
InputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo])
68+
end
69+
end
70+
71+
test "rejects schemas with symbol $ref references" do
72+
assert_raises(ArgumentError) do
73+
InputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo])
74+
end
75+
end
3076
end
3177
end
3278
end

0 commit comments

Comments
 (0)