ruby-mcp-client 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCPClient
4
- # Represents an MCP Task for long-running operations with progress tracking
5
- # Tasks follow the MCP 2025-11-25 specification for structured task management
4
+ # Represents an MCP Task for long-running, task-augmented operations.
5
+ # Conforms to the MCP 2025-11-25 Tasks utility.
6
6
  #
7
- # Task states: pending, running, completed, failed, cancelled
7
+ # Task statuses: working, input_required, completed, failed, cancelled.
8
+ # A task begins in `working`; completed/failed/cancelled are terminal.
8
9
  class Task
9
- # Valid task states
10
- VALID_STATES = %w[pending running completed failed cancelled].freeze
10
+ # Valid task statuses (MCP 2025-11-25)
11
+ VALID_STATUSES = %w[working input_required completed failed cancelled].freeze
11
12
 
12
- attr_reader :id, :state, :progress_token, :progress, :total, :message, :result, :server
13
+ # Statuses from which a task will not transition further
14
+ TERMINAL_STATUSES = %w[completed failed cancelled].freeze
15
+
16
+ attr_reader :task_id, :status, :status_message, :created_at, :last_updated_at, :ttl, :poll_interval, :server
13
17
 
14
18
  # Create a new Task
15
- # @param id [String] unique task identifier
16
- # @param state [String] task state (pending, running, completed, failed, cancelled)
17
- # @param progress_token [String, nil] optional token for tracking progress
18
- # @param progress [Integer, nil] current progress value
19
- # @param total [Integer, nil] total progress value
20
- # @param message [String, nil] human-readable status message
21
- # @param result [Object, nil] task result (when completed)
19
+ # @param task_id [String] unique task identifier
20
+ # @param status [String] task status (working, input_required, completed, failed, cancelled)
21
+ # @param status_message [String, nil] optional human-readable status detail
22
+ # @param created_at [String, nil] ISO 8601 creation timestamp
23
+ # @param last_updated_at [String, nil] ISO 8601 last-update timestamp
24
+ # @param ttl [Integer, nil] retention duration in milliseconds since creation (nil = unspecified)
25
+ # @param poll_interval [Integer, nil] suggested polling interval in milliseconds
22
26
  # @param server [MCPClient::ServerBase, nil] the server this task belongs to
23
- def initialize(id:, state: 'pending', progress_token: nil, progress: nil, total: nil,
24
- message: nil, result: nil, server: nil)
25
- validate_state!(state)
26
- @id = id
27
- @state = state
28
- @progress_token = progress_token
29
- @progress = progress
30
- @total = total
31
- @message = message
32
- @result = result
27
+ def initialize(task_id:, status: 'working', status_message: nil, created_at: nil,
28
+ last_updated_at: nil, ttl: nil, poll_interval: nil, server: nil)
29
+ validate_status!(status)
30
+ @task_id = task_id
31
+ @status = status
32
+ @status_message = status_message
33
+ @created_at = created_at
34
+ @last_updated_at = last_updated_at
35
+ @ttl = ttl
36
+ @poll_interval = poll_interval
33
37
  @server = server
34
38
  end
35
39
 
36
- # Create a Task from a JSON hash
37
- # @param json [Hash] the JSON hash with task fields
40
+ # Build a Task from a flat Task hash. This is the shape of GetTaskResult,
41
+ # CancelTaskResult, the items in a ListTasksResult, and the params of a
42
+ # notifications/tasks/status notification.
43
+ # @param json [Hash] the flat task hash
38
44
  # @param server [MCPClient::ServerBase, nil] optional server reference
39
45
  # @return [Task]
40
46
  def self.from_json(json, server: nil)
47
+ data = json || {}
41
48
  new(
42
- id: json['id'] || json[:id],
43
- state: json['state'] || json[:state] || 'pending',
44
- progress_token: json['progressToken'] || json[:progressToken] || json[:progress_token],
45
- progress: json['progress'] || json[:progress],
46
- total: json['total'] || json[:total],
47
- message: json['message'] || json[:message],
48
- result: json.key?('result') ? json['result'] : json[:result],
49
+ task_id: extract_field(data, 'taskId', :task_id),
50
+ status: extract_field(data, 'status') || 'working',
51
+ status_message: extract_field(data, 'statusMessage', :status_message),
52
+ created_at: extract_field(data, 'createdAt', :created_at),
53
+ last_updated_at: extract_field(data, 'lastUpdatedAt', :last_updated_at),
54
+ ttl: extract_field(data, 'ttl'),
55
+ poll_interval: extract_field(data, 'pollInterval', :poll_interval),
49
56
  server: server
50
57
  )
51
58
  end
52
59
 
53
- # Convert to JSON-serializable hash
60
+ # Build a Task from a CreateTaskResult, which wraps the task under `task`.
61
+ # @param result [Hash] the CreateTaskResult ({ 'task' => { ... } })
62
+ # @param server [MCPClient::ServerBase, nil] optional server reference
63
+ # @return [Task]
64
+ def self.from_create_result(result, server: nil)
65
+ task_data = (result && (result['task'] || result[:task])) || result
66
+ from_json(task_data, server: server)
67
+ end
68
+
69
+ # Read a value by camelCase string key, falling back to a snake_case symbol.
70
+ # Uses key? so an explicit null value is preserved (not treated as absent).
71
+ # @return [Object, nil]
72
+ def self.extract_field(data, str_key, sym_key = nil)
73
+ return data[str_key] if data.key?(str_key)
74
+ return data[str_key.to_sym] if data.key?(str_key.to_sym)
75
+ return data[sym_key] if sym_key && data.key?(sym_key)
76
+
77
+ nil
78
+ end
79
+ private_class_method :extract_field
80
+
81
+ # Convert to a spec-shaped, JSON-serializable hash
54
82
  # @return [Hash]
55
83
  def to_h
56
- result = { 'id' => @id, 'state' => @state }
57
- result['progressToken'] = @progress_token if @progress_token
58
- result['progress'] = @progress if @progress
59
- result['total'] = @total if @total
60
- result['message'] = @message if @message
61
- result['result'] = @result unless @result.nil?
62
- result
84
+ # ttl is a REQUIRED Task field whose value may be null, so it is always
85
+ # included (even when nil). The other optional fields are omitted when nil.
86
+ hash = { 'taskId' => @task_id, 'status' => @status, 'ttl' => @ttl }
87
+ hash['statusMessage'] = @status_message if @status_message
88
+ hash['createdAt'] = @created_at if @created_at
89
+ hash['lastUpdatedAt'] = @last_updated_at if @last_updated_at
90
+ hash['pollInterval'] = @poll_interval if @poll_interval
91
+ hash
63
92
  end
64
93
 
65
94
  # Convert to JSON string
@@ -68,60 +97,63 @@ module MCPClient
68
97
  to_h.to_json(*)
69
98
  end
70
99
 
71
- # Check if task is in a terminal state
100
+ # Whether the task is in a terminal status (completed, failed, cancelled)
72
101
  # @return [Boolean]
73
102
  def terminal?
74
- %w[completed failed cancelled].include?(@state)
103
+ TERMINAL_STATUSES.include?(@status)
75
104
  end
76
105
 
77
- # Check if task is still active (pending or running)
106
+ # Whether the task is still active (not terminal — working or input_required)
78
107
  # @return [Boolean]
79
108
  def active?
80
- %w[pending running].include?(@state)
109
+ !terminal?
81
110
  end
82
111
 
83
- # Calculate progress percentage
84
- # @return [Float, nil] percentage (0.0-100.0) or nil if progress info unavailable
85
- def progress_percentage
86
- return nil unless @progress && @total&.positive?
112
+ # Whether the task is waiting for input (status input_required)
113
+ # @return [Boolean]
114
+ def input_required?
115
+ @status == 'input_required'
116
+ end
87
117
 
88
- (@progress.to_f / @total * 100).round(2)
118
+ # Whether the task is still running (status working)
119
+ # @return [Boolean]
120
+ def working?
121
+ @status == 'working'
89
122
  end
90
123
 
91
124
  # Check equality
92
125
  def ==(other)
93
126
  return false unless other.is_a?(Task)
94
127
 
95
- id == other.id && state == other.state
128
+ task_id == other.task_id && status == other.status
96
129
  end
97
130
 
98
131
  alias eql? ==
99
132
 
100
133
  def hash
101
- [id, state].hash
134
+ [task_id, status].hash
102
135
  end
103
136
 
104
137
  # String representation
105
138
  def to_s
106
- parts = ["Task[#{@id}]: #{@state}"]
107
- parts << "(#{@progress}/#{@total})" if @progress && @total
108
- parts << "- #{@message}" if @message
139
+ parts = ["Task[#{@task_id}]: #{@status}"]
140
+ parts << "- #{@status_message}" if @status_message
109
141
  parts.join(' ')
110
142
  end
111
143
 
112
144
  def inspect
113
- "#<MCPClient::Task id=#{@id.inspect} state=#{@state.inspect}>"
145
+ "#<MCPClient::Task task_id=#{@task_id.inspect} status=#{@status.inspect}>"
114
146
  end
115
147
 
116
148
  private
117
149
 
118
- # Validate task state
119
- # @param state [String] the state to validate
120
- # @raise [ArgumentError] if the state is not valid
121
- def validate_state!(state)
122
- return if VALID_STATES.include?(state)
150
+ # Validate task status
151
+ # @param status [String] the status to validate
152
+ # @raise [ArgumentError] if the status is not valid
153
+ def validate_status!(status)
154
+ return if VALID_STATUSES.include?(status)
123
155
 
124
- raise ArgumentError, "Invalid task state: #{state.inspect}. Must be one of: #{VALID_STATES.join(', ')}"
156
+ raise ArgumentError, "Invalid task status: #{status.inspect}. Must be one of: #{VALID_STATUSES.join(', ')}"
125
157
  end
126
158
  end
127
159
  end
@@ -17,7 +17,10 @@ module MCPClient
17
17
  # @return [Hash, nil] optional annotations describing tool behavior (e.g., readOnly, destructive)
18
18
  # @!attribute [r] server
19
19
  # @return [MCPClient::ServerBase, nil] the server this tool belongs to
20
- attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server
20
+ # @!attribute [r] task_support
21
+ # @return [String, nil] tool-level task negotiation (MCP 2025-11-25):
22
+ # 'forbidden' (default), 'optional', or 'required'; nil when not advertised
23
+ attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server, :task_support
21
24
 
22
25
  # Initialize a new Tool
23
26
  # @param name [String] the name of the tool
@@ -27,7 +30,9 @@ module MCPClient
27
30
  # @param output_schema [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
28
31
  # @param annotations [Hash, nil] optional annotations describing tool behavior
29
32
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
30
- def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil)
33
+ # @param task_support [String, nil] execution.taskSupport value (MCP 2025-11-25)
34
+ def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil,
35
+ task_support: nil)
31
36
  @name = name
32
37
  @title = title
33
38
  @description = description
@@ -35,6 +40,7 @@ module MCPClient
35
40
  @output_schema = output_schema
36
41
  @annotations = annotations
37
42
  @server = server
43
+ @task_support = task_support
38
44
  end
39
45
 
40
46
  # Create a Tool instance from JSON data
@@ -48,6 +54,8 @@ module MCPClient
48
54
  output_schema = data['outputSchema'] || data[:outputSchema]
49
55
  annotations = data['annotations'] || data[:annotations]
50
56
  title = data['title'] || data[:title]
57
+ execution = data['execution'] || data[:execution]
58
+ task_support = execution && (execution['taskSupport'] || execution[:taskSupport])
51
59
  new(
52
60
  name: data['name'] || data[:name],
53
61
  description: data['description'] || data[:description],
@@ -55,7 +63,8 @@ module MCPClient
55
63
  title: title,
56
64
  output_schema: output_schema,
57
65
  annotations: annotations,
58
- server: server
66
+ server: server,
67
+ task_support: task_support
59
68
  )
60
69
  end
61
70
 
@@ -114,21 +123,25 @@ module MCPClient
114
123
 
115
124
  # Check the readOnlyHint annotation (MCP 2025-11-25)
116
125
  # When true, the tool does not modify its environment.
117
- # @return [Boolean] defaults to true when not specified
126
+ # Per the MCP ToolAnnotations schema the default is false, i.e. a tool
127
+ # without this hint is assumed to potentially modify its environment.
128
+ # @return [Boolean] defaults to false when not specified
118
129
  def read_only_hint?
119
- return true unless @annotations
130
+ return false unless @annotations
120
131
 
121
- fetch_annotation_hint('readOnlyHint', :readOnlyHint, true)
132
+ fetch_annotation_hint('readOnlyHint', :readOnlyHint, false)
122
133
  end
123
134
 
124
135
  # Check the destructiveHint annotation (MCP 2025-11-25)
125
136
  # When true, the tool may perform destructive updates.
126
- # Only meaningful when readOnlyHint is false.
127
- # @return [Boolean] defaults to false when not specified
137
+ # Only meaningful when readOnlyHint is false. Per the MCP ToolAnnotations
138
+ # schema the default is true, i.e. a non-read-only tool without this hint
139
+ # is assumed to be potentially destructive.
140
+ # @return [Boolean] defaults to true when not specified
128
141
  def destructive_hint?
129
- return false unless @annotations
142
+ return true unless @annotations
130
143
 
131
- fetch_annotation_hint('destructiveHint', :destructiveHint, false)
144
+ fetch_annotation_hint('destructiveHint', :destructiveHint, true)
132
145
  end
133
146
 
134
147
  # Check the idempotentHint annotation (MCP 2025-11-25)
@@ -156,6 +169,31 @@ module MCPClient
156
169
  !@output_schema.nil? && !@output_schema.empty?
157
170
  end
158
171
 
172
+ # Whether task-augmented execution is allowed for this tool (MCP 2025-11-25).
173
+ # True when execution.taskSupport is 'optional' or 'required'.
174
+ # @return [Boolean]
175
+ def supports_task?
176
+ %w[optional required].include?(@task_support)
177
+ end
178
+
179
+ # Whether task-augmented execution is required for this tool
180
+ # @return [Boolean]
181
+ def task_required?
182
+ @task_support == 'required'
183
+ end
184
+
185
+ # Whether task-augmented execution is optional for this tool
186
+ # @return [Boolean]
187
+ def task_optional?
188
+ @task_support == 'optional'
189
+ end
190
+
191
+ # Whether task-augmented execution is forbidden (the default when unset)
192
+ # @return [Boolean]
193
+ def task_forbidden?
194
+ @task_support.nil? || @task_support == 'forbidden'
195
+ end
196
+
159
197
  private
160
198
 
161
199
  # Fetch a boolean annotation hint, checking both string and symbol keys.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '1.0.1'
5
+ VERSION = '1.1.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
8
  PROTOCOL_VERSION = '2025-11-25'
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mcp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Szymon Kurcab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-22 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: faraday
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +72,14 @@ dependencies:
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '7.0'
75
+ version: '8.0'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '7.0'
82
+ version: '8.0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: rspec
71
85
  requirement: !ruby/object:Gem::Requirement