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.
- checksums.yaml +4 -4
- data/README.md +113 -4
- data/lib/mcp_client/auth/oauth_provider.rb +333 -40
- data/lib/mcp_client/auth.rb +11 -4
- data/lib/mcp_client/client.rb +197 -34
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +5 -1
- data/lib/mcp_client/json_rpc_common.rb +19 -4
- data/lib/mcp_client/server_base.rb +73 -0
- data/lib/mcp_client/server_http.rb +27 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +4 -1
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +4 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +5 -2
- data/lib/mcp_client/server_sse.rb +49 -36
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +13 -3
- data/lib/mcp_client/server_stdio.rb +124 -20
- data/lib/mcp_client/server_streamable_http.rb +11 -30
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +48 -10
- data/lib/mcp_client/version.rb +1 -1
- metadata +18 -4
data/lib/mcp_client/task.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
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
|
|
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
|
|
10
|
-
|
|
10
|
+
# Valid task statuses (MCP 2025-11-25)
|
|
11
|
+
VALID_STATUSES = %w[working input_required completed failed cancelled].freeze
|
|
11
12
|
|
|
12
|
-
|
|
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
|
|
16
|
-
# @param
|
|
17
|
-
# @param
|
|
18
|
-
# @param
|
|
19
|
-
# @param
|
|
20
|
-
# @param
|
|
21
|
-
# @param
|
|
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(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
@
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
#
|
|
100
|
+
# Whether the task is in a terminal status (completed, failed, cancelled)
|
|
72
101
|
# @return [Boolean]
|
|
73
102
|
def terminal?
|
|
74
|
-
|
|
103
|
+
TERMINAL_STATUSES.include?(@status)
|
|
75
104
|
end
|
|
76
105
|
|
|
77
|
-
#
|
|
106
|
+
# Whether the task is still active (not terminal — working or input_required)
|
|
78
107
|
# @return [Boolean]
|
|
79
108
|
def active?
|
|
80
|
-
|
|
109
|
+
!terminal?
|
|
81
110
|
end
|
|
82
111
|
|
|
83
|
-
#
|
|
84
|
-
# @return [
|
|
85
|
-
def
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
134
|
+
[task_id, status].hash
|
|
102
135
|
end
|
|
103
136
|
|
|
104
137
|
# String representation
|
|
105
138
|
def to_s
|
|
106
|
-
parts = ["Task[#{@
|
|
107
|
-
parts << "
|
|
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
|
|
145
|
+
"#<MCPClient::Task task_id=#{@task_id.inspect} status=#{@status.inspect}>"
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
private
|
|
117
149
|
|
|
118
|
-
# Validate task
|
|
119
|
-
# @param
|
|
120
|
-
# @raise [ArgumentError] if the
|
|
121
|
-
def
|
|
122
|
-
return if
|
|
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
|
|
156
|
+
raise ArgumentError, "Invalid task status: #{status.inspect}. Must be one of: #{VALID_STATUSES.join(', ')}"
|
|
125
157
|
end
|
|
126
158
|
end
|
|
127
159
|
end
|
data/lib/mcp_client/tool.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
130
|
+
return false unless @annotations
|
|
120
131
|
|
|
121
|
-
fetch_annotation_hint('readOnlyHint', :readOnlyHint,
|
|
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
|
-
#
|
|
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
|
|
142
|
+
return true unless @annotations
|
|
130
143
|
|
|
131
|
-
fetch_annotation_hint('destructiveHint', :destructiveHint,
|
|
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.
|
data/lib/mcp_client/version.rb
CHANGED
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
|
|
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-
|
|
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: '
|
|
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: '
|
|
82
|
+
version: '8.0'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
84
|
name: rspec
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|