Support progress notifications per MCP specification

## Motivation and Context

The MCP specification defines `notifications/progress` for reporting incremental progress
during long-running tool operations. The Ruby SDK did not yet support this, so tools had no way to
send progress updates to clients. This caused the `tools-call-with-progress` conformance test to fail.

This adds an `MCP::ServerContext` wrapper that provides a `report_progress` method on
`server_context`. Tools call `server_context.report_progress(value, total:, message:)` to send
notifications back to the client. The method is a no-op when no `progressToken` was provided,
so tools can call it unconditionally without checking for a token.

This design is consistent with the TypeScript and Python SDKs, which provide progress
reporting through a context object rather than a separate parameter.

On the client side, `Client#call_tool` now accepts a `progress_token:` parameter that sets
`_meta.progressToken` in the request.

Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/progress

## How Has This Been Tested?

Added comprehensive tests in `test/mcp/server_progress_test.rb` covering `notify_progress`,
tool integration with progress, block-defined tools, `**kwargs` tools, and no-op behavior
without a token.

Added client tests for `progress_token:` parameter inclusion and omission.

Added `test/mcp/server_context_test.rb` test for non-Hash `server_context` preservation
when `_meta` is present.

Conformance: `tools-call-with-progress` removed from expected failures.

## Breaking Change

`server_context` is now always an `MCP::ServerContext` instance when passed to tools,
even if the server has no context configured. Code that checks `server_context ?` to
detect a nil context will need to be updated, as `ServerContext` is always truthy.
`ServerContext` delegates method calls to the underlying context object via
`method_missing`, so `server_context[:key]` and other accessor patterns continue to work.

In practice, this is unlikely to affect users. A tool that declares `server_context:`
is opting in to use the context; if the server has no context configured, that is a
server configuration issue rather than something the tool should handle with a nil check.
This commit is contained in:
Koichi ITO 2026-02-20 02:34:26 +09:00
commit bbe637612b

View file

@ -96,6 +96,7 @@ module MCP
Methods::INITIALIZE => method(:init),
Methods::PING => ->(_) { {} },
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
# No op handlers for currently unsupported methods
@ -168,6 +169,21 @@ module MCP
report_exception(e, { notification: "resources_list_changed" })
end
def notify_progress(progress_token:, progress:, total: nil, message: nil)
return unless @transport
params = {
"progressToken" => progress_token,
"progress" => progress,
"total" => total,
"message" => message,
}.compact
@transport.send_notification(Methods::NOTIFICATIONS_PROGRESS, params)
rescue => e
report_exception(e, notification: "progress")
end
def notify_log_message(data:, level:, logger: nil)
return unless @transport
return unless logging_message_notification&.should_notify?(level)
@ -419,7 +435,9 @@ module MCP
end
end
call_tool_with_args(tool, arguments, server_context_with_meta(request))
progress_token = request.dig(:_meta, :progressToken)
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token)
rescue RequestHandlerError
raise
rescue => e
@ -488,10 +506,12 @@ module MCP
parameters.any? { |type, name| type == :keyrest || name == :server_context }
end
def call_tool_with_args(tool, arguments, server_context)
def call_tool_with_args(tool, arguments, context, progress_token: nil)
args = arguments&.transform_keys(&:to_sym) || {}
if accepts_server_context?(tool.method(:call))
progress = Progress.new(server: self, progress_token: progress_token)
server_context = ServerContext.new(context, progress: progress)
tool.call(**args, server_context: server_context).to_h
else
tool.call(**args).to_h
@ -508,13 +528,13 @@ module MCP
def server_context_with_meta(request)
meta = request[:_meta]
if @server_context && meta
if meta && @server_context.is_a?(Hash)
context = @server_context.dup
context[:_meta] = meta
context
elsif meta
elsif meta && @server_context.nil?
{ _meta: meta }
elsif @server_context
else
@server_context
end
end