ruby-sdk/lib/mcp/client.rb
Koichi ITO bbe637612b 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.
2026-03-10 01:49:09 +09:00

165 lines
5.2 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
module MCP
class Client
# Initializes a new MCP::Client instance.
#
# @param transport [Object] The transport object to use for communication with the server.
# The transport should be a duck type that responds to `send_request`. See the README for more details.
#
# @example
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
# client = MCP::Client.new(transport: transport)
def initialize(transport:)
@transport = transport
end
# The user may want to access additional transport-specific methods/attributes
# So keeping it public
attr_reader :transport
# Returns the list of tools available from the server.
# Each call will make a new request – the result is not cached.
#
# @return [Array<MCP::Client::Tool>] An array of available tools.
#
# @example
# tools = client.tools
# tools.each do |tool|
# puts tool.name
# end
def tools
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "tools/list",
})
response.dig("result", "tools")&.map do |tool|
Tool.new(
name: tool["name"],
description: tool["description"],
input_schema: tool["inputSchema"],
)
end || []
end
# Returns the list of resources available from the server.
# Each call will make a new request – the result is not cached.
#
# @return [Array<Hash>] An array of available resources.
def resources
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "resources/list",
})
response.dig("result", "resources") || []
end
# Returns the list of resource templates available from the server.
# Each call will make a new request – the result is not cached.
#
# @return [Array<Hash>] An array of available resource templates.
def resource_templates
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "resources/templates/list",
})
response.dig("result", "resourceTemplates") || []
end
# Returns the list of prompts available from the server.
# Each call will make a new request – the result is not cached.
#
# @return [Array<Hash>] An array of available prompts.
def prompts
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "prompts/list",
})
response.dig("result", "prompts") || []
end
# Calls a tool via the transport layer and returns the full response from the server.
#
# @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
# tool = client.tools.first
# response = client.call_tool(tool: tool, arguments: { foo: "bar" })
# structured_content = response.dig("result", "structuredContent")
#
# @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, 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: params,
})
end
# Reads a resource from the server by URI and returns the contents.
#
# @param uri [String] The URI of the resource to read.
# @return [Array<Hash>] An array of resource contents (text or blob).
def read_resource(uri:)
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "resources/read",
params: { uri: uri },
})
response.dig("result", "contents") || []
end
# Gets a prompt from the server by name and returns its details.
#
# @param name [String] The name of the prompt to get.
# @return [Hash] A hash containing the prompt details.
def get_prompt(name:)
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "prompts/get",
params: { name: name },
})
response.fetch("result", {})
end
private
def request_id
SecureRandom.uuid
end
class RequestHandlerError < StandardError
attr_reader :error_type, :original_error, :request
def initialize(message, request, error_type: :internal_error, original_error: nil)
super(message)
@request = request
@error_type = error_type
@original_error = original_error
end
end
end
end