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
69 changes: 53 additions & 16 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,15 @@ def initialize(elicitations)
#
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
class UnsupportedProtocolVersionError < RequestHandlerError
def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS)
# No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash
# into keywords and fail with "unknown keywords".
def initialize(requested, request = nil)
super(
"Unsupported protocol version",
request,
error_type: :unsupported_protocol_version,
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
error_data: { supported: supported, requested: requested || "unknown" },
error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
)
end
end
Expand Down Expand Up @@ -560,25 +562,26 @@ def handle_request(request, method, session: nil, related_request_id: nil)
server_context: { request: request },
exception_already_reported: ->(e) { reported_exception.equal?(e) },
) do
envelope = lift_request_envelope(params, method: method, session: session)
result = case method
when Methods::INITIALIZE
init(params, session: session)
when Methods::RESOURCES_READ
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope))
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
validate_resource_subscription_params!(params)
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
{}
when Methods::TOOLS_CALL
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
when Methods::PROMPTS_GET
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
when Methods::COMPLETION_COMPLETE
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
when Methods::LOGGING_SET_LEVEL
configure_logging_level(params, session: session)
else
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
end
client = session&.client || @client
add_instrumentation_data(client: client) if client
Expand Down Expand Up @@ -609,6 +612,34 @@ def handle_request(request, method, session: nil, related_request_id: nil)
}
end

# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
# pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is
# rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for
# every other request.
def lift_request_envelope(params, method:, session:)
return if Methods.notification?(method)
return if method == Methods::SERVER_DISCOVER

modern_session = session.respond_to?(:era) && session.era == :modern

if modern_session && method == Methods::INITIALIZE
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
raise UnsupportedProtocolVersionError.new(requested, params)
end

if RequestEnvelope.modern?(params)
RequestEnvelope.parse!(params, request: params)
elsif modern_session
raise RequestHandlerError.new(
"Invalid Request: modern sessions require the SEP-2575 `_meta` envelope",
params,
error_type: :invalid_request,
)
end
end

def handle_cancelled_notification(params, session: nil)
return unless session
return unless params.is_a?(Hash)
Expand Down Expand Up @@ -748,7 +779,7 @@ def list_tools(request)
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
end

def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
tool_name = request[:name]

tool = tools[tool_name]
Expand Down Expand Up @@ -781,7 +812,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
progress_token = request.dig(:_meta, :progressToken)

response = call_tool_with_args(
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope
)
result = response.to_h
validate_tool_call_result!(tool, result)
Expand All @@ -808,7 +839,7 @@ def list_prompts(request)
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
end

def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
prompt_name = request[:name]
prompt = @prompts[prompt_name]
unless prompt
Expand All @@ -826,6 +857,7 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)

call_prompt_template_with_args(prompt, prompt_args, server_context)
Expand Down Expand Up @@ -921,7 +953,7 @@ def apply_cache_metadata(result)
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
end

def complete(params, session: nil, related_request_id: nil, cancellation: nil)
def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
validate_completion_params!(params)

result = dispatch_optional_context_handler(
Expand All @@ -930,6 +962,7 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)

normalize_completion_result(result)
Expand All @@ -938,28 +971,30 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
# `server_context.raise_if_cancelled!`.
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
dispatch_optional_context_handler(
@handlers[Methods::RESOURCES_READ],
request,
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)
end

# Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
return handler.call(params) unless handler_declares_server_context?(handler)

server_context = build_server_context(
request: params,
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)
handler.call(params, server_context: server_context)
end
Expand All @@ -984,7 +1019,7 @@ def handler_declares_server_context?(handler)

# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
# (progress, cancellation, nested server-to-client requests).
def build_server_context(request:, session:, related_request_id:, cancellation:)
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
meta_source = request.is_a?(Hash) ? request : {}
progress_token = meta_source.dig(:_meta, :progressToken)
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
Expand All @@ -994,6 +1029,7 @@ def build_server_context(request:, session:, related_request_id:, cancellation:)
notification_target: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)
end

Expand Down Expand Up @@ -1053,7 +1089,7 @@ def accepts_server_context?(method_object)
end
end

def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
Expand All @@ -1068,6 +1104,7 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session:
notification_target: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
)
tool.call(**args, server_context: server_context)
else
Expand Down
61 changes: 60 additions & 1 deletion lib/mcp/server_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ module MCP
class ServerContext
attr_reader :cancellation

def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
# The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern;
# `nil` on legacy requests.
attr_reader :envelope

def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil)
@context = context
@progress = progress
@notification_target = notification_target
@related_request_id = related_request_id
@cancellation = cancellation
@envelope = envelope
end

def cancelled?
Expand All @@ -20,6 +25,52 @@ def raise_if_cancelled!
@cancellation&.raise_if_cancelled!
end

# Whether the current request follows the stateless modern lifecycle (SEP-2575).
def modern?
!@envelope.nil?
end

# Client identity for the current request. Modern requests carry it in the `_meta` envelope;
# legacy sessions fall back to the state stored by `initialize`. The envelope always wins
# because servers MUST NOT infer identity from prior requests.
def client_info
return @envelope.client_info if @envelope

@notification_target.client if @notification_target.respond_to?(:client)
end

# Client capabilities for the current request, with the same envelope-first resolution as {#client_info}.
def client_capabilities
return @envelope.client_capabilities if @envelope

@notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities)
end

# The protocol version the current request was made with. `nil` on legacy requests,
# where the version is a session-level negotiation result rather than per-request data.
def protocol_version
@envelope&.protocol_version
end

# Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys,
# e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError`
# (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared.
def require_client_capability!(*path)
raise ArgumentError, "at least one capability key is required" if path.empty?

declared = client_capabilities
value = path.reduce(declared) do |acc, key|
break unless acc.is_a?(Hash)

symbol_value = acc[key.to_sym]
symbol_value.nil? ? acc[key.to_s] : symbol_value
end
return unless value.nil?

required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } }
raise Server::MissingRequiredClientCapabilityError, required
end

# Reports progress for the current tool operation.
# The notification is automatically scoped to the originating session.
#
Expand All @@ -41,6 +92,14 @@ def report_progress(progress, total: nil, message: nil)
def notify_log_message(data:, level:, logger: nil)
return unless @notification_target

# Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`,
# the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops
# the message the same way. Session- or server-level gating still applies downstream on delegation.
if @envelope
threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level)
return unless threshold&.valid_level? && threshold.should_notify?(level)
end

@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
end

Expand Down
29 changes: 28 additions & 1 deletion lib/mcp/server_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@ 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
ERAS = [:legacy, :modern].freeze

attr_reader :session_id, :client, :logging_message_notification

def initialize(server:, transport:, session_id: nil)
# Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds,
# then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions
# with `era: :modern` up front.
attr_reader :era

def initialize(server:, transport:, session_id: nil, era: nil)
validate_era!(era) if era

@server = server
@transport = transport
@session_id = session_id
@era = era
@client = nil
@client_capabilities = nil
@logging_message_notification = nil
Expand All @@ -31,6 +41,19 @@ def initialized?
# (the initialization phase MUST be the first interaction).
def mark_initialized!
@initialized = true
# A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575),
# so it also locks the connection era.
@era ||= :legacy
end

# One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises,
# because a connection can never change eras.
def lock_era!(era)
validate_era!(era)
return if @era == era
raise "Session era already locked to #{@era}" if @era

@era = era
end

# Registers a `Cancellation` token for an in-flight request.
Expand Down Expand Up @@ -236,6 +259,10 @@ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)

private

def validate_era!(era)
raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era)
end

# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
Expand Down
Loading