Skip to content

Commit 075ae28

Browse files
authored
Merge pull request #344 from atesgoral/feature/output-schema-validation
feat: add opt-in tool output validation
2 parents d1e0afa + 407b064 commit 075ae28

5 files changed

Lines changed: 245 additions & 2 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,19 @@ MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/l
645645

646646
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
647647

648+
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`:
649+
650+
```ruby
651+
configuration = MCP::Configuration.new(validate_tool_call_results: true)
652+
server = MCP::Server.new(
653+
name: "example_server",
654+
tools: [WeatherTool],
655+
configuration: configuration
656+
)
657+
```
658+
659+
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.
660+
648661
### Tool Responses with Structured Content
649662

650663
Tools can return structured data alongside text content using the `structured_content` parameter.

lib/mcp/configuration.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Configuration
1616
attr_writer :instrumentation_callback
1717

1818
def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
19-
validate_tool_call_arguments: true)
19+
validate_tool_call_arguments: true, validate_tool_call_results: false)
2020
@exception_reporter = exception_reporter
2121
@around_request = around_request
2222
@instrumentation_callback = instrumentation_callback
@@ -25,8 +25,10 @@ def initialize(exception_reporter: nil, around_request: nil, instrumentation_cal
2525
validate_protocol_version!(protocol_version)
2626
end
2727
validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
28+
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
2829

2930
@validate_tool_call_arguments = validate_tool_call_arguments
31+
@validate_tool_call_results = validate_tool_call_results
3032
end
3133

3234
def protocol_version=(protocol_version)
@@ -41,6 +43,12 @@ def validate_tool_call_arguments=(validate_tool_call_arguments)
4143
@validate_tool_call_arguments = validate_tool_call_arguments
4244
end
4345

46+
def validate_tool_call_results=(validate_tool_call_results)
47+
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
48+
49+
@validate_tool_call_results = validate_tool_call_results
50+
end
51+
4452
def protocol_version
4553
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
4654
end
@@ -80,11 +88,16 @@ def instrumentation_callback?
8088
end
8189

8290
attr_reader :validate_tool_call_arguments
91+
attr_reader :validate_tool_call_results
8392

8493
def validate_tool_call_arguments?
8594
!!@validate_tool_call_arguments
8695
end
8796

97+
def validate_tool_call_results?
98+
!!@validate_tool_call_results
99+
end
100+
88101
def merge(other)
89102
return self if other.nil?
90103

@@ -113,13 +126,15 @@ def merge(other)
113126
end
114127

115128
validate_tool_call_arguments = other.validate_tool_call_arguments
129+
validate_tool_call_results = other.validate_tool_call_results
116130

117131
Configuration.new(
118132
exception_reporter: exception_reporter,
119133
around_request: around_request,
120134
instrumentation_callback: instrumentation_callback,
121135
protocol_version: protocol_version,
122136
validate_tool_call_arguments: validate_tool_call_arguments,
137+
validate_tool_call_results: validate_tool_call_results,
123138
)
124139
end
125140

@@ -138,6 +153,12 @@ def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments
138153
end
139154
end
140155

156+
def validate_value_of_validate_tool_call_results!(validate_tool_call_results)
157+
unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass)
158+
raise ArgumentError, "validate_tool_call_results must be a boolean"
159+
end
160+
end
161+
141162
def default_exception_reporter
142163
@default_exception_reporter ||= ->(exception, server_context) {}
143164
end

lib/mcp/server.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,11 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
586586

587587
progress_token = request.dig(:_meta, :progressToken)
588588

589-
call_tool_with_args(
589+
result = call_tool_with_args(
590590
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
591591
)
592+
validate_tool_call_result!(tool, result)
593+
result
592594
rescue RequestHandlerError, CancelledError
593595
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
594596
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
@@ -745,6 +747,14 @@ def error_tool_response(text)
745747
).to_h
746748
end
747749

750+
def validate_tool_call_result!(tool, result)
751+
return unless configuration.validate_tool_call_results
752+
return unless tool.output_schema
753+
return if result[:isError]
754+
755+
tool.output_schema.validate_result(result[:structuredContent])
756+
end
757+
748758
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
749759
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
750760
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.

test/mcp/configuration_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ class ConfigurationTest < ActiveSupport::TestCase
7474
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
7575
end
7676

77+
test "raises ArgumentError when validate_tool_call_results is not a boolean value" do
78+
config = Configuration.new
79+
exception = assert_raises(ArgumentError) do
80+
config.validate_tool_call_results = "true"
81+
end
82+
assert_equal("validate_tool_call_results must be a boolean", exception.message)
83+
end
84+
7785
test "merges protocol version from other configuration" do
7886
config1 = Configuration.new(protocol_version: "2025-03-26")
7987
config2 = Configuration.new(protocol_version: "2025-06-18")
@@ -123,6 +131,40 @@ class ConfigurationTest < ActiveSupport::TestCase
123131
refute merged.validate_tool_call_arguments
124132
end
125133

134+
test "defaults validate_tool_call_results to false" do
135+
config = Configuration.new
136+
refute config.validate_tool_call_results
137+
end
138+
139+
test "can set validate_tool_call_results to true" do
140+
config = Configuration.new(validate_tool_call_results: true)
141+
assert config.validate_tool_call_results
142+
end
143+
144+
test "validate_tool_call_results? returns false when not set" do
145+
config = Configuration.new
146+
refute config.validate_tool_call_results?
147+
end
148+
149+
test "validate_tool_call_results? returns true when set" do
150+
config = Configuration.new(validate_tool_call_results: true)
151+
assert config.validate_tool_call_results?
152+
end
153+
154+
test "merge preserves validate_tool_call_results from other config" do
155+
config1 = Configuration.new(validate_tool_call_results: true)
156+
config2 = Configuration.new
157+
merged = config1.merge(config2)
158+
refute merged.validate_tool_call_results?
159+
end
160+
161+
test "merge preserves validate_tool_call_results from self when other set" do
162+
config1 = Configuration.new(validate_tool_call_results: true)
163+
config2 = Configuration.new
164+
merged = config2.merge(config1)
165+
assert merged.validate_tool_call_results
166+
end
167+
126168
test "initializes with a default pass-through around_request" do
127169
config = Configuration.new
128170
called = false
@@ -183,5 +225,12 @@ class ConfigurationTest < ActiveSupport::TestCase
183225
end
184226
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
185227
end
228+
229+
test "raises ArgumentError when validate_tool_call_results is not a boolean" do
230+
exception = assert_raises(ArgumentError) do
231+
Configuration.new(validate_tool_call_results: "true")
232+
end
233+
assert_equal("validate_tool_call_results must be a boolean", exception.message)
234+
end
186235
end
187236
end

test/mcp/server_test.rb

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,156 @@ class Example < Tool
17671767
assert_includes response[:result][:content][0][:text], "Invalid arguments"
17681768
end
17691769

1770+
test "tools/call skips output schema validation by default" do
1771+
tool = Tool.define(
1772+
name: "invalid_structured_content_tool",
1773+
output_schema: {
1774+
type: "object",
1775+
properties: { result: { type: "string" } },
1776+
required: ["result"],
1777+
},
1778+
) do
1779+
Tool::Response.new(
1780+
[{ type: "text", text: "ok" }],
1781+
structured_content: { result: 123 },
1782+
)
1783+
end
1784+
server = Server.new(tools: [tool])
1785+
1786+
response = server.handle({
1787+
jsonrpc: "2.0",
1788+
id: 1,
1789+
method: "tools/call",
1790+
params: { name: "invalid_structured_content_tool" },
1791+
})
1792+
1793+
assert_nil response[:error]
1794+
assert_equal({ result: 123 }, response[:result][:structuredContent])
1795+
end
1796+
1797+
test "tools/call validates structuredContent against output schema when enabled" do
1798+
tool = Tool.define(
1799+
name: "valid_structured_content_tool",
1800+
output_schema: {
1801+
type: "object",
1802+
properties: { result: { type: "string" } },
1803+
required: ["result"],
1804+
},
1805+
) do
1806+
Tool::Response.new(
1807+
[{ type: "text", text: "ok" }],
1808+
structured_content: { result: "success" },
1809+
)
1810+
end
1811+
server = Server.new(
1812+
tools: [tool],
1813+
configuration: Configuration.new(validate_tool_call_results: true),
1814+
)
1815+
1816+
response = server.handle({
1817+
jsonrpc: "2.0",
1818+
id: 1,
1819+
method: "tools/call",
1820+
params: { name: "valid_structured_content_tool" },
1821+
})
1822+
1823+
assert_nil response[:error]
1824+
assert_equal({ result: "success" }, response[:result][:structuredContent])
1825+
end
1826+
1827+
test "tools/call returns JSON-RPC error for invalid structuredContent when output schema validation is enabled" do
1828+
tool = Tool.define(
1829+
name: "invalid_structured_content_tool",
1830+
output_schema: {
1831+
type: "object",
1832+
properties: { result: { type: "string" } },
1833+
required: ["result"],
1834+
},
1835+
) do
1836+
Tool::Response.new(
1837+
[{ type: "text", text: "ok" }],
1838+
structured_content: { result: 123 },
1839+
)
1840+
end
1841+
server = Server.new(
1842+
tools: [tool],
1843+
configuration: Configuration.new(validate_tool_call_results: true),
1844+
)
1845+
1846+
response = server.handle({
1847+
jsonrpc: "2.0",
1848+
id: 1,
1849+
method: "tools/call",
1850+
params: { name: "invalid_structured_content_tool" },
1851+
})
1852+
1853+
assert_nil response[:result]
1854+
assert_equal(-32603, response[:error][:code])
1855+
assert_equal "Internal error", response[:error][:message]
1856+
assert_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, response[:error][:data])
1857+
end
1858+
1859+
test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do
1860+
tool = Tool.define(
1861+
name: "missing_structured_content_tool",
1862+
output_schema: {
1863+
type: "object",
1864+
properties: { result: { type: "string" } },
1865+
required: ["result"],
1866+
},
1867+
) do
1868+
Tool::Response.new([{ type: "text", text: "ok" }])
1869+
end
1870+
server = Server.new(
1871+
tools: [tool],
1872+
configuration: Configuration.new(validate_tool_call_results: true),
1873+
)
1874+
1875+
response = server.handle({
1876+
jsonrpc: "2.0",
1877+
id: 1,
1878+
method: "tools/call",
1879+
params: { name: "missing_structured_content_tool" },
1880+
})
1881+
1882+
assert_nil response[:result]
1883+
assert_equal(-32603, response[:error][:code])
1884+
assert_equal "Internal error", response[:error][:message]
1885+
assert_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, response[:error][:data])
1886+
end
1887+
1888+
test "tools/call skips output schema validation for error responses" do
1889+
tool = Tool.define(
1890+
name: "error_response_tool",
1891+
output_schema: {
1892+
type: "object",
1893+
properties: { result: { type: "string" } },
1894+
required: ["result"],
1895+
},
1896+
) do
1897+
Tool::Response.new(
1898+
[{ type: "text", text: "failed" }],
1899+
error: true,
1900+
structured_content: { result: 123 },
1901+
)
1902+
end
1903+
server = Server.new(
1904+
tools: [tool],
1905+
configuration: Configuration.new(validate_tool_call_results: true),
1906+
)
1907+
1908+
response = server.handle({
1909+
jsonrpc: "2.0",
1910+
id: 1,
1911+
method: "tools/call",
1912+
params: { name: "error_response_tool" },
1913+
})
1914+
1915+
assert_nil response[:error]
1916+
assert response[:result][:isError]
1917+
assert_equal({ result: 123 }, response[:result][:structuredContent])
1918+
end
1919+
17701920
test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do
17711921
server = Server.new(
17721922
tools: [TestTool],

0 commit comments

Comments
 (0)