Merge pull request #344 from atesgoral/feature/output-schema-validation

feat: add opt-in tool output validation
This commit is contained in:
Koichi ITO 2026-05-13 10:37:16 +09:00 • committed by GitHub
commit 075ae28655
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 245 additions and 2 deletions

View file

@ -645,6 +645,19 @@ MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/l
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. To validate successful tool responses, enable `validate_tool_call_results`:
```ruby
configuration = MCP::Configuration.new(validate_tool_call_results: true)
server = MCP::Server.new(
name: "example_server",
tools: [WeatherTool],
configuration: configuration
)
```
When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. Error responses are not validated against the output schema.
### Tool Responses with Structured Content ### Tool Responses with Structured Content
Tools can return structured data alongside text content using the `structured_content` parameter. Tools can return structured data alongside text content using the `structured_content` parameter.

View file

@ -16,7 +16,7 @@ module MCP
attr_writer :instrumentation_callback attr_writer :instrumentation_callback
def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
validate_tool_call_arguments: true) validate_tool_call_arguments: true, validate_tool_call_results: false)
@exception_reporter = exception_reporter @exception_reporter = exception_reporter
@around_request = around_request @around_request = around_request
@instrumentation_callback = instrumentation_callback @instrumentation_callback = instrumentation_callback
@ -25,8 +25,10 @@ module MCP
validate_protocol_version!(protocol_version) validate_protocol_version!(protocol_version)
end end
validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
@validate_tool_call_arguments = validate_tool_call_arguments @validate_tool_call_arguments = validate_tool_call_arguments
@validate_tool_call_results = validate_tool_call_results
end end
def protocol_version=(protocol_version) def protocol_version=(protocol_version)
@ -41,6 +43,12 @@ module MCP
@validate_tool_call_arguments = validate_tool_call_arguments @validate_tool_call_arguments = validate_tool_call_arguments
end end
def validate_tool_call_results=(validate_tool_call_results)
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
@validate_tool_call_results = validate_tool_call_results
end
def protocol_version def protocol_version
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION @protocol_version || LATEST_STABLE_PROTOCOL_VERSION
end end
@ -80,11 +88,16 @@ module MCP
end end
attr_reader :validate_tool_call_arguments attr_reader :validate_tool_call_arguments
attr_reader :validate_tool_call_results
def validate_tool_call_arguments? def validate_tool_call_arguments?
!!@validate_tool_call_arguments !!@validate_tool_call_arguments
end end
def validate_tool_call_results?
!!@validate_tool_call_results
end
def merge(other) def merge(other)
return self if other.nil? return self if other.nil?
@ -113,6 +126,7 @@ module MCP
end end
validate_tool_call_arguments = other.validate_tool_call_arguments validate_tool_call_arguments = other.validate_tool_call_arguments
validate_tool_call_results = other.validate_tool_call_results
Configuration.new( Configuration.new(
exception_reporter: exception_reporter, exception_reporter: exception_reporter,
@ -120,6 +134,7 @@ module MCP
instrumentation_callback: instrumentation_callback, instrumentation_callback: instrumentation_callback,
protocol_version: protocol_version, protocol_version: protocol_version,
validate_tool_call_arguments: validate_tool_call_arguments, validate_tool_call_arguments: validate_tool_call_arguments,
validate_tool_call_results: validate_tool_call_results,
) )
end end
@ -138,6 +153,12 @@ module MCP
end end
end end
def validate_value_of_validate_tool_call_results!(validate_tool_call_results)
unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass)
raise ArgumentError, "validate_tool_call_results must be a boolean"
end
end
def default_exception_reporter def default_exception_reporter
@default_exception_reporter ||= ->(exception, server_context) {} @default_exception_reporter ||= ->(exception, server_context) {}
end end

View file

@ -586,9 +586,11 @@ module MCP
progress_token = request.dig(:_meta, :progressToken) progress_token = request.dig(:_meta, :progressToken)
call_tool_with_args( result = call_tool_with_args(
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
) )
validate_tool_call_result!(tool, result)
result
rescue RequestHandlerError, CancelledError rescue RequestHandlerError, CancelledError
# CancelledError is intentionally not wrapped so `handle_request` can turn it into # CancelledError is intentionally not wrapped so `handle_request` can turn it into
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
@ -745,6 +747,14 @@ module MCP
).to_h ).to_h
end end
def validate_tool_call_result!(tool, result)
return unless configuration.validate_tool_call_results
return unless tool.output_schema
return if result[:isError]
tool.output_schema.validate_result(result[:structuredContent])
end
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`. # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.

View file

@ -74,6 +74,14 @@ module MCP
assert_equal("validate_tool_call_arguments must be a boolean", exception.message) assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
end 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 test "merges protocol version from other configuration" do
config1 = Configuration.new(protocol_version: "2025-03-26") config1 = Configuration.new(protocol_version: "2025-03-26")
config2 = Configuration.new(protocol_version: "2025-06-18") config2 = Configuration.new(protocol_version: "2025-06-18")
@ -123,6 +131,40 @@ module MCP
refute merged.validate_tool_call_arguments refute merged.validate_tool_call_arguments
end 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 test "initializes with a default pass-through around_request" do
config = Configuration.new config = Configuration.new
called = false called = false
@ -183,5 +225,12 @@ module MCP
end end
assert_equal("validate_tool_call_arguments must be a boolean", exception.message) assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
end 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
end end

View file

@ -1767,6 +1767,156 @@ module MCP
assert_includes response[:result][:content][0][:text], "Invalid arguments" assert_includes response[:result][:content][0][:text], "Invalid arguments"
end 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 test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do
server = Server.new( server = Server.new(
tools: [TestTool], tools: [TestTool],