Skip to content
Open
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
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,28 @@ class WeatherTool < MCP::Tool
end
```

Please note: in this case, you must provide `type: "array"`. The default type
for output schemas is `object`.
Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`,
applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`).

Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
(`{ type: "string" }`) or a root-level composition:

```ruby
class FlexibleTool < MCP::Tool
output_schema(
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "number" } }
]
)
end
```

Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it
(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to
all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are
capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects;
violations raise `ArgumentError` at construction time.

MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:

Expand Down Expand Up @@ -729,6 +749,10 @@ Tools can return structured data alongside text content using the `structured_co

The structured content will be included in the JSON-RPC response as the `structuredContent` field.

Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients
that only read `content` still receive the data.

```ruby
class WeatherTool < MCP::Tool
description "Get current weather and return structured data"
Expand Down
14 changes: 13 additions & 1 deletion lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
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
serialize_structured_content_fallback(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 @@ -801,6 +801,18 @@ def validate_tool_call_result!(tool, result)
tool.output_schema.validate_result(result[:structuredContent])
end

# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
# Clients on older protocol versions may only read `content`,
# so when a tool returns non-object structured content without providing
# any content blocks, mirror the value into `content` as serialized JSON text.
def serialize_structured_content_fallback(result)
structured = result[:structuredContent]
return result if structured.nil? || structured.is_a?(Hash)
return result unless result[:content].nil? || result[:content].empty?

result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
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
17 changes: 17 additions & 0 deletions lib/mcp/tool/output_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,29 @@ class Tool
class OutputSchema < Schema
class ValidationError < StandardError; end

# Root-level keywords whose presence means the user already chose a root schema shape,
# so no `type: "object"` default should be merged in.
ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze

def validate_result(result)
errors = fully_validate(result)
if errors.any?
raise ValidationError, "Invalid result: #{errors.join(", ")}"
end
end

private

# Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive,
# or a root-level composition.
# Default the root to an object only when no root schema keyword is present, which preserves the wire output
# of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched
# (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema).
def apply_default_root_type!
return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) }

super
end
end
end
end
74 changes: 66 additions & 8 deletions lib/mcp/tool/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,29 @@ def clear
end
VALIDATION_CACHE = ValidationCache.new

# JSON Schema 2020-12 is the default dialect for MCP schema definitions
# per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
# is still performed against the JSON Schema draft-04 metaschema.
# JSON Schema 2020-12 is the default dialect for MCP schema definitions per MCP 2025-11-25 (SEP-1613),
# and SEP-2106 requires tool schemas to conform to the full 2020-12 vocabulary. Both emission and
# runtime validation use this dialect. Because MCP mandates 2020-12, the SDK validates against it
# regardless of any `$schema` a document embeds; for compliant schemas this is the same dialect
# the Python SDK's `jsonschema.validate` resolves to.
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"

DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#"
# Resource bounds for schema compilation, mirroring the TypeScript SDK's schema bounds (SEP-2106):
# schemas may use the full JSON Schema 2020-12 vocabulary including composition keywords and `$ref`,
# so adversarial documents must be rejected before they can cause excessive validation cost.
# Only same-document references (starting with `#`) are accepted, so schema handling can never trigger network
# or file access.
MAX_SCHEMA_DEPTH = 64
MAX_SUBSCHEMA_COUNT = 10_000

# Reference keywords whose targets the SDK refuses to dereference. Both `$ref` and `$dynamicRef` may carry
# an absolute URI under JSON Schema 2020-12, so a non-same-document value is an external reference.
REFERENCE_KEYWORDS = [:"$ref", :"$dynamicRef"].freeze

def initialize(schema = {})
@schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
@schema[:type] ||= "object"
apply_default_root_type!
validate_schema_bounds!
validate_schema!
end

Expand All @@ -61,6 +74,48 @@ def to_h

private

# Root-type defaulting hook. The base class preserves the historical behavior of defaulting the root
# to an object schema; `OutputSchema` overrides this because SEP-2106 allows any root schema there.
def apply_default_root_type!
@schema[:type] ||= "object"
end

# Enforces `MAX_SCHEMA_DEPTH` / `MAX_SUBSCHEMA_COUNT` and the same-document reference rule over
# the whole schema document.
def validate_schema_bounds!
subschema_count = 0
stack = [[@schema, 1]]

until stack.empty?
node, depth = stack.pop
if depth > MAX_SCHEMA_DEPTH
raise ArgumentError,
"Invalid JSON Schema: nesting exceeds the maximum depth of #{MAX_SCHEMA_DEPTH}."
end

case node
when Hash
subschema_count += 1
if subschema_count > MAX_SUBSCHEMA_COUNT
raise ArgumentError,
"Invalid JSON Schema: document exceeds the maximum of #{MAX_SUBSCHEMA_COUNT} subschema objects."
end

REFERENCE_KEYWORDS.each do |keyword|
ref = node[keyword]
next unless ref.is_a?(String) && !ref.start_with?("#")

raise ArgumentError,
"Invalid JSON Schema: only same-document #{keyword} (starting with '#') is supported, got #{ref.inspect}."
end

node.each_value { |child| stack << [child, depth + 1] }
when Array
node.each { |child| stack << [child, depth + 1] }
end
end
end

def stringify(obj)
case obj
when Hash
Expand All @@ -78,13 +133,16 @@ def stringify(obj)
# Memoized per Schema instance because schema content is fixed at construction,
# so the compiled schemer is reusable across many `fully_validate` calls.
#
# Validated against the JSON Schema 2020-12 metaschema per SEP-2106, so `$defs`/`$ref` and
# the rest of the 2020-12 vocabulary resolve natively.
#
# `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
# which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
# `ArgumentError` so callers see the same exception class they used to.
def schemer
@schemer ||= JSONSchemer.schema(
stringify(schema_for_validation),
meta_schema: DRAFT4_META_SCHEMA_URI,
meta_schema: JSON_SCHEMA_2020_12_URI,
format: false,
)
rescue RegexpError => e
Expand Down Expand Up @@ -112,8 +170,8 @@ def validate_schema!
VALIDATION_CACHE.store(key)
end

# `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation:
# this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas.
# Strip the top-level `$schema` before validation so the SDK always validates against
# the 2020-12 metaschema (SEP-2106) regardless of any dialect URI a caller embedded in the document.
def schema_for_validation
return @schema unless @schema.key?(:"$schema")

Expand Down
52 changes: 52 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2884,6 +2884,58 @@ def server_context
end
end

test "#handle tools/call mirrors non-object structuredContent into serialized text content" do
# Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`,
# so the server adds a serialized fallback when the tool provided no content blocks.
server = Server.new(name: "structured_test", tools: [])
server.define_tool(name: "array_tool") do
Tool::Response.new(nil, structured_content: [1, 2])
end

response = server.handle({
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: { name: "array_tool", arguments: {} },
})

assert_equal [1, 2], response.dig(:result, :structuredContent)
assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content)
end

test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do
server = Server.new(name: "structured_test", tools: [])
server.define_tool(name: "array_tool") do
Tool::Response.new([{ type: "text", text: "two items" }], structured_content: [1, 2])
end

response = server.handle({
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: { name: "array_tool", arguments: {} },
})

assert_equal [{ type: "text", text: "two items" }], response.dig(:result, :content)
end

test "#handle tools/call leaves object structuredContent without a text fallback" do
server = Server.new(name: "structured_test", tools: [])
server.define_tool(name: "object_tool") do
Tool::Response.new(nil, structured_content: { answer: 42 })
end

response = server.handle({
jsonrpc: "2.0",
method: "tools/call",
id: 1,
params: { name: "object_tool", arguments: {} },
})

assert_equal({ answer: 42 }, response.dig(:result, :structuredContent))
assert_empty response.dig(:result, :content)
end

test "#handle tools/list returns paginated results when page_size is set" do
tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A")
tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B")
Expand Down
36 changes: 36 additions & 0 deletions test/mcp/tool/input_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ class InputSchemaTest < ActiveSupport::TestCase
end
end

test "rejects a draft-04-only boolean exclusiveMinimum under the 2020-12 dialect" do
# SEP-2106 validates tool schemas against the JSON Schema 2020-12 metaschema,
# where `exclusiveMinimum` must be a number. The draft-04 boolean form (deprecated since draft-06)
# is rejected at construction, matching the Python SDK's `jsonschema` validator selection.
error = assert_raises(ArgumentError) do
InputSchema.new(properties: { age: { type: "integer", minimum: 0, exclusiveMinimum: true } })
end
assert_includes error.message, "Invalid JSON Schema"
end

test "accepts the 2020-12 numeric exclusiveMinimum form" do
assert_nothing_raised do
InputSchema.new(properties: { age: { type: "integer", exclusiveMinimum: 0 } })
end
end

test "schema without required arguments is valid" do
assert_nothing_raised do
InputSchema.new(properties: { foo: { type: "string" } })
Expand Down Expand Up @@ -183,6 +199,26 @@ class InputSchemaTest < ActiveSupport::TestCase
assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref]
end

test "keeps the object root type while accepting 2020-12 composition keywords" do
# Per SEP-2106, an input schema root must stay `type: "object"` but may use
# the full 2020-12 vocabulary below the root.
schema = InputSchema.new(
"$defs": { name: { type: "string", minLength: 1 } },
properties: {
name: { "$ref": "#/$defs/name" },
value: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
if: { properties: { value: { type: "integer" } } },
then: { required: ["name"] },
allOf: [{ properties: { extra: { type: "boolean" } } }],
)

assert_equal "object", schema.to_h[:type]
assert schema.to_h.key?(:"$defs")
assert schema.to_h.key?(:if)
assert schema.to_h.key?(:allOf)
end

test "== compares two input schemas with the same properties, required fields" do
schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
Expand Down
45 changes: 45 additions & 0 deletions test/mcp/tool/output_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,51 @@ class OutputSchemaTest < ActiveSupport::TestCase
end
end

test "does not inject a root type into a root-level oneOf schema" do
# Per SEP-2106, an output schema may be any JSON Schema 2020-12 document.
# Merging `type: "object"` into a root combinator would produce a wrong schema.
schema = OutputSchema.new(oneOf: [{ type: "string" }, { type: "integer" }])

refute schema.to_h.key?(:type)
assert_equal [{ type: "string" }, { type: "integer" }], schema.to_h[:oneOf]
assert_nothing_raised { schema.validate_result("text") }
assert_nothing_raised { schema.validate_result(42) }
assert_raises(OutputSchema::ValidationError) { schema.validate_result(1.5) }
end

test "does not inject a root type into a root-level $ref schema" do
schema = OutputSchema.new(
"$ref": "#/$defs/result",
"$defs": { result: { type: "string" } },
)

refute schema.to_h.key?(:type)
assert_nothing_raised { schema.validate_result("text") }
assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) }
end

test "allows primitive root schemas" do
schema = OutputSchema.new(type: "string")

assert_nothing_raised { schema.validate_result("text") }
assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) }
end

test "does not inject a root type into a root-level enum schema" do
schema = OutputSchema.new(enum: ["red", "green", "blue"])

refute schema.to_h.key?(:type)
assert_nothing_raised { schema.validate_result("red") }
assert_raises(OutputSchema::ValidationError) { schema.validate_result("yellow") }
end

test "defaults a properties-only schema to a root object" do
# Wire-format regression: the common shorthand keeps serializing with the injected `type: "object"`.
schema = OutputSchema.new(properties: { result: { type: "string" } })

assert_equal "object", schema.to_h[:type]
end

test "allow to declare array schemas" do
schema = OutputSchema.new({
type: "array",
Expand Down
Loading