## Motivation and Context The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle that the current implementation answers with the wrong signal: - The RPCs the modern lifecycle removes (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe`) were still dispatched, or answered incidental errors (`initialize` answered `-32022`, `resources/subscribe` a capability error); the requirement is `-32601` Method not found with HTTP 404. - `server/discover` was fully envelope-exempt, while the requirements validate its envelope like every other modern request. The per-request `_meta` validation itself (`-32602` naming the offending keys, optional `clientInfo`, era classification on the reserved `protocolVersion` key alone) landed separately in #491; this change builds on that shape and completes the admission rules: - `Server#handle_request` rejects `Methods::MODERN_REMOVED_METHODS` on a modern-era session with `-32601` before the capability check, since the method does not exist in that era at all. The Streamable HTTP modern path maps `-32601` to 404 and `-32602` to 400 through its existing status ladder. - The dual-era sniff treats an `initialize` carrying the modern envelope as modern traffic (a modern client naming a removed method) instead of the legacy-distinctive handshake, and `server/discover` keeps its envelope exemption outside the modern era only. The envelope-less rejection on a modern-locked session adopts #491's wire shape, `-32602` with the detailed message. One deliberate divergence from the Python SDK: its dual-era stream loop answers an `initialize` on a modern-locked connection with `-32022` carrying the supported-version list, while its HTTP path serves the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently, so this SDK answers `-32601` on every transport, stdio included, for uniform era semantics. The stdio era-lock tests used a modern `ping` as the era-distinctive request; they now use `tools/list`, since `ping` no longer exists in the modern lifecycle. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New and updated tests cover each admission rule on `Server#handle`, the Streamable HTTP modern path, and the stdio era lock (all five removed methods per transport, the envelope requirement on `server/discover`, and the envelope-carrying `initialize` routing). Against the conformance fixture server, every admission check of the `server-stateless` scenario passes at `--spec-version 2026-07-28`: the per-request `_meta` validation, removed-method 404, discovery, capability, and version-negotiation checks are all green, and the only remaining failures are the `subscriptions/listen` checks that belong to separate work. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy `_meta` (`progressToken`, trace context) keeps its legacy classification. Within the modern lifecycle, previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate.
117 lines
4.8 KiB
Ruby
117 lines
4.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MCP
|
|
module Methods
|
|
INITIALIZE = "initialize"
|
|
PING = "ping"
|
|
LOGGING_SET_LEVEL = "logging/setLevel"
|
|
# Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
|
|
SERVER_DISCOVER = "server/discover"
|
|
|
|
PROMPTS_GET = "prompts/get"
|
|
PROMPTS_LIST = "prompts/list"
|
|
COMPLETION_COMPLETE = "completion/complete"
|
|
|
|
RESOURCES_LIST = "resources/list"
|
|
RESOURCES_READ = "resources/read"
|
|
RESOURCES_TEMPLATES_LIST = "resources/templates/list"
|
|
RESOURCES_SUBSCRIBE = "resources/subscribe"
|
|
RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"
|
|
|
|
TOOLS_CALL = "tools/call"
|
|
TOOLS_LIST = "tools/list"
|
|
|
|
# RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575):
|
|
# `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`,
|
|
# `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource
|
|
# subscription pair by the connectionless model, which leaves nothing to keep alive
|
|
# or subscribe on. A modern-era request naming one of these answers with `-32601`
|
|
# Method not found (HTTP 404 on Streamable HTTP).
|
|
MODERN_REMOVED_METHODS = [
|
|
INITIALIZE,
|
|
PING,
|
|
LOGGING_SET_LEVEL,
|
|
RESOURCES_SUBSCRIBE,
|
|
RESOURCES_UNSUBSCRIBE,
|
|
].freeze
|
|
|
|
ROOTS_LIST = "roots/list"
|
|
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
|
ELICITATION_CREATE = "elicitation/create"
|
|
|
|
# Notification methods
|
|
NOTIFICATIONS_INITIALIZED = "notifications/initialized"
|
|
NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
|
|
NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
|
|
NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
|
|
NOTIFICATIONS_RESOURCES_UPDATED = "notifications/resources/updated"
|
|
NOTIFICATIONS_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"
|
|
NOTIFICATIONS_MESSAGE = "notifications/message"
|
|
NOTIFICATIONS_PROGRESS = "notifications/progress"
|
|
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
|
|
NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"
|
|
|
|
class MissingRequiredCapabilityError < StandardError
|
|
attr_reader :method
|
|
attr_reader :capability
|
|
|
|
def initialize(method, capability)
|
|
super("Server does not support #{capability} (required for #{method})")
|
|
@method = method
|
|
@capability = capability
|
|
end
|
|
end
|
|
|
|
class << self
|
|
def notification?(method)
|
|
method.is_a?(String) && method.start_with?("notifications/")
|
|
end
|
|
|
|
def ensure_capability!(method, capabilities)
|
|
case method
|
|
when PROMPTS_GET, PROMPTS_LIST
|
|
require_capability!(method, capabilities, :prompts)
|
|
when NOTIFICATIONS_PROMPTS_LIST_CHANGED
|
|
require_capability!(method, capabilities, :prompts)
|
|
require_capability!(method, capabilities, :prompts, :listChanged)
|
|
when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ
|
|
require_capability!(method, capabilities, :resources)
|
|
when NOTIFICATIONS_RESOURCES_LIST_CHANGED
|
|
require_capability!(method, capabilities, :resources)
|
|
require_capability!(method, capabilities, :resources, :listChanged)
|
|
when RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE, NOTIFICATIONS_RESOURCES_UPDATED
|
|
require_capability!(method, capabilities, :resources)
|
|
require_capability!(method, capabilities, :resources, :subscribe)
|
|
when TOOLS_CALL, TOOLS_LIST
|
|
require_capability!(method, capabilities, :tools)
|
|
when NOTIFICATIONS_TOOLS_LIST_CHANGED
|
|
require_capability!(method, capabilities, :tools)
|
|
require_capability!(method, capabilities, :tools, :listChanged)
|
|
when LOGGING_SET_LEVEL, NOTIFICATIONS_MESSAGE
|
|
require_capability!(method, capabilities, :logging)
|
|
when COMPLETION_COMPLETE
|
|
require_capability!(method, capabilities, :completions)
|
|
when ROOTS_LIST
|
|
require_capability!(method, capabilities, :roots)
|
|
when SAMPLING_CREATE_MESSAGE
|
|
require_capability!(method, capabilities, :sampling)
|
|
when ELICITATION_CREATE
|
|
require_capability!(method, capabilities, :elicitation)
|
|
when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
|
|
# No specific capability required.
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def require_capability!(method, capabilities, *keys)
|
|
name = keys.join(".") # :resources, :subscribe -> "resources.subscribe"
|
|
has_capability = capabilities.dig(*keys)
|
|
return if has_capability
|
|
|
|
raise MissingRequiredCapabilityError.new(method, name)
|
|
end
|
|
end
|
|
end
|
|
end
|