Skip to content

Commit 600c96f

Browse files
committed
Validate ToolCall Args against Schema
(cherry picked from commit eeb287117535dfb43785704171625323f01d5bda)
1 parent a601558 commit 600c96f

File tree

8 files changed

+275
-3
lines changed

8 files changed

+275
-3
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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def handle_request(request, method)
150150
report_exception(e, { request: request })
151151
if e.is_a?(RequestHandlerError)
152152
add_instrumentation_data(error: e.error_type)
153-
raise e
153+
raise StandardError, e.message
154154
end
155155

156156
add_instrumentation_data(error: :internal_error)
@@ -213,6 +213,15 @@ def call_tool(request)
213213
)
214214
end
215215

216+
if configuration.validate_tool_call_arguments && tool.input_schema
217+
begin
218+
tool.input_schema.validate_arguments(arguments)
219+
rescue Tool::InputSchema::ValidationError => e
220+
add_instrumentation_data(error: :invalid_schema)
221+
raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
222+
end
223+
end
224+
216225
begin
217226
call_params = tool_call_parameters(tool)
218227

lib/mcp/tool/input_schema.rb

Lines changed: 31 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,32 @@ 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+
schema = to_h
41+
begin
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+
rescue StandardError => e
52+
raise ArgumentError, "Invalid JSON Schema: #{e.message}"
53+
end
54+
end
2455
end
2556
end
2657
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("sorbet-static-and-runtime")
3334
end

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: 24 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,29 @@ 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
3054
end
3155
end
3256
end

test/mcp/tool_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ class InputSchemaTool < Tool
8282
assert_equal expected, tool.input_schema.to_h
8383
end
8484

85+
test "raises detailed error message for invalid schema" do
86+
error = assert_raises(ArgumentError) do
87+
Class.new(MCP::Tool) do
88+
input_schema(
89+
properties: {
90+
count: { type: "integer", minimum: "not a number" },
91+
},
92+
required: [:count],
93+
)
94+
end
95+
end
96+
97+
assert_includes error.message, "Invalid JSON Schema"
98+
assert_includes error.message, "#/properties/count/minimum"
99+
assert_includes error.message, "string did not match the following type: number"
100+
end
101+
85102
test ".define allows definition of simple tools with a block" do
86103
tool = Tool.define(name: "mock_tool", description: "a mock tool for testing") do |_|
87104
Tool::Response.new([{ type: "text", content: "OK" }])
@@ -226,5 +243,23 @@ def call(message:, server_context: nil)
226243
assert_equal response.content, [{ type: "text", content: "OK" }]
227244
assert_equal response.is_error, false
228245
end
246+
247+
test "input_schema rejects any $ref in schema" do
248+
schema_with_ref = {
249+
properties: {
250+
foo: { "$ref" => "#/definitions/bar" },
251+
},
252+
required: [],
253+
definitions: {
254+
bar: { type: "string" },
255+
},
256+
}
257+
error = assert_raises(ArgumentError) do
258+
Class.new(MCP::Tool) do
259+
input_schema schema_with_ref
260+
end
261+
end
262+
assert_match(/Invalid JSON Schema/, error.message)
263+
end
229264
end
230265
end

0 commit comments

Comments
 (0)