From 852ac8b30c3e48a832929e9ace2c538e51d7ea6a Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Sun, 15 Feb 2026 00:01:37 +0100 Subject: [PATCH 1/3] tasks: implement for MCP 2025-11-25 --- lib/mcp_client.rb | 2 +- lib/mcp_client/client.rb | 70 +++++++ lib/mcp_client/errors.rb | 6 + lib/mcp_client/task.rb | 127 ++++++++++++ spec/lib/mcp_client/client_spec.rb | 219 +++++++++++++++++++++ spec/lib/mcp_client/task_spec.rb | 305 +++++++++++++++++++++++++++++ 6 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 lib/mcp_client/task.rb create mode 100644 spec/lib/mcp_client/task_spec.rb diff --git a/lib/mcp_client.rb b/lib/mcp_client.rb index b248daf..c4d4898 100644 --- a/lib/mcp_client.rb +++ b/lib/mcp_client.rb @@ -9,7 +9,7 @@ require_relative 'mcp_client/resource_content' require_relative 'mcp_client/audio_content' require_relative 'mcp_client/root' -require_relative 'mcp_client/elicitation_validator' +require_relative 'mcp_client/task' require_relative 'mcp_client/server_base' require_relative 'mcp_client/server_stdio' require_relative 'mcp_client/server_sse' diff --git a/lib/mcp_client/client.rb b/lib/mcp_client/client.rb index 5407f54..ad17e59 100644 --- a/lib/mcp_client/client.rb +++ b/lib/mcp_client/client.rb @@ -497,6 +497,76 @@ def complete(ref:, argument:, context: nil, server: nil) srv.complete(ref: ref, argument: argument, context: context) end + # Create a new task on a server (MCP 2025-11-25) + # Tasks represent long-running operations that can report progress + # @param method [String] the method to execute as a task + # @param params [Hash] parameters for the task method + # @param progress_token [String, nil] optional token for receiving progress notifications + # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector + # @return [MCPClient::Task] the created task + # @raise [MCPClient::Errors::ServerNotFound] if no server is available + # @raise [MCPClient::Errors::TaskError] if task creation fails + def create_task(method, params: {}, progress_token: nil, server: nil) + srv = select_server(server) + rpc_params = { method: method, params: params } + rpc_params[:progressToken] = progress_token if progress_token + + begin + result = srv.rpc_request('tasks/create', rpc_params) + MCPClient::Task.from_json(result, server: srv) + rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError => e + raise MCPClient::Errors::TaskError, "Error creating task: #{e.message}" + end + end + + # Get the current state of a task (MCP 2025-11-25) + # @param task_id [String] the ID of the task to query + # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector + # @return [MCPClient::Task] the task with current state + # @raise [MCPClient::Errors::ServerNotFound] if no server is available + # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist + # @raise [MCPClient::Errors::TaskError] if retrieving the task fails + def get_task(task_id, server: nil) + srv = select_server(server) + + begin + result = srv.rpc_request('tasks/get', { id: task_id }) + MCPClient::Task.from_json(result, server: srv) + rescue MCPClient::Errors::ServerError => e + if e.message.include?('not found') || e.message.include?('unknown task') + raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found" + end + + raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}" + rescue MCPClient::Errors::TransportError => e + raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}" + end + end + + # Cancel a running task (MCP 2025-11-25) + # @param task_id [String] the ID of the task to cancel + # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector + # @return [MCPClient::Task] the task with updated (cancelled) state + # @raise [MCPClient::Errors::ServerNotFound] if no server is available + # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist + # @raise [MCPClient::Errors::TaskError] if cancellation fails + def cancel_task(task_id, server: nil) + srv = select_server(server) + + begin + result = srv.rpc_request('tasks/cancel', { id: task_id }) + MCPClient::Task.from_json(result, server: srv) + rescue MCPClient::Errors::ServerError => e + if e.message.include?('not found') || e.message.include?('unknown task') + raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found" + end + + raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}" + rescue MCPClient::Errors::TransportError => e + raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}" + end + end + # Set the logging level on all connected servers (MCP 2025-06-18) # To set on a specific server, use: client.find_server('name').log_level = 'debug' # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error', diff --git a/lib/mcp_client/errors.rb b/lib/mcp_client/errors.rb index 1e06eba..c1bb843 100644 --- a/lib/mcp_client/errors.rb +++ b/lib/mcp_client/errors.rb @@ -50,5 +50,11 @@ class AmbiguousResourceURI < MCPError; end # Raised when transport type cannot be determined from target URL/command class TransportDetectionError < MCPError; end + + # Raised when a task is not found + class TaskNotFound < MCPError; end + + # Raised when there's an error creating or managing a task + class TaskError < MCPError; end end end diff --git a/lib/mcp_client/task.rb b/lib/mcp_client/task.rb new file mode 100644 index 0000000..3681415 --- /dev/null +++ b/lib/mcp_client/task.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module MCPClient + # Represents an MCP Task for long-running operations with progress tracking + # Tasks follow the MCP 2025-11-25 specification for structured task management + # + # Task states: pending, running, completed, failed, cancelled + class Task + # Valid task states + VALID_STATES = %w[pending running completed failed cancelled].freeze + + attr_reader :id, :state, :progress_token, :progress, :total, :message, :result, :server + + # Create a new Task + # @param id [String] unique task identifier + # @param state [String] task state (pending, running, completed, failed, cancelled) + # @param progress_token [String, nil] optional token for tracking progress + # @param progress [Integer, nil] current progress value + # @param total [Integer, nil] total progress value + # @param message [String, nil] human-readable status message + # @param result [Object, nil] task result (when completed) + # @param server [MCPClient::ServerBase, nil] the server this task belongs to + def initialize(id:, state: 'pending', progress_token: nil, progress: nil, total: nil, + message: nil, result: nil, server: nil) + validate_state!(state) + @id = id + @state = state + @progress_token = progress_token + @progress = progress + @total = total + @message = message + @result = result + @server = server + end + + # Create a Task from a JSON hash + # @param json [Hash] the JSON hash with task fields + # @param server [MCPClient::ServerBase, nil] optional server reference + # @return [Task] + def self.from_json(json, server: nil) + new( + id: json['id'] || json[:id], + state: json['state'] || json[:state] || 'pending', + progress_token: json['progressToken'] || json[:progressToken] || json[:progress_token], + progress: json['progress'] || json[:progress], + total: json['total'] || json[:total], + message: json['message'] || json[:message], + result: json['result'] || json[:result], + server: server + ) + end + + # Convert to JSON-serializable hash + # @return [Hash] + def to_h + result = { 'id' => @id, 'state' => @state } + result['progressToken'] = @progress_token if @progress_token + result['progress'] = @progress if @progress + result['total'] = @total if @total + result['message'] = @message if @message + result['result'] = @result if @result + result + end + + # Convert to JSON string + # @return [String] + def to_json(*) + to_h.to_json(*) + end + + # Check if task is in a terminal state + # @return [Boolean] + def terminal? + %w[completed failed cancelled].include?(@state) + end + + # Check if task is still active (pending or running) + # @return [Boolean] + def active? + %w[pending running].include?(@state) + end + + # Calculate progress percentage + # @return [Float, nil] percentage (0.0-100.0) or nil if progress info unavailable + def progress_percentage + return nil unless @progress && @total && @total.positive? + + (@progress.to_f / @total * 100).round(2) + end + + # Check equality + def ==(other) + return false unless other.is_a?(Task) + + id == other.id && state == other.state + end + + alias eql? == + + def hash + [id, state].hash + end + + # String representation + def to_s + parts = ["Task[#{@id}]: #{@state}"] + parts << "(#{@progress}/#{@total})" if @progress && @total + parts << "- #{@message}" if @message + parts.join(' ') + end + + def inspect + "#" + end + + private + + # Validate task state + # @param state [String] the state to validate + # @raise [ArgumentError] if the state is not valid + def validate_state!(state) + return if VALID_STATES.include?(state) + + raise ArgumentError, "Invalid task state: #{state.inspect}. Must be one of: #{VALID_STATES.join(', ')}" + end + end +end diff --git a/spec/lib/mcp_client/client_spec.rb b/spec/lib/mcp_client/client_spec.rb index 9c1b1b6..8274437 100644 --- a/spec/lib/mcp_client/client_spec.rb +++ b/spec/lib/mcp_client/client_spec.rb @@ -912,6 +912,225 @@ end end + describe '#create_task' do + let(:client) { described_class.new(mcp_server_configs: [{ type: 'stdio', command: 'test' }]) } + let(:task_result) do + { 'id' => 'task-123', 'state' => 'pending', 'progressToken' => 'pt-abc' } + end + + before do + allow(mock_server).to receive(:rpc_request) + .with('tasks/create', { method: 'longRunningOp', params: { input: 'data' }, progressToken: 'pt-abc' }) + .and_return(task_result) + end + + it 'creates a task and returns a Task object' do + task = client.create_task('longRunningOp', params: { input: 'data' }, progress_token: 'pt-abc') + expect(task).to be_a(MCPClient::Task) + expect(task.id).to eq('task-123') + expect(task.state).to eq('pending') + expect(task.progress_token).to eq('pt-abc') + expect(task.server).to eq(mock_server) + end + + it 'creates a task without progress token' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/create', { method: 'simpleOp', params: {} }) + .and_return({ 'id' => 'task-456', 'state' => 'pending' }) + + task = client.create_task('simpleOp') + expect(task.id).to eq('task-456') + expect(task.progress_token).to be_nil + end + + it 'raises TaskError on server error' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/create', { method: 'failOp', params: {} }) + .and_raise(MCPClient::Errors::ServerError.new('Internal error')) + + expect do + client.create_task('failOp') + end.to raise_error(MCPClient::Errors::TaskError, /Error creating task/) + end + + it 'raises TaskError on transport error' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/create', { method: 'failOp', params: {} }) + .and_raise(MCPClient::Errors::TransportError.new('Connection lost')) + + expect do + client.create_task('failOp') + end.to raise_error(MCPClient::Errors::TaskError, /Error creating task/) + end + + context 'with server selection' do + let(:mock_server2) { instance_double(MCPClient::ServerBase, name: 'server2') } + let(:multi_client) do + client = described_class.new(mcp_server_configs: [ + { type: 'stdio', command: 'test1' }, + { type: 'stdio', command: 'test2' } + ]) + client.instance_variable_set(:@servers, [mock_server, mock_server2]) + client + end + + before do + allow(mock_server2).to receive(:on_notification) + allow(mock_server2).to receive(:rpc_request) + .with('tasks/create', { method: 'op', params: {} }) + .and_return({ 'id' => 'task-s2', 'state' => 'pending' }) + end + + it 'creates a task on a specific server by name' do + task = multi_client.create_task('op', server: 'server2') + expect(task.id).to eq('task-s2') + expect(task.server).to eq(mock_server2) + end + end + end + + describe '#get_task' do + let(:client) { described_class.new(mcp_server_configs: [{ type: 'stdio', command: 'test' }]) } + let(:task_result) do + { 'id' => 'task-123', 'state' => 'running', 'progress' => 50, 'total' => 100, 'message' => 'Halfway' } + end + + before do + allow(mock_server).to receive(:rpc_request) + .with('tasks/get', { id: 'task-123' }) + .and_return(task_result) + end + + it 'gets a task and returns a Task object' do + task = client.get_task('task-123') + expect(task).to be_a(MCPClient::Task) + expect(task.id).to eq('task-123') + expect(task.state).to eq('running') + expect(task.progress).to eq(50) + expect(task.total).to eq(100) + expect(task.message).to eq('Halfway') + expect(task.server).to eq(mock_server) + end + + it 'raises TaskNotFound when task does not exist' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/get', { id: 'nonexistent' }) + .and_raise(MCPClient::Errors::ServerError.new('Task not found')) + + expect do + client.get_task('nonexistent') + end.to raise_error(MCPClient::Errors::TaskNotFound, "Task 'nonexistent' not found") + end + + it 'raises TaskNotFound for unknown task error' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/get', { id: 'bad-id' }) + .and_raise(MCPClient::Errors::ServerError.new('unknown task')) + + expect do + client.get_task('bad-id') + end.to raise_error(MCPClient::Errors::TaskNotFound) + end + + it 'raises TaskError on other server errors' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/get', { id: 'task-err' }) + .and_raise(MCPClient::Errors::ServerError.new('Internal error')) + + expect do + client.get_task('task-err') + end.to raise_error(MCPClient::Errors::TaskError, /Error getting task/) + end + + it 'raises TaskError on transport error' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/get', { id: 'task-err' }) + .and_raise(MCPClient::Errors::TransportError.new('Connection lost')) + + expect do + client.get_task('task-err') + end.to raise_error(MCPClient::Errors::TaskError, /Error getting task/) + end + end + + describe '#cancel_task' do + let(:client) { described_class.new(mcp_server_configs: [{ type: 'stdio', command: 'test' }]) } + let(:cancel_result) do + { 'id' => 'task-123', 'state' => 'cancelled', 'message' => 'Cancelled by user' } + end + + before do + allow(mock_server).to receive(:rpc_request) + .with('tasks/cancel', { id: 'task-123' }) + .and_return(cancel_result) + end + + it 'cancels a task and returns updated Task object' do + task = client.cancel_task('task-123') + expect(task).to be_a(MCPClient::Task) + expect(task.id).to eq('task-123') + expect(task.state).to eq('cancelled') + expect(task.message).to eq('Cancelled by user') + expect(task.server).to eq(mock_server) + end + + it 'raises TaskNotFound when task does not exist' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/cancel', { id: 'nonexistent' }) + .and_raise(MCPClient::Errors::ServerError.new('Task not found')) + + expect do + client.cancel_task('nonexistent') + end.to raise_error(MCPClient::Errors::TaskNotFound, "Task 'nonexistent' not found") + end + + it 'raises TaskError on other server errors' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/cancel', { id: 'task-err' }) + .and_raise(MCPClient::Errors::ServerError.new('Cannot cancel completed task')) + + expect do + client.cancel_task('task-err') + end.to raise_error(MCPClient::Errors::TaskError, /Error cancelling task/) + end + + it 'raises TaskError on transport error' do + allow(mock_server).to receive(:rpc_request) + .with('tasks/cancel', { id: 'task-err' }) + .and_raise(MCPClient::Errors::TransportError.new('Connection lost')) + + expect do + client.cancel_task('task-err') + end.to raise_error(MCPClient::Errors::TaskError, /Error cancelling task/) + end + + context 'with server selection' do + let(:mock_server2) { instance_double(MCPClient::ServerBase, name: 'server2') } + let(:multi_client) do + client = described_class.new(mcp_server_configs: [ + { type: 'stdio', command: 'test1' }, + { type: 'stdio', command: 'test2' } + ]) + client.instance_variable_set(:@servers, [mock_server, mock_server2]) + client + end + + before do + allow(mock_server2).to receive(:on_notification) + allow(mock_server2).to receive(:rpc_request) + .with('tasks/cancel', { id: 'task-s2' }) + .and_return({ 'id' => 'task-s2', 'state' => 'cancelled' }) + end + + it 'cancels a task on a specific server by name' do + task = multi_client.cancel_task('task-s2', server: 'server2') + expect(task.id).to eq('task-s2') + expect(task.state).to eq('cancelled') + expect(task.server).to eq(mock_server2) + end + end + end + describe 'notification handling' do let(:client) { described_class.new(mcp_server_configs: [{ type: 'stdio', command: 'test' }]) } let(:notification_callback) { double('callback') } diff --git a/spec/lib/mcp_client/task_spec.rb b/spec/lib/mcp_client/task_spec.rb new file mode 100644 index 0000000..7d15fda --- /dev/null +++ b/spec/lib/mcp_client/task_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MCPClient::Task do + describe '#initialize' do + it 'creates a task with required attributes' do + task = described_class.new(id: 'task-123') + expect(task.id).to eq('task-123') + expect(task.state).to eq('pending') + end + + it 'creates a task with all attributes' do + task = described_class.new( + id: 'task-123', + state: 'running', + progress_token: 'pt-456', + progress: 50, + total: 100, + message: 'Processing...', + result: { 'data' => 'value' } + ) + expect(task.id).to eq('task-123') + expect(task.state).to eq('running') + expect(task.progress_token).to eq('pt-456') + expect(task.progress).to eq(50) + expect(task.total).to eq(100) + expect(task.message).to eq('Processing...') + expect(task.result).to eq({ 'data' => 'value' }) + end + + it 'raises ArgumentError for invalid state' do + expect do + described_class.new(id: 'task-123', state: 'invalid') + end.to raise_error(ArgumentError, /Invalid task state/) + end + + it 'accepts all valid states' do + %w[pending running completed failed cancelled].each do |state| + task = described_class.new(id: 'task-123', state: state) + expect(task.state).to eq(state) + end + end + + it 'stores server reference' do + server = instance_double(MCPClient::ServerBase) + task = described_class.new(id: 'task-123', server: server) + expect(task.server).to eq(server) + end + end + + describe '.from_json' do + it 'parses JSON with string keys' do + json = { + 'id' => 'task-abc', + 'state' => 'running', + 'progressToken' => 'pt-xyz', + 'progress' => 25, + 'total' => 100, + 'message' => 'In progress', + 'result' => { 'output' => 'data' } + } + task = described_class.from_json(json) + + expect(task.id).to eq('task-abc') + expect(task.state).to eq('running') + expect(task.progress_token).to eq('pt-xyz') + expect(task.progress).to eq(25) + expect(task.total).to eq(100) + expect(task.message).to eq('In progress') + expect(task.result).to eq({ 'output' => 'data' }) + end + + it 'parses JSON with symbol keys' do + json = { + id: 'task-abc', + state: 'completed', + progressToken: 'pt-xyz', + progress: 100, + total: 100, + message: 'Done', + result: { 'output' => 'data' } + } + task = described_class.from_json(json) + + expect(task.id).to eq('task-abc') + expect(task.state).to eq('completed') + expect(task.progress_token).to eq('pt-xyz') + end + + it 'handles missing optional fields' do + json = { 'id' => 'task-abc' } + task = described_class.from_json(json) + + expect(task.id).to eq('task-abc') + expect(task.state).to eq('pending') + expect(task.progress_token).to be_nil + expect(task.progress).to be_nil + expect(task.total).to be_nil + expect(task.message).to be_nil + expect(task.result).to be_nil + end + + it 'accepts a server parameter' do + server = instance_double(MCPClient::ServerBase) + json = { 'id' => 'task-abc', 'state' => 'pending' } + task = described_class.from_json(json, server: server) + + expect(task.server).to eq(server) + end + + it 'handles snake_case progress_token key' do + json = { id: 'task-abc', progress_token: 'pt-xyz' } + task = described_class.from_json(json) + + expect(task.progress_token).to eq('pt-xyz') + end + end + + describe '#to_h' do + it 'returns hash with required fields only' do + task = described_class.new(id: 'task-123', state: 'pending') + expect(task.to_h).to eq({ 'id' => 'task-123', 'state' => 'pending' }) + end + + it 'includes optional fields when present' do + task = described_class.new( + id: 'task-123', + state: 'running', + progress_token: 'pt-456', + progress: 50, + total: 100, + message: 'Working...', + result: { 'data' => 'value' } + ) + hash = task.to_h + + expect(hash['id']).to eq('task-123') + expect(hash['state']).to eq('running') + expect(hash['progressToken']).to eq('pt-456') + expect(hash['progress']).to eq(50) + expect(hash['total']).to eq(100) + expect(hash['message']).to eq('Working...') + expect(hash['result']).to eq({ 'data' => 'value' }) + end + + it 'excludes nil optional fields' do + task = described_class.new(id: 'task-123') + hash = task.to_h + + expect(hash).not_to have_key('progressToken') + expect(hash).not_to have_key('progress') + expect(hash).not_to have_key('total') + expect(hash).not_to have_key('message') + expect(hash).not_to have_key('result') + end + end + + describe '#to_json' do + it 'serializes to JSON string' do + task = described_class.new(id: 'task-123', state: 'running', message: 'Working') + json = task.to_json + parsed = JSON.parse(json) + + expect(parsed['id']).to eq('task-123') + expect(parsed['state']).to eq('running') + expect(parsed['message']).to eq('Working') + end + end + + describe '#terminal?' do + it 'returns true for completed state' do + expect(described_class.new(id: 't', state: 'completed').terminal?).to be true + end + + it 'returns true for failed state' do + expect(described_class.new(id: 't', state: 'failed').terminal?).to be true + end + + it 'returns true for cancelled state' do + expect(described_class.new(id: 't', state: 'cancelled').terminal?).to be true + end + + it 'returns false for pending state' do + expect(described_class.new(id: 't', state: 'pending').terminal?).to be false + end + + it 'returns false for running state' do + expect(described_class.new(id: 't', state: 'running').terminal?).to be false + end + end + + describe '#active?' do + it 'returns true for pending state' do + expect(described_class.new(id: 't', state: 'pending').active?).to be true + end + + it 'returns true for running state' do + expect(described_class.new(id: 't', state: 'running').active?).to be true + end + + it 'returns false for completed state' do + expect(described_class.new(id: 't', state: 'completed').active?).to be false + end + + it 'returns false for failed state' do + expect(described_class.new(id: 't', state: 'failed').active?).to be false + end + + it 'returns false for cancelled state' do + expect(described_class.new(id: 't', state: 'cancelled').active?).to be false + end + end + + describe '#progress_percentage' do + it 'calculates percentage when progress and total are set' do + task = described_class.new(id: 't', state: 'running', progress: 25, total: 200) + expect(task.progress_percentage).to eq(12.5) + end + + it 'returns 100.0 when progress equals total' do + task = described_class.new(id: 't', state: 'completed', progress: 100, total: 100) + expect(task.progress_percentage).to eq(100.0) + end + + it 'returns nil when progress is nil' do + task = described_class.new(id: 't', state: 'running', total: 100) + expect(task.progress_percentage).to be_nil + end + + it 'returns nil when total is nil' do + task = described_class.new(id: 't', state: 'running', progress: 50) + expect(task.progress_percentage).to be_nil + end + + it 'returns nil when total is zero' do + task = described_class.new(id: 't', state: 'running', progress: 0, total: 0) + expect(task.progress_percentage).to be_nil + end + end + + describe 'equality' do + it 'considers tasks with same id and state as equal' do + task1 = described_class.new(id: 'task-123', state: 'running') + task2 = described_class.new(id: 'task-123', state: 'running') + + expect(task1).to eq(task2) + expect(task1.eql?(task2)).to be true + expect(task1.hash).to eq(task2.hash) + end + + it 'considers tasks with different id as not equal' do + task1 = described_class.new(id: 'task-123', state: 'running') + task2 = described_class.new(id: 'task-456', state: 'running') + + expect(task1).not_to eq(task2) + end + + it 'considers tasks with different state as not equal' do + task1 = described_class.new(id: 'task-123', state: 'running') + task2 = described_class.new(id: 'task-123', state: 'completed') + + expect(task1).not_to eq(task2) + end + end + + describe '#to_s' do + it 'returns basic string for simple task' do + task = described_class.new(id: 'task-123', state: 'pending') + expect(task.to_s).to eq('Task[task-123]: pending') + end + + it 'includes progress when available' do + task = described_class.new(id: 'task-123', state: 'running', progress: 50, total: 100) + expect(task.to_s).to eq('Task[task-123]: running (50/100)') + end + + it 'includes message when available' do + task = described_class.new(id: 'task-123', state: 'running', message: 'Processing files') + expect(task.to_s).to eq('Task[task-123]: running - Processing files') + end + + it 'includes both progress and message' do + task = described_class.new(id: 'task-123', state: 'running', progress: 3, total: 10, message: 'Step 3') + expect(task.to_s).to eq('Task[task-123]: running (3/10) - Step 3') + end + end + + describe '#inspect' do + it 'returns a readable representation' do + task = described_class.new(id: 'task-123', state: 'running') + expect(task.inspect).to eq('#') + end + end + + describe 'VALID_STATES' do + it 'contains all expected states' do + expect(described_class::VALID_STATES).to eq(%w[pending running completed failed cancelled]) + end + + it 'is frozen' do + expect(described_class::VALID_STATES).to be_frozen + end + end +end From 2b4117660acfd5228d662b0790747eccdb9b3df8 Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Sun, 15 Feb 2026 00:10:31 +0100 Subject: [PATCH 2/3] style: fix rubocop SafeNavigation offense in task.rb --- lib/mcp_client/task.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp_client/task.rb b/lib/mcp_client/task.rb index 3681415..dd60e7e 100644 --- a/lib/mcp_client/task.rb +++ b/lib/mcp_client/task.rb @@ -83,7 +83,7 @@ def active? # Calculate progress percentage # @return [Float, nil] percentage (0.0-100.0) or nil if progress info unavailable def progress_percentage - return nil unless @progress && @total && @total.positive? + return nil unless @progress && @total&.positive? (@progress.to_f / @total * 100).round(2) end From 9ed6c36c5a53ed8ad59036c6bb5450990e808b99 Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Sun, 15 Feb 2026 00:25:57 +0100 Subject: [PATCH 3/3] fix: preserve false task results in from_json and to_h --- lib/mcp_client.rb | 1 + lib/mcp_client/task.rb | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/mcp_client.rb b/lib/mcp_client.rb index c4d4898..73301d7 100644 --- a/lib/mcp_client.rb +++ b/lib/mcp_client.rb @@ -9,6 +9,7 @@ require_relative 'mcp_client/resource_content' require_relative 'mcp_client/audio_content' require_relative 'mcp_client/root' +require_relative 'mcp_client/elicitation_validator' require_relative 'mcp_client/task' require_relative 'mcp_client/server_base' require_relative 'mcp_client/server_stdio' diff --git a/lib/mcp_client/task.rb b/lib/mcp_client/task.rb index dd60e7e..6537da8 100644 --- a/lib/mcp_client/task.rb +++ b/lib/mcp_client/task.rb @@ -45,7 +45,7 @@ def self.from_json(json, server: nil) progress: json['progress'] || json[:progress], total: json['total'] || json[:total], message: json['message'] || json[:message], - result: json['result'] || json[:result], + result: json.key?('result') ? json['result'] : json[:result], server: server ) end @@ -58,7 +58,7 @@ def to_h result['progress'] = @progress if @progress result['total'] = @total if @total result['message'] = @message if @message - result['result'] = @result if @result + result['result'] = @result unless @result.nil? result end