Skip to content

Commit bdd6995

Browse files
authored
Merge pull request #133 from codenamev/main
Add Tool output_schema support with comprehensive validation
2 parents 5c5f420 + cd46813 commit bdd6995

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

456560
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.
@@ -758,7 +862,7 @@ The client provides a wrapper class for tools returned by the server:
758862

759863
- `MCP::Client::Tool` - Represents a single tool with its metadata
760864

761-
This class provide easy access to tool properties like name, description, and input schema.
865+
This class provides easy access to tool properties like name, description, input schema, and output schema.
762866

763867
## Releases
764868

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)