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
13 changed files with 731 additions and 16 deletions
76
README.md
76
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
64
test/mcp/progress_test.rb
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
365
test/mcp/server_progress_test.rb
Normal file
365
test/mcp/server_progress_test.rb
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue