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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 28 additions & 34 deletions conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
Expand All @@ -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,
},
},
)
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions lib/mcp/elicitation.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 121 additions & 0 deletions lib/mcp/elicitation/enum_schema.rb
Original file line number Diff line number Diff line change
@@ -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
Loading