## Motivation and Context The MCP specification (2025-11-25) defines elicitation as a server-to-client request mechanism that allows servers to request user input during tool execution: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation This aligns with the Python SDK, which also exposes separate methods for form and URL mode elicitation. Three methods are added following the Python SDK's API design: - `create_form_elicitation`: collects structured data via JSON Schema - `create_url_elicitation`: directs users to external URLs for sensitive interactions (OAuth, credentials, etc.) - `notify_elicitation_complete`: notifies the client that a URL mode interaction has completed `URLElicitationRequiredError` (error code `-32042`) is defined as a subclass of `RequestHandlerError` with its own `error_code` and `error_data` attributes. `JsonRpcHandler#handle_request_error` is extended to check for these attributes so that MCP-specific error codes can be propagated without embedding MCP knowledge in the generic JSON-RPC layer. ## How Has This Been Tested? - Unit tests for `ServerSession` form/URL mode, capability checks, nil capabilities, notification, and `URLElicitationRequiredError` - `ServerContext` delegation tests for all three elicitation methods - `JsonRpcHandler` tests for custom error code and fallback behavior - `Methods` capability test for `NOTIFICATIONS_ELICITATION_COMPLETE` - Conformance tests pass (40/40), including `tools-call-elicitation`, `elicitation-sep1034-defaults`, and `elicitation-sep1330-enums` - All tests pass (`rake test`), RuboCop is clean - Verified form mode elicitation end-to-end with a sample MCP server running in Claude Code ## Breaking Changes None. This is a new feature addition.
136 lines
5.5 KiB
Ruby
136 lines
5.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "methods"
|
|
|
|
module MCP
|
|
# Holds per-connection state for a single client session.
|
|
# Created by the transport layer; delegates request handling to the shared `Server`.
|
|
class ServerSession
|
|
attr_reader :session_id, :client, :logging_message_notification
|
|
|
|
def initialize(server:, transport:, session_id: nil)
|
|
@server = server
|
|
@transport = transport
|
|
@session_id = session_id
|
|
@client = nil
|
|
@client_capabilities = nil
|
|
@logging_message_notification = nil
|
|
end
|
|
|
|
def handle(request)
|
|
@server.handle(request, session: self)
|
|
end
|
|
|
|
def handle_json(request_json)
|
|
@server.handle_json(request_json, session: self)
|
|
end
|
|
|
|
# Called by `Server#init` during the initialization handshake.
|
|
def store_client_info(client:, capabilities: nil)
|
|
@client = client
|
|
@client_capabilities = capabilities
|
|
end
|
|
|
|
# Called by `Server#configure_logging_level`.
|
|
def configure_logging(logging_message_notification)
|
|
@logging_message_notification = logging_message_notification
|
|
end
|
|
|
|
# Returns per-session client capabilities, falling back to global.
|
|
def client_capabilities
|
|
@client_capabilities || @server.client_capabilities
|
|
end
|
|
|
|
# Sends a `sampling/createMessage` request scoped to this session.
|
|
def create_sampling_message(related_request_id: nil, **kwargs)
|
|
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
|
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
|
|
end
|
|
|
|
# Sends an `elicitation/create` request (form mode) scoped to this session.
|
|
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
|
|
unless client_capabilities&.dig(:elicitation)
|
|
raise "Client does not support elicitation. " \
|
|
"The client must declare the `elicitation` capability during initialization."
|
|
end
|
|
|
|
params = { mode: "form", message: message, requestedSchema: requested_schema }
|
|
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
|
|
end
|
|
|
|
# Sends an `elicitation/create` request (URL mode) scoped to this session.
|
|
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
|
|
unless client_capabilities&.dig(:elicitation, :url)
|
|
raise "Client does not support URL mode elicitation. " \
|
|
"The client must declare the `elicitation.url` capability during initialization."
|
|
end
|
|
|
|
params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
|
|
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
|
|
end
|
|
|
|
# Sends an elicitation complete notification scoped to this session.
|
|
def notify_elicitation_complete(elicitation_id:)
|
|
send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
|
|
rescue => e
|
|
@server.report_exception(e, notification: "elicitation_complete")
|
|
end
|
|
|
|
# Sends a progress notification to this session only.
|
|
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
|
|
params = {
|
|
"progressToken" => progress_token,
|
|
"progress" => progress,
|
|
"total" => total,
|
|
"message" => message,
|
|
}.compact
|
|
|
|
send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
|
|
rescue => e
|
|
@server.report_exception(e, notification: "progress")
|
|
end
|
|
|
|
# Sends a log message notification to this session only.
|
|
def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
|
|
effective_logging = @logging_message_notification || @server.logging_message_notification
|
|
return unless effective_logging&.should_notify?(level)
|
|
|
|
params = { "data" => data, "level" => level }
|
|
params["logger"] = logger if logger
|
|
|
|
send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
|
|
rescue => e
|
|
@server.report_exception(e, { notification: "log_message" })
|
|
end
|
|
|
|
private
|
|
|
|
# Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
|
|
# a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
|
|
#
|
|
# TODO: When Ruby 2.7 support is dropped, replace with a direct call:
|
|
# `@transport.send_notification(method, params, session_id: @session_id)` and
|
|
# add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
|
|
def send_to_transport(method, params, related_request_id: nil)
|
|
if @session_id
|
|
@transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
|
|
else
|
|
@transport.send_notification(method, params)
|
|
end
|
|
end
|
|
|
|
# Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
|
|
# a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
|
|
#
|
|
# TODO: When Ruby 2.7 support is dropped, replace with a direct call:
|
|
# `@transport.send_request(method, params, session_id: @session_id)` and
|
|
# add `**` to `Transport#send_request` and `StdioTransport#send_request`.
|
|
def send_to_transport_request(method, params, related_request_id: nil)
|
|
if @session_id
|
|
@transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
|
|
else
|
|
@transport.send_request(method, params)
|
|
end
|
|
end
|
|
end
|
|
end
|