Add MCP::Elicitation::EnumSchema builders for SEP-1330
## Motivation and Context MCP 2025-11-25 (SEP-1330) replaces the ad-hoc `enumNames` convention with four standards-based enum schema variants for elicitation requests: titled/untitled single-select and titled/untitled multi-select. The TypeScript SDK ships typed Zod definitions for each shape (`packages/core/src/types/schemas.ts`), and the Python SDK relies on hand-built schemas with the same shapes in its conformance fixtures. Adds builder class methods on `MCP::Elicitation::EnumSchema` that produce the canonical JSON-Schema shapes for each variant, plus a `legacy_titled` builder retained for backward compatibility with clients that have not yet migrated. Each constructor returns an `EnumSchema` instance whose `to_h` produces the property-value Hash to pass through `requested_schema:` on `ServerSession#create_form_elicitation`. Each builder maps one-to-one to the spec's schema type names in `schema/2025-11-25/schema.ts`, which the TypeScript and Python SDKs also carry verbatim in their type layers: | Spec type (schema.ts) | Builder | |---------------------------------------------------|-------------------------------------------------| | `UntitledSingleSelectEnumSchema` | `untitled_single_select` | | `TitledSingleSelectEnumSchema` | `titled_single_select` | | `UntitledMultiSelectEnumSchema` | `untitled_multi_select` | | `TitledMultiSelectEnumSchema` | `titled_multi_select` | | `LegacyTitledEnumSchema` (deprecated in the spec) | `legacy_titled` | | `EnumSchema` (the union of all five) | the `MCP::Elicitation::EnumSchema` class itself | These shapes stay relevant on the stateless 2026-07-28 lifecycle: elicitation is not deprecated by SEP-2577 and reaches modern clients as `elicitation/create` requests embedded in SEP-2322 `input_required` results, whose `requestedSchema` uses the same variants. The README documents that pairing; the example references `MCP::Server::InputRequiredResult`, so this change should merge after the SEP-2322 server-side PR. The conformance server now builds the enum schemas of its `test_elicitation_sep1330_enums` tool (and the `status` enum of the SEP-1034 defaults tool) through these builders instead of hand-written hashes, so the conformance run exercises the exact schemas the SDK produces for users. ## How Has This Been Tested? `test/mcp/elicitation/enum_schema_test.rb` covers each of the five shapes with happy-path emission, `default` propagation, `title` / `description` propagation, and the four ArgumentError cases (empty values, missing `:value`, missing `:title`, mismatched `value_titles` length). Two additional regression tests assert that empty-but-non-nil defaults survive `to_h`: - `EnumSchema.untitled_single_select(default: "")` keeps the empty string in the emitted Hash - `EnumSchema.untitled_multi_select(default: [])` keeps the empty array in the emitted Hash `bundle exec rake` (tests, RuboCop, and conformance baseline) passes; the `elicitation-sep1330-enums` and `elicitation-sep1034-defaults` conformance scenarios pass with the builder-produced schemas, confirming they emit the same wire shapes as the previous hand-written hashes. ## Breaking Changes None. The change is purely additive: new module `MCP::Elicitation::EnumSchema` with new class methods. Existing callers of `ServerSession#create_form_elicitation` that pass raw hashes continue to work unchanged.
This commit is contained in:
parent
d51d4a0f91
commit
b54eebac75
6 changed files with 361 additions and 34 deletions
58
README.md
58
README.md
|
|
@ -1970,6 +1970,64 @@ server.define_tool(name: "configure_deploy", description: "Configure a deploymen
|
||||||
end
|
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
|
||||||
|
|
||||||
URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows:
|
URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows:
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,9 @@ module Conformance
|
||||||
name: { type: "string", default: "John Doe" },
|
name: { type: "string", default: "John Doe" },
|
||||||
age: { type: "integer", default: 30 },
|
age: { type: "integer", default: 30 },
|
||||||
score: { type: "number", default: 95.5 },
|
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 },
|
verified: { type: "boolean", default: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -235,45 +237,37 @@ module Conformance
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(server_context:, **_args)
|
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(
|
result = server_context.create_form_elicitation(
|
||||||
message: "Please select options",
|
message: "Please select options",
|
||||||
requested_schema: {
|
requested_schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
untitledSingle: {
|
untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select(
|
||||||
type: "string",
|
values: ["option1", "option2", "option3"],
|
||||||
enum: ["option1", "option2", "option3"],
|
).to_h,
|
||||||
},
|
titledSingle: MCP::Elicitation::EnumSchema.titled_single_select(
|
||||||
titledSingle: {
|
options: [
|
||||||
type: "string",
|
{ value: "value1", title: "First Option" },
|
||||||
oneOf: [
|
{ value: "value2", title: "Second Option" },
|
||||||
{ const: "value1", title: "First Option" },
|
{ value: "value3", title: "Third Option" },
|
||||||
{ const: "value2", title: "Second Option" },
|
|
||||||
{ const: "value3", title: "Third Option" },
|
|
||||||
],
|
],
|
||||||
},
|
).to_h,
|
||||||
legacyEnum: {
|
legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled(
|
||||||
type: "string",
|
values: ["opt1", "opt2", "opt3"],
|
||||||
enum: ["opt1", "opt2", "opt3"],
|
value_titles: ["Option One", "Option Two", "Option Three"],
|
||||||
enumNames: ["Option One", "Option Two", "Option Three"],
|
).to_h,
|
||||||
},
|
untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select(
|
||||||
untitledMulti: {
|
values: ["option1", "option2", "option3"],
|
||||||
type: "array",
|
).to_h,
|
||||||
items: {
|
titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select(
|
||||||
type: "string",
|
options: [
|
||||||
enum: ["option1", "option2", "option3"],
|
{ value: "value1", title: "First Choice" },
|
||||||
},
|
{ value: "value2", title: "Second Choice" },
|
||||||
},
|
{ value: "value3", title: "Third Choice" },
|
||||||
titledMulti: {
|
],
|
||||||
type: "array",
|
).to_h,
|
||||||
items: {
|
|
||||||
anyOf: [
|
|
||||||
{ const: "value1", title: "First Choice" },
|
|
||||||
{ const: "value2", title: "Second Choice" },
|
|
||||||
{ const: "value3", title: "Third Choice" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ module MCP
|
||||||
autoload :CancelledError, "mcp/cancelled_error"
|
autoload :CancelledError, "mcp/cancelled_error"
|
||||||
autoload :Client, "mcp/client"
|
autoload :Client, "mcp/client"
|
||||||
autoload :Content, "mcp/content"
|
autoload :Content, "mcp/content"
|
||||||
|
autoload :Elicitation, "mcp/elicitation"
|
||||||
autoload :ErrorCodes, "mcp/error_codes"
|
autoload :ErrorCodes, "mcp/error_codes"
|
||||||
autoload :Icon, "mcp/icon"
|
autoload :Icon, "mcp/icon"
|
||||||
autoload :Prompt, "mcp/prompt"
|
autoload :Prompt, "mcp/prompt"
|
||||||
|
|
|
||||||
10
lib/mcp/elicitation.rb
Normal file
10
lib/mcp/elicitation.rb
Normal file
|
|
@ -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
|
||||||
121
lib/mcp/elicitation/enum_schema.rb
Normal file
121
lib/mcp/elicitation/enum_schema.rb
Normal file
|
|
@ -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
|
||||||
143
test/mcp/elicitation/enum_schema_test.rb
Normal file
143
test/mcp/elicitation/enum_schema_test.rb
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue