feat: add opt-in tool output validation
This commit is contained in:
parent
d1e0afa158
commit
407b064140
2 changed files with 245 additions and 2 deletions
|
|
@ -74,6 +74,14 @@ module MCP
|
|||
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
|
||||
end
|
||||
|
||||
test "raises ArgumentError when validate_tool_call_results is not a boolean value" do
|
||||
config = Configuration.new
|
||||
exception = assert_raises(ArgumentError) do
|
||||
config.validate_tool_call_results = "true"
|
||||
end
|
||||
assert_equal("validate_tool_call_results must be a boolean", exception.message)
|
||||
end
|
||||
|
||||
test "merges protocol version from other configuration" do
|
||||
config1 = Configuration.new(protocol_version: "2025-03-26")
|
||||
config2 = Configuration.new(protocol_version: "2025-06-18")
|
||||
|
|
@ -123,6 +131,40 @@ module MCP
|
|||
refute merged.validate_tool_call_arguments
|
||||
end
|
||||
|
||||
test "defaults validate_tool_call_results to false" do
|
||||
config = Configuration.new
|
||||
refute config.validate_tool_call_results
|
||||
end
|
||||
|
||||
test "can set validate_tool_call_results to true" do
|
||||
config = Configuration.new(validate_tool_call_results: true)
|
||||
assert config.validate_tool_call_results
|
||||
end
|
||||
|
||||
test "validate_tool_call_results? returns false when not set" do
|
||||
config = Configuration.new
|
||||
refute config.validate_tool_call_results?
|
||||
end
|
||||
|
||||
test "validate_tool_call_results? returns true when set" do
|
||||
config = Configuration.new(validate_tool_call_results: true)
|
||||
assert config.validate_tool_call_results?
|
||||
end
|
||||
|
||||
test "merge preserves validate_tool_call_results from other config" do
|
||||
config1 = Configuration.new(validate_tool_call_results: true)
|
||||
config2 = Configuration.new
|
||||
merged = config1.merge(config2)
|
||||
refute merged.validate_tool_call_results?
|
||||
end
|
||||
|
||||
test "merge preserves validate_tool_call_results from self when other set" do
|
||||
config1 = Configuration.new(validate_tool_call_results: true)
|
||||
config2 = Configuration.new
|
||||
merged = config2.merge(config1)
|
||||
assert merged.validate_tool_call_results
|
||||
end
|
||||
|
||||
test "initializes with a default pass-through around_request" do
|
||||
config = Configuration.new
|
||||
called = false
|
||||
|
|
@ -183,5 +225,12 @@ module MCP
|
|||
end
|
||||
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
|
||||
end
|
||||
|
||||
test "raises ArgumentError when validate_tool_call_results is not a boolean" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
Configuration.new(validate_tool_call_results: "true")
|
||||
end
|
||||
assert_equal("validate_tool_call_results must be a boolean", exception.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1767,6 +1767,156 @@ module MCP
|
|||
assert_includes response[:result][:content][0][:text], "Invalid arguments"
|
||||
end
|
||||
|
||||
test "tools/call skips output schema validation by default" do
|
||||
tool = Tool.define(
|
||||
name: "invalid_structured_content_tool",
|
||||
output_schema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
) do
|
||||
Tool::Response.new(
|
||||
[{ type: "text", text: "ok" }],
|
||||
structured_content: { result: 123 },
|
||||
)
|
||||
end
|
||||
server = Server.new(tools: [tool])
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "invalid_structured_content_tool" },
|
||||
})
|
||||
|
||||
assert_nil response[:error]
|
||||
assert_equal({ result: 123 }, response[:result][:structuredContent])
|
||||
end
|
||||
|
||||
test "tools/call validates structuredContent against output schema when enabled" do
|
||||
tool = Tool.define(
|
||||
name: "valid_structured_content_tool",
|
||||
output_schema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
) do
|
||||
Tool::Response.new(
|
||||
[{ type: "text", text: "ok" }],
|
||||
structured_content: { result: "success" },
|
||||
)
|
||||
end
|
||||
server = Server.new(
|
||||
tools: [tool],
|
||||
configuration: Configuration.new(validate_tool_call_results: true),
|
||||
)
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "valid_structured_content_tool" },
|
||||
})
|
||||
|
||||
assert_nil response[:error]
|
||||
assert_equal({ result: "success" }, response[:result][:structuredContent])
|
||||
end
|
||||
|
||||
test "tools/call returns JSON-RPC error for invalid structuredContent when output schema validation is enabled" do
|
||||
tool = Tool.define(
|
||||
name: "invalid_structured_content_tool",
|
||||
output_schema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
) do
|
||||
Tool::Response.new(
|
||||
[{ type: "text", text: "ok" }],
|
||||
structured_content: { result: 123 },
|
||||
)
|
||||
end
|
||||
server = Server.new(
|
||||
tools: [tool],
|
||||
configuration: Configuration.new(validate_tool_call_results: true),
|
||||
)
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "invalid_structured_content_tool" },
|
||||
})
|
||||
|
||||
assert_nil response[:result]
|
||||
assert_equal(-32603, response[:error][:code])
|
||||
assert_equal "Internal error", response[:error][:message]
|
||||
assert_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, response[:error][:data])
|
||||
end
|
||||
|
||||
test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do
|
||||
tool = Tool.define(
|
||||
name: "missing_structured_content_tool",
|
||||
output_schema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
) do
|
||||
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||
end
|
||||
server = Server.new(
|
||||
tools: [tool],
|
||||
configuration: Configuration.new(validate_tool_call_results: true),
|
||||
)
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "missing_structured_content_tool" },
|
||||
})
|
||||
|
||||
assert_nil response[:result]
|
||||
assert_equal(-32603, response[:error][:code])
|
||||
assert_equal "Internal error", response[:error][:message]
|
||||
assert_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, response[:error][:data])
|
||||
end
|
||||
|
||||
test "tools/call skips output schema validation for error responses" do
|
||||
tool = Tool.define(
|
||||
name: "error_response_tool",
|
||||
output_schema: {
|
||||
type: "object",
|
||||
properties: { result: { type: "string" } },
|
||||
required: ["result"],
|
||||
},
|
||||
) do
|
||||
Tool::Response.new(
|
||||
[{ type: "text", text: "failed" }],
|
||||
error: true,
|
||||
structured_content: { result: 123 },
|
||||
)
|
||||
end
|
||||
server = Server.new(
|
||||
tools: [tool],
|
||||
configuration: Configuration.new(validate_tool_call_results: true),
|
||||
)
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "error_response_tool" },
|
||||
})
|
||||
|
||||
assert_nil response[:error]
|
||||
assert response[:result][:isError]
|
||||
assert_equal({ result: 123 }, response[:result][:structuredContent])
|
||||
end
|
||||
|
||||
test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do
|
||||
server = Server.new(
|
||||
tools: [TestTool],
|
||||
|
|
|
|||
Loading…
Reference in a new issue