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

View file

@ -108,11 +108,12 @@ The server supports sending notifications to clients when lists of tools, prompt
#### Notification Methods
The server provides three notification methods:
The server provides the following notification methods:
- `notify_tools_list_changed` - Send a notification when the tools list changes
- `notify_prompts_list_changed` - Send a notification when the prompts list changes
- `notify_resources_list_changed` - Send a notification when the resources list changes
- `notify_progress` - Send a progress notification for long-running operations
- `notify_log_message` - Send a structured logging notification message
#### Notification Format
@ -122,8 +123,72 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
- `notifications/tools/list_changed`
- `notifications/prompts/list_changed`
- `notifications/resources/list_changed`
- `notifications/progress`
- `notifications/message`
### Progress
The MCP Ruby SDK supports progress tracking for long-running tool operations,
following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress).
#### How Progress Works
1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool
2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution
3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress
#### Server-Side: Tool with Progress
Tools that accept a `server_context:` parameter can call `report_progress` on it.
The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method:
```ruby
class LongRunningTool < MCP::Tool
description "A tool that reports progress during execution"
input_schema(
properties: {
count: { type: "integer" },
},
required: ["count"]
)
def self.call(count:, server_context:)
count.times do |i|
# Do work here.
server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}")
end
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
end
end
```
The `server_context.report_progress` method accepts:
- `progress` (required) — current progress value (numeric)
- `total:` (optional) — total expected value, so clients can display a percentage
- `message:` (optional) — human-readable status message
#### Server-Side: Direct `notify_progress` Usage
You can also call `notify_progress` directly on the server instance:
```ruby
server.notify_progress(
progress_token: "token-123",
progress: 50,
total: 100, # optional
message: "halfway" # optional
)
```
**Key Features:**
- Tools report progress via `server_context.report_progress`
- `report_progress` is a no-op when no `progressToken` was provided by the client
- `notify_progress` is a no-op when no transport is configured
- Supports both numeric and string progress tokens
### Logging
The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging).
@ -1044,8 +1109,17 @@ response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
# Call a tool with progress tracking.
response = client.call_tool(
tool: tools.first,
arguments: { count: 10 },
progress_token: "my-progress-token"
)
```
The server will send `notifications/progress` back to the client during execution.
#### HTTP Authorization
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:

View file

@ -5,5 +5,3 @@ server:
- tools-call-elicitation
- elicitation-sep1034-defaults
- elicitation-sep1330-enums
# TODO: The SDK does not extract `_meta.progressToken` from tool call requests or deliver `notifications/progress` to tools.
- tools-call-with-progress

View file

@ -141,13 +141,16 @@ module Conformance
end
end
# test_tool_with_progress: the actual progress dispatch is in `tools_call_handler`
class TestToolWithProgress < MCP::Tool
tool_name "test_tool_with_progress"
description "A tool that reports progress notifications"
class << self
def call(**_args)
def call(server_context:, **_args)
server_context.report_progress(0, total: 100)
server_context.report_progress(50, total: 100)
server_context.report_progress(100, total: 100)
MCP::Tool::Response.new([MCP::Content::Text.new("Progress complete").to_h])
end
end

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

View file

@ -305,5 +305,43 @@ module MCP
assert_equal({}, contents)
end
def test_call_tool_includes_meta_progress_token_when_provided
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
progress_token = "my-progress-token"
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :_meta, :progressToken) == progress_token &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params, :arguments) == arguments
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token)
end
def test_call_tool_omits_meta_when_no_progress_token
transport = mock
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] },
}
transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "tools/call" &&
args.dig(:request, :params, :name) == "tool1" &&
args.dig(:request, :params).key?(:_meta) == false
end.returns(mock_response).once
client = Client.new(transport: transport)
client.call_tool(tool: tool, arguments: arguments)
end
end
end

64
test/mcp/progress_test.rb Normal file
View file

@ -0,0 +1,64 @@
# frozen_string_literal: true
require "test_helper"
module MCP
class ProgressTest < ActiveSupport::TestCase
class MockTransport < Transport
attr_reader :notifications
def initialize(server)
super
@notifications = []
end
def send_notification(method, params = nil)
@notifications << { method: method, params: params }
true
end
def send_response(response); end
def open; end
def close; end
def handle_request(request); end
end
setup do
@server = Server.new(name: "test_server")
@transport = MockTransport.new(@server)
@server.transport = @transport
end
test "#report is a no-op when progress_token is nil" do
progress = Progress.new(server: @server, progress_token: nil)
progress.report(50, total: 100, message: "halfway")
assert_equal 0, @transport.notifications.size
end
test "#report sends notification when progress_token is present" do
progress = Progress.new(server: @server, progress_token: "token-1")
progress.report(50, total: 100, message: "halfway")
assert_equal 1, @transport.notifications.size
notification = @transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "token-1", notification[:params]["progressToken"]
assert_equal 50, notification[:params]["progress"]
assert_equal 100, notification[:params]["total"]
assert_equal "halfway", notification[:params]["message"]
end
test "#report omits total and message when not provided" do
progress = Progress.new(server: @server, progress_token: "token-1")
progress.report(50)
assert_equal 1, @transport.notifications.size
notification = @transport.notifications.first
assert_equal "token-1", notification[:params]["progressToken"]
assert_equal 50, notification[:params]["progress"]
refute notification[:params].key?("total")
refute notification[:params].key?("message")
end
end
end

View file

@ -4,6 +4,63 @@ require "test_helper"
module MCP
class ServerContextTest < ActiveSupport::TestCase
test "ServerContext delegates method calls to the underlying context" do
context = { user: "test_user" }
progress = Progress.new(server: mock, progress_token: nil)
server_context = ServerContext.new(context, progress: progress)
assert_equal "test_user", server_context[:user]
end
test "ServerContext respond_to? returns true for methods on the underlying context" do
context = { user: "test_user" }
progress = Progress.new(server: mock, progress_token: nil)
server_context = ServerContext.new(context, progress: progress)
assert_respond_to server_context, :[]
assert_respond_to server_context, :keys
end
test "ServerContext respond_to? returns false for methods not on the underlying context" do
context = { user: "test_user" }
progress = Progress.new(server: mock, progress_token: nil)
server_context = ServerContext.new(context, progress: progress)
refute_respond_to server_context, :nonexistent_method
end
test "ServerContext raises NoMethodError for methods not on the underlying context" do
context = { user: "test_user" }
progress = Progress.new(server: mock, progress_token: nil)
server_context = ServerContext.new(context, progress: progress)
assert_raises(NoMethodError) { server_context.nonexistent_method }
end
test "ServerContext delegates to custom object context" do
context = Object.new
def context.custom_method
"custom_value"
end
progress = Progress.new(server: mock, progress_token: nil)
server_context = ServerContext.new(context, progress: progress)
assert_equal "custom_value", server_context.custom_method
end
test "ServerContext#report_progress works with nil context" do
progress = mock
progress.expects(:report).with(50, total: 100, message: nil).once
server_context = ServerContext.new(nil, progress: progress)
server_context.report_progress(50, total: 100)
end
# Tool without server_context parameter
class SimpleToolWithoutContext < Tool
tool_name "simple_without_context"
@ -27,7 +84,8 @@ module MCP
class << self
def call(message:, server_context: nil)
context_info = server_context ? "with context: #{server_context[:user]}" : "no context"
user = server_context.respond_to?(:[]) ? server_context[:user] : nil
context_info = user ? "with context: #{user}" : "no context"
Tool::Response.new([
{ type: "text", content: "ToolWithOptionalContext: #{message} (#{context_info})" },
])
@ -241,7 +299,8 @@ module MCP
class << self
def template(args, server_context: nil)
context_info = server_context ? "with context: #{server_context[:user]}" : "no context"
user = server_context.respond_to?(:[]) ? server_context[:user] : nil
context_info = user ? "with context: #{user}" : "no context"
Prompt::Result.new(
messages: [
Prompt::Message.new(
@ -588,6 +647,45 @@ module MCP
response[:result][:messages][0][:content][:text]
end
test "non-Hash server_context is preserved when _meta is present" do
server_obj = Object.new
def server_obj.custom_method
"custom_value"
end
tool_class = Class.new(Tool) do
tool_name "tool_with_non_hash_context"
define_singleton_method(:call) do |server_context:, **_args|
Tool::Response.new([
{ type: "text", content: "custom: #{server_context.custom_method}" },
])
end
end
server = Server.new(
name: "test_server",
tools: [tool_class],
server_context: server_obj,
)
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "tool_with_non_hash_context",
arguments: {},
_meta: { progressToken: "token-1" },
},
}
response = server.handle(request)
assert response[:result]
assert_equal "custom: custom_value", response[:result][:content][0][:content]
end
test "_meta is nested within server_context for prompts" do
class PromptWithNestedContext < Prompt
prompt_name "prompt_with_nested_context"

View file

@ -0,0 +1,365 @@
# frozen_string_literal: true
require "test_helper"
module MCP
class ServerProgressTest < ActiveSupport::TestCase
class MockTransport < Transport
attr_reader :notifications
def initialize(server)
super
@notifications = []
end
def send_notification(method, params = nil)
@notifications << { method: method, params: params }
true
end
def send_response(response); end
def open; end
def close; end
def handle_request(request); end
end
# Tool without progress parameter.
class SimpleToolWithoutProgress < Tool
tool_name "simple_without_progress"
description "A tool that doesn't use progress"
input_schema(properties: { message: { type: "string" } }, required: ["message"])
class << self
def call(message:)
Tool::Response.new([{ type: "text", text: "SimpleToolWithoutProgress: #{message}" }])
end
end
end
# Tool with progress via server_context.
class ToolWithProgress < Tool
tool_name "tool_with_progress"
description "A tool that uses progress"
input_schema(properties: { message: { type: "string" } }, required: ["message"])
class << self
def call(message:, server_context:)
server_context.report_progress(50, total: 100, message: "halfway")
server_context.report_progress(100, total: 100, message: "done")
Tool::Response.new([{ type: "text", text: "ToolWithProgress: #{message}" }])
end
end
end
# Tool with server_context accessing both context data and progress.
class ToolWithContextAndProgress < Tool
tool_name "tool_with_context_and_progress"
description "A tool that uses both server_context and progress"
input_schema(properties: { message: { type: "string" } }, required: ["message"])
class << self
def call(message:, server_context:)
server_context.report_progress(100)
context_info = server_context[:user] || "none"
Tool::Response.new([{ type: "text", text: "ToolWithContextAndProgress: #{message} context=#{context_info}" }])
end
end
end
# Tool with **kwargs.
class ToolWithKwargs < Tool
tool_name "tool_with_kwargs"
description "A tool that uses **kwargs"
class << self
def call(**kwargs)
context = kwargs[:server_context]
context.report_progress(75)
Tool::Response.new([{ type: "text", text: "ToolWithKwargs: progress=#{context ? "present" : "absent"}" }])
end
end
end
setup do
@server = Server.new(
name: "test_server",
version: "1.0.0",
tools: [SimpleToolWithoutProgress, ToolWithProgress, ToolWithContextAndProgress, ToolWithKwargs],
server_context: { user: "test_user" },
)
@mock_transport = MockTransport.new(@server)
@server.transport = @mock_transport
end
test "#notify_progress sends correct notification through transport with all params" do
@server.notify_progress(progress_token: "token-1", progress: 50, total: 100, message: "halfway")
assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "token-1", notification[:params]["progressToken"]
assert_equal 50, notification[:params]["progress"]
assert_equal 100, notification[:params]["total"]
assert_equal "halfway", notification[:params]["message"]
end
test "#notify_progress omits total and message when nil" do
@server.notify_progress(progress_token: "token-1", progress: 50)
assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "token-1", notification[:params]["progressToken"]
assert_equal 50, notification[:params]["progress"]
refute notification[:params].key?("total")
refute notification[:params].key?("message")
end
test "#notify_progress does nothing without transport" do
server_without_transport = Server.new(name: "test_server")
assert_nothing_raised do
server_without_transport.notify_progress(progress_token: "token-1", progress: 50)
end
end
test "#notify_progress handles transport errors gracefully" do
error_transport = Class.new(MockTransport) do
def send_notification(method, params = nil)
raise StandardError, "Transport error"
end
end.new(@server)
@server.transport = error_transport
@server.configuration.exception_reporter.expects(:call).once.with do |exception, context|
assert_kind_of StandardError, exception
assert_equal "Transport error", exception.message
assert_equal({ notification: "progress" }, context)
true
end
assert_nothing_raised do
@server.notify_progress(progress_token: "token-1", progress: 50)
end
end
test "tool with progress parameter receives Progress instance and sends notifications via _meta.progressToken" do
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "tool_with_progress",
arguments: { message: "Hello" },
_meta: { progressToken: "token-abc" },
},
}
response = @server.handle(request)
assert response[:result]
assert_equal "ToolWithProgress: Hello", response[:result][:content][0][:text]
assert_equal 2, @mock_transport.notifications.size
first = @mock_transport.notifications[0]
assert_equal Methods::NOTIFICATIONS_PROGRESS, first[:method]
assert_equal "token-abc", first[:params]["progressToken"]
assert_equal 50, first[:params]["progress"]
assert_equal 100, first[:params]["total"]
assert_equal "halfway", first[:params]["message"]
second = @mock_transport.notifications[1]
assert_equal Methods::NOTIFICATIONS_PROGRESS, second[:method]
assert_equal "token-abc", second[:params]["progressToken"]
assert_equal 100, second[:params]["progress"]
assert_equal 100, second[:params]["total"]
assert_equal "done", second[:params]["message"]
end
test "tool without progress parameter works normally" do
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "simple_without_progress",
arguments: { message: "Hello" },
},
}
response = @server.handle(request)
assert response[:result]
assert_equal "SimpleToolWithoutProgress: Hello", response[:result][:content][0][:text]
assert_equal 0, @mock_transport.notifications.size
end
test "server_context.report_progress is a no-op when no progressToken in request" do
received_context = :unset
tool_class = Class.new(Tool) do
tool_name "progress_nil_tool"
define_singleton_method(:call) do |server_context:|
received_context = server_context
Tool::Response.new([{ type: "text", text: "done" }])
end
end
server = Server.new(
name: "test_server",
tools: [tool_class],
server_context: { user: "test" },
)
server.transport = @mock_transport
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "progress_nil_tool",
arguments: {},
},
}
server.handle(request)
assert_instance_of ServerContext, received_context
assert_nothing_raised { received_context.report_progress(50) }
assert_equal 0, @mock_transport.notifications.size
end
test "tool with both server_context and progress receives both" do
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "tool_with_context_and_progress",
arguments: { message: "Hello" },
_meta: { progressToken: "token-xyz" },
},
}
response = @server.handle(request)
assert response[:result]
assert_equal "ToolWithContextAndProgress: Hello context=test_user", response[:result][:content][0][:text]
assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "token-xyz", notification[:params]["progressToken"]
assert_equal 100, notification[:params]["progress"]
end
test "tool with **kwargs receives Progress instance" do
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "tool_with_kwargs",
arguments: {},
_meta: { progressToken: "token-kwargs" },
},
}
response = @server.handle(request)
assert response[:result]
assert_equal "ToolWithKwargs: progress=present", response[:result][:content][0][:text]
assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "token-kwargs", notification[:params]["progressToken"]
assert_equal 75, notification[:params]["progress"]
end
test "block-defined tool with progress via server_context works" do
server = Server.new(name: "test_server")
server.transport = @mock_transport
server.define_tool(name: "block_tool") do |server_context:|
server_context.report_progress(42, total: 100)
Tool::Response.new([{ type: "text", text: "block_tool done" }])
end
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "block_tool",
arguments: {},
_meta: { progressToken: "block-token" },
},
}
response = server.handle(request)
assert response[:result]
assert_equal "block_tool done", response[:result][:content][0][:text]
assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method]
assert_equal "block-token", notification[:params]["progressToken"]
assert_equal 42, notification[:params]["progress"]
assert_equal 100, notification[:params]["total"]
end
test "multiple progress notifications during tool execution" do
server = Server.new(name: "test_server")
server.transport = @mock_transport
server.define_tool(name: "multi_progress_tool") do |server_context:|
(1..5).each do |i|
server_context.report_progress(i * 20, total: 100)
end
Tool::Response.new([{ type: "text", text: "done" }])
end
request = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "multi_progress_tool",
arguments: {},
_meta: { progressToken: "multi-token" },
},
}
server.handle(request)
assert_equal 5, @mock_transport.notifications.size
@mock_transport.notifications.each_with_index do |n, i|
assert_equal Methods::NOTIFICATIONS_PROGRESS, n[:method]
assert_equal "multi-token", n[:params]["progressToken"]
assert_equal (i + 1) * 20, n[:params]["progress"]
assert_equal 100, n[:params]["total"]
end
end
test "incoming notifications/progress is handled as no-op" do
request = {
jsonrpc: "2.0",
method: "notifications/progress",
params: {
progressToken: "token-1",
progress: 50,
},
}
# Should not raise and should return nil (notification, no id).
result = @server.handle(request)
assert_nil result
end
end
end

View file

@ -294,7 +294,7 @@ module MCP
if RUBY_VERSION >= "3.1"
# Ruby 3.1+: Mocha stub preserves `method.parameters` info.
@tool.expects(:call).with(arg: "value", server_context: nil).returns(tool_response)
@tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response)
else
# Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false.
@tool.expects(:call).with(arg: "value").returns(tool_response)
@ -352,7 +352,7 @@ module MCP
if RUBY_VERSION >= "3.1"
# Ruby 3.1+: Mocha stub preserves `method.parameters` info.
@tool.expects(:call).with(arg: "value", server_context: nil).returns(tool_response)
@tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response)
else
# Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false.
@tool.expects(:call).with(arg: "value").returns(tool_response)