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:
parent
5d8f1bdf9f
commit
bbe637612b
5 changed files with 731 additions and 16 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
21
lib/mcp/progress.rb
Normal 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
|
||||
|
|
@ -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
26
lib/mcp/server_context.rb
Normal 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
|
||||
Loading…
Reference in a new issue