ruby-sdk/lib/mcp/methods.rb
Koichi ITO 6d42cf7826 Fix an incorrect result: null response to an id-bearing notification message
## Motivation and Context

A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.

Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.

Fixes #434.

## How Has This Been Tested?

Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.

## Breaking Changes

None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
2026-07-01 01:55:15 +09:00

101 lines
4 KiB
Ruby

# frozen_string_literal: true
module MCP
module Methods
INITIALIZE = "initialize"
PING = "ping"
LOGGING_SET_LEVEL = "logging/setLevel"
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"
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, 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