Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Tools can return structured data alongside text content using the `structured_content` parameter.
Expand Down
23 changes: 22 additions & 1 deletion lib/mcp/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class Configuration
attr_writer :instrumentation_callback

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
@around_request = around_request
@instrumentation_callback = instrumentation_callback
Expand All @@ -25,8 +25,10 @@ def initialize(exception_reporter: nil, around_request: nil, instrumentation_cal
validate_protocol_version!(protocol_version)
end
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_results = validate_tool_call_results
end

def protocol_version=(protocol_version)
Expand All @@ -41,6 +43,12 @@ def validate_tool_call_arguments=(validate_tool_call_arguments)
@validate_tool_call_arguments = validate_tool_call_arguments
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
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
end
Expand Down Expand Up @@ -80,11 +88,16 @@ def instrumentation_callback?
end

attr_reader :validate_tool_call_arguments
attr_reader :validate_tool_call_results

def validate_tool_call_arguments?
!!@validate_tool_call_arguments
end

def validate_tool_call_results?
!!@validate_tool_call_results
end

def merge(other)
return self if other.nil?

Expand Down Expand Up @@ -113,13 +126,15 @@ def merge(other)
end

validate_tool_call_arguments = other.validate_tool_call_arguments
validate_tool_call_results = other.validate_tool_call_results

Configuration.new(
exception_reporter: exception_reporter,
around_request: around_request,
instrumentation_callback: instrumentation_callback,
protocol_version: protocol_version,
validate_tool_call_arguments: validate_tool_call_arguments,
validate_tool_call_results: validate_tool_call_results,
)
end

Expand All @@ -138,6 +153,12 @@ def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments
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
@default_exception_reporter ||= ->(exception, server_context) {}
end
Expand Down
12 changes: 11 additions & 1 deletion lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -586,9 +586,11 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)

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
)
validate_tool_call_result!(tool, result)
result
rescue RequestHandlerError, CancelledError
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
Expand Down Expand Up @@ -745,6 +747,14 @@ def error_tool_response(text)
).to_h
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`.
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
Expand Down
49 changes: 49 additions & 0 deletions test/mcp/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ class ConfigurationTest < ActiveSupport::TestCase
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")
Expand Down Expand Up @@ -123,6 +131,40 @@ class ConfigurationTest < ActiveSupport::TestCase
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
Expand Down Expand Up @@ -183,5 +225,12 @@ class ConfigurationTest < ActiveSupport::TestCase
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
150 changes: 150 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1767,6 +1767,156 @@ class Example < Tool
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],
Expand Down