Merge pull request #344 from atesgoral/feature/output-schema-validation
feat: add opt-in tool output validation
This commit is contained in:
commit
075ae28655
5 changed files with 245 additions and 2 deletions
13
README.md
13
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue