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
1 changed files with 731 additions and 16 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue