diff --git a/README.md b/README.md index 000e6e26..adfa6516 100644 --- a/README.md +++ b/README.md @@ -1970,6 +1970,64 @@ server.define_tool(name: "configure_deploy", description: "Configure a deploymen end ``` +#### Enum Schemas + +For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per +[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) instead of building +the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select, +plus the legacy `enumNames` form retained for backward compatibility: + +```ruby +size_schema = MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "s", title: "Small" }, + { value: "m", title: "Medium" }, + { value: "l", title: "Large" }, + ], + default: "m", +) + +tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["urgent", "billing", "feedback"], +) + +result = server_context.create_form_elicitation( + message: "Tell us about your order", + requested_schema: { + type: "object", + properties: { + size: size_schema.to_h, + tags: tags_schema.to_h, + }, + required: ["size"], + }, +) +``` + +The available builders are `untitled_single_select`, `titled_single_select`, `untitled_multi_select`, `titled_multi_select`, +and `legacy_titled`. Each accepts optional `default:`, `title:`, and `description:`. + +The same builders produce the `requestedSchema` of an `elicitation/create` request embedded in a SEP-2322 `input_required` result, +which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle: + +```ruby +MCP::Server::InputRequiredResult.new( + input_requests: { + "size" => { + method: "elicitation/create", + params: { + message: "Pick a size", + requestedSchema: { + type: "object", + properties: { size: size_schema.to_h }, + required: ["size"], + }, + }, + }, + }, +) +``` + #### URL Mode URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: diff --git a/conformance/server.rb b/conformance/server.rb index 7b697d83..eb6a2e30 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -219,7 +219,9 @@ def call(server_context:, **_args) name: { type: "string", default: "John Doe" }, age: { type: "integer", default: 30 }, score: { type: "number", default: 95.5 }, - status: { type: "string", enum: ["active", "inactive", "pending"], default: "active" }, + status: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["active", "inactive", "pending"], default: "active", + ).to_h, verified: { type: "boolean", default: true }, }, }, @@ -235,45 +237,37 @@ class TestElicitationSep1330Enums < MCP::Tool class << self def call(server_context:, **_args) + # Built with the SEP-1330 builders so the conformance run exercises the same schemas + # the SDK produces for users. result = server_context.create_form_elicitation( message: "Please select options", requested_schema: { type: "object", properties: { - untitledSingle: { - type: "string", - enum: ["option1", "option2", "option3"], - }, - titledSingle: { - type: "string", - oneOf: [ - { const: "value1", title: "First Option" }, - { const: "value2", title: "Second Option" }, - { const: "value3", title: "Third Option" }, + untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledSingle: MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "value1", title: "First Option" }, + { value: "value2", title: "Second Option" }, + { value: "value3", title: "Third Option" }, ], - }, - legacyEnum: { - type: "string", - enum: ["opt1", "opt2", "opt3"], - enumNames: ["Option One", "Option Two", "Option Three"], - }, - untitledMulti: { - type: "array", - items: { - type: "string", - enum: ["option1", "option2", "option3"], - }, - }, - titledMulti: { - type: "array", - items: { - anyOf: [ - { const: "value1", title: "First Choice" }, - { const: "value2", title: "Second Choice" }, - { const: "value3", title: "Third Choice" }, - ], - }, - }, + ).to_h, + legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled( + values: ["opt1", "opt2", "opt3"], + value_titles: ["Option One", "Option Two", "Option Three"], + ).to_h, + untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select( + options: [ + { value: "value1", title: "First Choice" }, + { value: "value2", title: "Second Choice" }, + { value: "value3", title: "Third Choice" }, + ], + ).to_h, }, }, ) diff --git a/lib/mcp.rb b/lib/mcp.rb index 02018272..2213f231 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -14,6 +14,7 @@ module MCP autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" + autoload :Elicitation, "mcp/elicitation" autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" diff --git a/lib/mcp/elicitation.rb b/lib/mcp/elicitation.rb new file mode 100644 index 00000000..26186c7f --- /dev/null +++ b/lib/mcp/elicitation.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module MCP + # Builders for elicitation `requestedSchema` definitions per MCP 2025-11-25. + # Each builder returns an instance whose `to_h` produces the JSON-Schema-shaped Hash + # a server passes as a property value in `create_form_elicitation(requested_schema:)`. + module Elicitation + autoload :EnumSchema, "mcp/elicitation/enum_schema" + end +end diff --git a/lib/mcp/elicitation/enum_schema.rb b/lib/mcp/elicitation/enum_schema.rb new file mode 100644 index 00000000..191a20f4 --- /dev/null +++ b/lib/mcp/elicitation/enum_schema.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module MCP + module Elicitation + # Builds the four enum schema variants defined by MCP 2025-11-25 (SEP-1330) plus the legacy `enumNames` form + # retained for backward compatibility. + # + # Each class method returns an `EnumSchema` instance; call `to_h` to get the property-value Hash and + # pass it through `requested_schema` to `ServerSession#create_form_elicitation`. + class EnumSchema + class << self + # Single-select with plain string values: `{ type: "string", enum: [...] }`. + def untitled_single_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "string", + extras: { enum: values }, + default: default, + title: title, + description: description, + ) + end + + # Single-select with display titles per option: `{ type: "string", oneOf: [{ const, title }, ...] }`. + # `options` is an Array of `{ value:, title: }` hashes. + def titled_single_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + new( + type: "string", + extras: { oneOf: options.map { |o| { const: o[:value], title: o[:title] } } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with plain string values: `{ type: "array", items: { type: "string", enum: [...] } }`. + def untitled_multi_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "array", + extras: { items: { type: "string", enum: values } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with display titles per option: `{ type: "array", items: { anyOf: [{ const, title }, ...] } }`. + def titled_multi_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + items_anyof = options.map { |o| { const: o[:value], title: o[:title] } } + new( + type: "array", + extras: { items: { anyOf: items_anyof } }, + default: default, + title: title, + description: description, + ) + end + + # Legacy single-select retained for backward compatibility with clients implementing + # the pre-SEP-1330 form: `{ enum, enumNames }`. + def legacy_titled(values:, value_titles:, default: nil, title: nil, description: nil) + validate_values!(values) + unless value_titles.is_a?(Array) && value_titles.length == values.length + raise ArgumentError, "value_titles must be an Array of the same length as values" + end + + new( + type: "string", + extras: { enum: values, enumNames: value_titles }, + default: default, + title: title, + description: description, + ) + end + + private + + def validate_values!(values) + unless values.is_a?(Array) && !values.empty? + raise ArgumentError, "values must be a non-empty Array" + end + end + + def validate_titled_options!(options) + unless options.is_a?(Array) && !options.empty? + raise ArgumentError, "options must be a non-empty Array of {value:, title:} hashes" + end + + options.each do |option| + unless option.is_a?(Hash) && option.key?(:value) && option.key?(:title) + raise ArgumentError, "each option must be a Hash with :value and :title keys" + end + end + end + end + + def initialize(type:, extras:, default: nil, title: nil, description: nil) + @type = type + @extras = extras + @default = default + @title = title + @description = description + end + + def to_h + hash = { type: @type }.merge(@extras) + hash[:title] = @title if @title + hash[:description] = @description if @description + hash[:default] = @default unless @default.nil? + hash + end + end + end +end diff --git a/test/mcp/elicitation/enum_schema_test.rb b/test/mcp/elicitation/enum_schema_test.rb new file mode 100644 index 00000000..19a42464 --- /dev/null +++ b/test/mcp/elicitation/enum_schema_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + module Elicitation + class EnumSchemaTest < ActiveSupport::TestCase + test "untitled_single_select returns string + enum schema" do + schema = EnumSchema.untitled_single_select(values: ["red", "green", "blue"]) + + assert_equal({ type: "string", enum: ["red", "green", "blue"] }, schema.to_h) + end + + test "untitled_single_select preserves default value" do + schema = EnumSchema.untitled_single_select(values: ["red", "green"], default: "red") + + assert_equal "red", schema.to_h[:default] + end + + test "titled_single_select returns string + oneOf schema" do + schema = EnumSchema.titled_single_select( + options: [ + { value: "small", title: "Small" }, + { value: "large", title: "Large" }, + ], + ) + + assert_equal( + { + type: "string", + oneOf: [ + { const: "small", title: "Small" }, + { const: "large", title: "Large" }, + ], + }, + schema.to_h, + ) + end + + test "untitled_multi_select returns array + items.enum schema" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b", "c"]) + + assert_equal( + { type: "array", items: { type: "string", enum: ["a", "b", "c"] } }, + schema.to_h, + ) + end + + test "untitled_multi_select preserves default array" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: ["a"]) + + assert_equal ["a"], schema.to_h[:default] + end + + test "titled_multi_select returns array + items.anyOf schema" do + schema = EnumSchema.titled_multi_select( + options: [ + { value: "us", title: "United States" }, + { value: "jp", title: "Japan" }, + ], + ) + + assert_equal( + { + type: "array", + items: { + anyOf: [ + { const: "us", title: "United States" }, + { const: "jp", title: "Japan" }, + ], + }, + }, + schema.to_h, + ) + end + + test "legacy_titled returns enum + enumNames" do + schema = EnumSchema.legacy_titled( + values: ["s", "m", "l"], + value_titles: ["Small", "Medium", "Large"], + ) + + assert_equal( + { + type: "string", + enum: ["s", "m", "l"], + enumNames: ["Small", "Medium", "Large"], + }, + schema.to_h, + ) + end + + test "title and description are included when provided" do + schema = EnumSchema.untitled_single_select( + values: ["a"], + title: "Color", + description: "Pick a color", + ) + + assert_equal "Color", schema.to_h[:title] + assert_equal "Pick a color", schema.to_h[:description] + end + + test "raises ArgumentError when values array is empty" do + assert_raises(ArgumentError) do + EnumSchema.untitled_single_select(values: []) + end + end + + test "raises ArgumentError when titled options miss :value" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ title: "missing value" }]) + end + end + + test "raises ArgumentError when titled options miss :title" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ value: "v" }]) + end + end + + test "raises ArgumentError when legacy value_titles length differs" do + assert_raises(ArgumentError) do + EnumSchema.legacy_titled(values: ["a", "b"], value_titles: ["A"]) + end + end + + test "preserves an empty-string default value" do + # Regression for `unless @default.nil?`: empty-but-non-nil defaults must still survive `to_h` + # (the guard distinguishes nil from "absent vs supplied", not truthy vs falsey). + schema = EnumSchema.untitled_single_select(values: ["", "yes", "no"], default: "") + + assert_equal "", schema.to_h[:default] + end + + test "preserves an empty-array default for multi-select" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: []) + + assert_equal [], schema.to_h[:default] + end + end + end +end