diff --git a/README.md b/README.md index 71fb5acd..8bd4222c 100644 --- a/README.md +++ b/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. +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. diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 34fcdecf..e24dc732 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -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 @@ -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) @@ -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 @@ -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? @@ -113,6 +126,7 @@ 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, @@ -120,6 +134,7 @@ def merge(other) 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 @@ -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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c3cdf23b..de2b4aa1 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -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. @@ -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:`. diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 8c395f18..8270b101 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -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") @@ -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 @@ -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 diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index a8f3ea0d..27913d59 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -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],