Skip to content

Commit aad8e94

Browse files
committed
Add Tool output_schema support with comprehensive validation
This commit implements optional JSON Schema validation for tool outputs according to the MCP specification: - Add MCP::Tool::OutputSchema class mirroring InputSchema functionality - Support output_schema in class definitions, Tool.define, and OutputSchema objects - Include validate_result method for runtime validation - Update MCP::Client::Tool to track output schemas - Add comprehensive test coverage with 249 passing tests - Update README with dedicated Tool Output Schemas section - Follow MCP spec requirements for server/client validation
1 parent 3f2a4b3 commit aad8e94

File tree

8 files changed

+495
-7
lines changed

8 files changed

+495
-7
lines changed

README.md

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,14 @@ class MyTool < MCP::Tool
385385
},
386386
required: ["message"]
387387
)
388+
output_schema(
389+
properties: {
390+
result: { type: "string" },
391+
success: { type: "boolean" },
392+
timestamp: { type: "string", format: "date-time" }
393+
},
394+
required: ["result", "success", "timestamp"]
395+
)
388396
annotations(
389397
read_only_hint: true,
390398
destructive_hint: false,
@@ -448,6 +456,102 @@ Tools can include annotations that provide additional metadata about their behav
448456

449457
Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method.
450458

459+
### Tool Output Schemas
460+
461+
Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways:
462+
463+
1. **Class definition with output_schema:**
464+
465+
```ruby
466+
class WeatherTool < MCP::Tool
467+
tool_name "get_weather"
468+
description "Get current weather for a location"
469+
470+
input_schema(
471+
properties: {
472+
location: { type: "string" },
473+
units: { type: "string", enum: ["celsius", "fahrenheit"] }
474+
},
475+
required: ["location"]
476+
)
477+
478+
output_schema(
479+
properties: {
480+
temperature: { type: "number" },
481+
condition: { type: "string" },
482+
humidity: { type: "integer" }
483+
},
484+
required: ["temperature", "condition", "humidity"]
485+
)
486+
487+
def self.call(location:, units: "celsius", server_context:)
488+
# Call weather API and structure the response
489+
api_response = WeatherAPI.fetch(location, units)
490+
weather_data = {
491+
temperature: api_response.temp,
492+
condition: api_response.description,
493+
humidity: api_response.humidity_percent
494+
}
495+
496+
output_schema.validate_result(weather_data)
497+
498+
MCP::Tool::Response.new([{
499+
type: "text",
500+
text: weather_data.to_json
501+
}])
502+
end
503+
end
504+
```
505+
506+
2. **Using Tool.define with output_schema:**
507+
508+
```ruby
509+
tool = MCP::Tool.define(
510+
name: "calculate_stats",
511+
description: "Calculate statistics for a dataset",
512+
input_schema: {
513+
properties: {
514+
numbers: { type: "array", items: { type: "number" } }
515+
},
516+
required: ["numbers"]
517+
},
518+
output_schema: {
519+
properties: {
520+
mean: { type: "number" },
521+
median: { type: "number" },
522+
count: { type: "integer" }
523+
},
524+
required: ["mean", "median", "count"]
525+
}
526+
) do |args, server_context|
527+
# Calculate statistics and validate against schema
528+
MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }])
529+
end
530+
```
531+
532+
3. **Using OutputSchema objects:**
533+
534+
```ruby
535+
class DataTool < MCP::Tool
536+
output_schema MCP::Tool::OutputSchema.new(
537+
properties: {
538+
success: { type: "boolean" },
539+
data: { type: "object" }
540+
},
541+
required: ["success"]
542+
)
543+
end
544+
```
545+
546+
MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) specifies that:
547+
548+
- **Server Validation**: Servers MUST provide structured results that conform to the output schema
549+
- **Client Validation**: Clients SHOULD validate structured results against the output schema
550+
- **Better Integration**: Enables strict schema validation, type information, and improved developer experience
551+
- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block
552+
553+
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
554+
451555
### Prompts
452556

453557
MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
@@ -755,7 +859,7 @@ The client provides a wrapper class for tools returned by the server:
755859

756860
- `MCP::Client::Tool` - Represents a single tool with its metadata
757861

758-
This class provide easy access to tool properties like name, description, and input schema.
862+
This class provides easy access to tool properties like name, description, input schema, and output schema.
759863

760864
## Releases
761865

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
require_relative "mcp/string_utils"
1919
require_relative "mcp/tool"
2020
require_relative "mcp/tool/input_schema"
21+
require_relative "mcp/tool/output_schema"
2122
require_relative "mcp/tool/response"
2223
require_relative "mcp/tool/annotations"
2324
require_relative "mcp/transport"

lib/mcp/client/tool.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
module MCP
44
class Client
55
class Tool
6-
attr_reader :name, :description, :input_schema
6+
attr_reader :name, :description, :input_schema, :output_schema
77

8-
def initialize(name:, description:, input_schema:)
8+
def initialize(name:, description:, input_schema:, output_schema: nil)
99
@name = name
1010
@description = description
1111
@input_schema = input_schema
12+
@output_schema = output_schema
1213
end
1314
end
1415
end

lib/mcp/tool.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ def call(*args, server_context: nil)
1414
end
1515

1616
def to_h
17-
result = {
17+
{
1818
name: name_value,
1919
title: title_value,
2020
description: description_value,
2121
inputSchema: input_schema_value.to_h,
22+
outputSchema: @output_schema_value&.to_h,
23+
annotations: annotations_value&.to_h,
2224
}.compact
23-
result[:annotations] = annotations_value.to_h if annotations_value
24-
result
2525
end
2626

2727
def inherited(subclass)
@@ -30,6 +30,7 @@ def inherited(subclass)
3030
subclass.instance_variable_set(:@title_value, nil)
3131
subclass.instance_variable_set(:@description_value, nil)
3232
subclass.instance_variable_set(:@input_schema_value, nil)
33+
subclass.instance_variable_set(:@output_schema_value, nil)
3334
subclass.instance_variable_set(:@annotations_value, nil)
3435
end
3536

@@ -49,6 +50,8 @@ def input_schema_value
4950
@input_schema_value || InputSchema.new
5051
end
5152

53+
attr_reader :output_schema_value
54+
5255
def title(value = NOT_SET)
5356
if value == NOT_SET
5457
@title_value
@@ -77,6 +80,18 @@ def input_schema(value = NOT_SET)
7780
end
7881
end
7982

83+
def output_schema(value = NOT_SET)
84+
if value == NOT_SET
85+
output_schema_value
86+
elsif value.is_a?(Hash)
87+
properties = value[:properties] || value["properties"] || {}
88+
required = value[:required] || value["required"] || []
89+
@output_schema_value = OutputSchema.new(properties:, required:)
90+
elsif value.is_a?(OutputSchema)
91+
@output_schema_value = value
92+
end
93+
end
94+
8095
def annotations(hash = NOT_SET)
8196
if hash == NOT_SET
8297
@annotations_value
@@ -85,12 +100,13 @@ def annotations(hash = NOT_SET)
85100
end
86101
end
87102

88-
def define(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, &block)
103+
def define(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, &block)
89104
Class.new(self) do
90105
tool_name name
91106
title title
92107
description description
93108
input_schema input_schema
109+
output_schema output_schema
94110
self.annotations(annotations) if annotations
95111
define_singleton_method(:call, &block) if block
96112
end

lib/mcp/tool/output_schema.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
require "json-schema"
4+
5+
module MCP
6+
class Tool
7+
class OutputSchema
8+
class ValidationError < StandardError; end
9+
10+
attr_reader :properties, :required
11+
12+
def initialize(properties: {}, required: [])
13+
@properties = properties
14+
@required = required.map(&:to_sym)
15+
validate_schema!
16+
end
17+
18+
def ==(other)
19+
other.is_a?(OutputSchema) && properties == other.properties && required == other.required
20+
end
21+
22+
def to_h
23+
{ type: "object" }.tap do |hsh|
24+
hsh[:properties] = properties if properties.any?
25+
hsh[:required] = required if required.any?
26+
end
27+
end
28+
29+
def validate_result(result)
30+
errors = JSON::Validator.fully_validate(to_h, result)
31+
if errors.any?
32+
raise ValidationError, "Invalid result: #{errors.join(", ")}"
33+
end
34+
end
35+
36+
private
37+
38+
def validate_schema!
39+
check_for_refs!
40+
schema = to_h
41+
schema_reader = JSON::Schema::Reader.new(
42+
accept_uri: false,
43+
accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
44+
)
45+
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
46+
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
47+
if errors.any?
48+
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
49+
end
50+
end
51+
52+
def check_for_refs!(obj = properties)
53+
case obj
54+
when Hash
55+
if obj.key?("$ref") || obj.key?(:$ref)
56+
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
57+
end
58+
59+
obj.each_value { |value| check_for_refs!(value) }
60+
when Array
61+
obj.each { |item| check_for_refs!(item) }
62+
end
63+
end
64+
end
65+
end
66+
end

test/mcp/client/tool_test.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,38 @@ def test_input_schema_returns_input_schema
2828
@tool.input_schema,
2929
)
3030
end
31+
32+
def test_output_schema_returns_nil_when_not_provided
33+
assert_nil(@tool.output_schema)
34+
end
35+
36+
def test_output_schema_returns_output_schema_when_provided
37+
tool_with_output = Tool.new(
38+
name: "test_tool_with_output",
39+
description: "A test tool with output schema",
40+
input_schema: { "type" => "object", "properties" => { "foo" => { "type" => "string" } } },
41+
output_schema: { "type" => "object", "properties" => { "result" => { "type" => "string" } } },
42+
)
43+
44+
assert_equal(
45+
{ "type" => "object", "properties" => { "result" => { "type" => "string" } } },
46+
tool_with_output.output_schema,
47+
)
48+
end
49+
50+
def test_initialization_with_all_parameters
51+
tool = Tool.new(
52+
name: "full_tool",
53+
description: "A tool with all parameters",
54+
input_schema: { "type" => "object" },
55+
output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } },
56+
)
57+
58+
assert_equal("full_tool", tool.name)
59+
assert_equal("A tool with all parameters", tool.description)
60+
assert_equal({ "type" => "object" }, tool.input_schema)
61+
assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema)
62+
end
3163
end
3264
end
3365
end

0 commit comments

Comments
 (0)