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
5 changed files with 731 additions and 16 deletions

View file

@ -7,6 +7,7 @@ require_relative "mcp/content"
require_relative "mcp/icon"
require_relative "mcp/instrumentation"
require_relative "mcp/methods"
require_relative "mcp/progress"
require_relative "mcp/prompt"
require_relative "mcp/prompt/argument"
require_relative "mcp/prompt/message"
@ -16,6 +17,7 @@ require_relative "mcp/resource/contents"
require_relative "mcp/resource/embedded"
require_relative "mcp/resource_template"
require_relative "mcp/server"
require_relative "mcp/server_context"
require_relative "mcp/server/transports/streamable_http_transport"
require_relative "mcp/server/transports/stdio_transport"
require_relative "mcp/string_utils"

View file

@ -90,6 +90,7 @@ module MCP
#
# @param tool [MCP::Client::Tool] The tool to be called.
# @param arguments [Object, nil] The arguments to pass to the tool.
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
# @return [Hash] The full JSON-RPC response from the transport.
#
# @example
@ -100,12 +101,17 @@ module MCP
# @note
# The exact requirements for `arguments` are determined by the transport layer in use.
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
def call_tool(tool:, arguments: nil)
def call_tool(tool:, arguments: nil, progress_token: nil)
params = { name: tool.name, arguments: arguments }
if progress_token
params[:_meta] = { progressToken: progress_token }
end
transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "tools/call",
params: { name: tool.name, arguments: arguments },
params: params,
})
end

21
lib/mcp/progress.rb Normal file
View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module MCP
class Progress
def initialize(server:, progress_token:)
@server = server
@progress_token = progress_token
end
def report(progress, total: nil, message: nil)
return unless @progress_token
@server.notify_progress(
progress_token: @progress_token,
progress: progress,
total: total,
message: message,
)
end
end
end

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

26
lib/mcp/server_context.rb Normal file
View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module MCP
class ServerContext
def initialize(context, progress:)
@context = context
@progress = progress
end
def report_progress(progress, total: nil, message: nil)
@progress.report(progress, total: total, message: message)
end
def method_missing(name, ...)
if @context.respond_to?(name)
@context.public_send(name, ...)
else
super
end
end
def respond_to_missing?(name, include_private = false)
@context.respond_to?(name) || super
end
end
end