manceps 1.0.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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # MCP protocol client supporting tools, resources, prompts, and notifications.
5
+ class Client
6
+ attr_reader :session
7
+
8
+ def initialize(url_or_command, auth: Auth::None.new, args: nil, env: nil, max_retries: 3, **options)
9
+ @transport = if args || !url_or_command.match?(%r{\Ahttps?://}i)
10
+ Transport::Stdio.new(url_or_command, args: args || [], env: env || {})
11
+ else
12
+ Transport::StreamableHTTP.new(url_or_command, auth: auth, timeout: options[:timeout])
13
+ end
14
+ @session = Session.new
15
+ @max_retries = max_retries
16
+ @backoff = Backoff.new
17
+ @notification_handlers = Hash.new { |h, k| h[k] = [] }
18
+ @elicitation_handler = nil
19
+ end
20
+
21
+ def connect
22
+ attempts = 0
23
+ begin
24
+ @transport.open if @transport.respond_to?(:open)
25
+
26
+ init_response = @transport.request(
27
+ JsonRpc.initialize_request(@session.next_id, capabilities: client_capabilities)
28
+ )
29
+ handle_rpc_error(init_response)
30
+ @session.establish(init_response)
31
+
32
+ unless Manceps.configuration.supported_versions.include?(@session.protocol_version)
33
+ server_version = @session.protocol_version
34
+ disconnect
35
+ raise ProtocolError.new(
36
+ "Server negotiated unsupported protocol version: #{server_version}",
37
+ code: -32_600
38
+ )
39
+ end
40
+
41
+ @transport.protocol_version = @session.protocol_version if @transport.respond_to?(:protocol_version=)
42
+
43
+ @transport.notify(JsonRpc.initialized_notification)
44
+ @backoff.reset
45
+ self
46
+ rescue ConnectionError, TimeoutError
47
+ attempts += 1
48
+ @transport.close if @transport.respond_to?(:close)
49
+ @session.reset
50
+ raise if attempts > @max_retries
51
+
52
+ sleep @backoff.next_delay
53
+ retry
54
+ end
55
+ end
56
+
57
+ def disconnect
58
+ sid = @transport.respond_to?(:session_id) ? @transport.session_id : @session.id
59
+ @transport.terminate_session(sid) if sid
60
+ @transport.close
61
+ @session.reset
62
+ end
63
+
64
+ def connected?
65
+ @session.active?
66
+ end
67
+
68
+ def reconnect!
69
+ @transport.close
70
+ @session.reset
71
+ connect
72
+ end
73
+
74
+ def ping
75
+ @transport.notify(JsonRpc.notification('ping'))
76
+ true
77
+ rescue ConnectionError, TimeoutError
78
+ false
79
+ end
80
+
81
+ def tools(force: false) # rubocop:disable Lint/UnusedMethodArgument
82
+ paginate_with_retry('tools/list', 'tools') { |data| Tool.new(data) }
83
+ end
84
+
85
+ def call_tool(name, **arguments)
86
+ response = request_with_retry('tools/call', name: name, arguments: arguments)
87
+ ToolResult.new(response['result'])
88
+ end
89
+
90
+ def call_tool_streaming(name, **arguments, &block)
91
+ body = JsonRpc.request(@session.next_id, 'tools/call', { name: name, arguments: arguments })
92
+ wrapped_block = if block
93
+ proc do |event|
94
+ if event.is_a?(Hash) && event['id'] && event['method']
95
+ handle_server_request(event)
96
+ else
97
+ block.call(event)
98
+ end
99
+ end
100
+ end
101
+ response = @transport.request_streaming(body, &wrapped_block)
102
+ handle_rpc_error(response)
103
+ ToolResult.new(response['result'])
104
+ end
105
+
106
+ def prompts(force: false) # rubocop:disable Lint/UnusedMethodArgument
107
+ paginate_with_retry('prompts/list', 'prompts') { |data| Prompt.new(data) }
108
+ end
109
+
110
+ def get_prompt(name, **arguments)
111
+ response = request_with_retry('prompts/get', name: name, arguments: arguments)
112
+ PromptResult.new(response['result'])
113
+ end
114
+
115
+ def resources(force: false) # rubocop:disable Lint/UnusedMethodArgument
116
+ paginate_with_retry('resources/list', 'resources') { |data| Resource.new(data) }
117
+ end
118
+
119
+ def resource_templates
120
+ paginate_with_retry('resources/templates/list', 'resourceTemplates') { |data| ResourceTemplate.new(data) }
121
+ end
122
+
123
+ def read_resource(uri)
124
+ response = request_with_retry('resources/read', uri: uri)
125
+ ResourceContents.new(response['result'])
126
+ end
127
+
128
+ def on(method, &block)
129
+ @notification_handlers[method] << block
130
+ end
131
+
132
+ def subscribe_resource(uri)
133
+ request('resources/subscribe', uri: uri)
134
+ end
135
+
136
+ def unsubscribe_resource(uri)
137
+ request('resources/unsubscribe', uri: uri)
138
+ end
139
+
140
+ def cancel_request(request_id, reason: nil)
141
+ params = { requestId: request_id }
142
+ params[:reason] = reason if reason
143
+ @transport.notify(JsonRpc.notification('notifications/cancelled', params))
144
+ end
145
+
146
+ def listen
147
+ @transport.listen do |message|
148
+ if message['id'] && message['method']
149
+ handle_server_request(message)
150
+ else
151
+ method = message['method']
152
+ params = message['params']
153
+ handlers = @notification_handlers[method]
154
+ handlers&.each { |h| h.call(params) }
155
+ end
156
+ end
157
+ end
158
+
159
+ def on_elicitation(&block)
160
+ @elicitation_handler = block
161
+ end
162
+
163
+ # --- Tasks (experimental, protocol 2025-11-25) ---
164
+
165
+ def tasks
166
+ response = request('tasks/list')
167
+ (response.dig('result', 'tasks') || []).map { |data| Task.new(data) }
168
+ end
169
+
170
+ def get_task(task_id)
171
+ response = request('tasks/get', taskId: task_id)
172
+ Task.new(response['result'])
173
+ end
174
+
175
+ def cancel_task(task_id) # rubocop:disable Naming/PredicateMethod
176
+ request('tasks/cancel', taskId: task_id)
177
+ true
178
+ end
179
+
180
+ def await_task(task_id, interval: 1, timeout: nil)
181
+ deadline = timeout ? Time.now + timeout : nil
182
+
183
+ loop do
184
+ task = get_task(task_id)
185
+ return task if task.done?
186
+
187
+ if deadline && Time.now >= deadline
188
+ raise TimeoutError, "Task #{task_id} did not complete within #{timeout} seconds"
189
+ end
190
+
191
+ sleep interval
192
+ end
193
+ end
194
+
195
+ def self.open(url, **)
196
+ client = new(url, **)
197
+ client.connect
198
+ yield client
199
+ ensure
200
+ client&.disconnect
201
+ end
202
+
203
+ MAX_PAGES = 100
204
+
205
+ private
206
+
207
+ def client_capabilities
208
+ caps = {}
209
+ caps['elicitation'] = { 'form' => {} } if @elicitation_handler
210
+ caps
211
+ end
212
+
213
+ def handle_server_request(request_data)
214
+ case request_data['method']
215
+ when 'elicitation/create'
216
+ return unless @elicitation_handler
217
+
218
+ elicitation = Elicitation.new(request_data['params'])
219
+ result = @elicitation_handler.call(elicitation)
220
+ response = JsonRpc.response(request_data['id'], result)
221
+ @transport.notify(response)
222
+ end
223
+ end
224
+
225
+ def request(method, **params)
226
+ body = JsonRpc.request(@session.next_id, method, params)
227
+ response = @transport.request(body)
228
+ handle_rpc_error(response)
229
+ response
230
+ end
231
+
232
+ def request_with_retry(method, **params)
233
+ request(method, **params)
234
+ rescue SessionExpiredError
235
+ reconnect!
236
+ request(method, **params)
237
+ end
238
+
239
+ def paginate(method, items_key, &)
240
+ results = []
241
+ cursor = nil
242
+ pages = 0
243
+
244
+ loop do
245
+ params = cursor ? { cursor: cursor } : {}
246
+ response = request(method, **params)
247
+ items = response.dig('result', items_key) || []
248
+ results.concat(items.map(&))
249
+
250
+ cursor = response.dig('result', 'nextCursor')
251
+ pages += 1
252
+ break if cursor.nil? || pages >= MAX_PAGES
253
+ end
254
+
255
+ results
256
+ end
257
+
258
+ def paginate_with_retry(method, items_key, &)
259
+ paginate(method, items_key, &)
260
+ rescue SessionExpiredError
261
+ reconnect!
262
+ paginate(method, items_key, &)
263
+ end
264
+
265
+ def handle_rpc_error(response)
266
+ return unless response.is_a?(Hash)
267
+
268
+ error = response['error'] || response[:error]
269
+ return unless error
270
+
271
+ raise ProtocolError.new(
272
+ error['message'] || error[:message] || 'Unknown JSON-RPC error',
273
+ code: error['code'] || error[:code],
274
+ data: error['data'] || error[:data]
275
+ )
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # A single content item returned by an MCP tool or prompt.
5
+ class Content
6
+ attr_reader :type, :text, :data, :mime_type, :uri, :resource
7
+
8
+ def initialize(data)
9
+ @type = data['type']
10
+ @text = data['text']
11
+ @data = data['data'] # base64 for image/audio
12
+ @mime_type = data['mimeType']
13
+ @uri = data['uri'] # for resource content
14
+ @resource = data['resource'] # for resource_link type
15
+ end
16
+
17
+ def text?
18
+ type == 'text'
19
+ end
20
+
21
+ def image?
22
+ type == 'image'
23
+ end
24
+
25
+ def resource?
26
+ type == 'resource'
27
+ end
28
+
29
+ def resource_link?
30
+ type == 'resource_link'
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Server-initiated request for additional user input.
5
+ class Elicitation
6
+ attr_reader :id, :message, :requested_schema
7
+
8
+ def initialize(data)
9
+ @id = data['id']
10
+ @message = data['message']
11
+ @requested_schema = data['requestedSchema']
12
+ end
13
+
14
+ def self.accept(content)
15
+ { action: 'accept', content: content }
16
+ end
17
+
18
+ def self.decline
19
+ { action: 'decline' }
20
+ end
21
+
22
+ def self.cancel
23
+ { action: 'cancel' }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ class Error < StandardError; end
5
+
6
+ class ConnectionError < Error; end
7
+ class TimeoutError < Error; end
8
+
9
+ # JSON-RPC error from the MCP server.
10
+ class ProtocolError < Error
11
+ attr_reader :code, :data
12
+
13
+ def initialize(message, code: nil, data: nil)
14
+ @code = code
15
+ @data = data
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ class AuthenticationError < Error; end
21
+ class SessionExpiredError < Error; end
22
+
23
+ # Error raised when a tool invocation fails.
24
+ class ToolError < Error
25
+ attr_reader :result
26
+
27
+ def initialize(message, result: nil)
28
+ @result = result
29
+ super(message)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Manceps
6
+ # JSON-RPC 2.0 message construction helpers.
7
+ module JsonRpc
8
+ module_function
9
+
10
+ def request(id, method, params = {})
11
+ { jsonrpc: '2.0', id: id, method: method, params: params }
12
+ end
13
+
14
+ def notification(method, params = {})
15
+ { jsonrpc: '2.0', method: method, params: params }
16
+ end
17
+
18
+ def initialize_request(id, client_info: nil, capabilities: {})
19
+ config = Manceps.configuration
20
+ info = client_info || { name: config.client_name, version: config.client_version }
21
+ info[:description] = config.client_description if config.client_description
22
+
23
+ request(id, 'initialize', {
24
+ protocolVersion: config.protocol_version,
25
+ capabilities: capabilities,
26
+ clientInfo: info
27
+ })
28
+ end
29
+
30
+ def response(id, result)
31
+ { jsonrpc: '2.0', id: id, result: result }
32
+ end
33
+
34
+ def initialized_notification
35
+ notification('notifications/initialized')
36
+ end
37
+
38
+ def parse_response(data)
39
+ data = JSON.parse(data, symbolize_names: true) if data.is_a?(String)
40
+
41
+ raise ProtocolError, "Invalid JSON-RPC version: #{data[:jsonrpc]}" unless data[:jsonrpc] == '2.0'
42
+
43
+ if data[:error]
44
+ err = data[:error]
45
+ raise ProtocolError.new(
46
+ err[:message] || 'Unknown error',
47
+ code: err[:code],
48
+ data: err[:data]
49
+ )
50
+ end
51
+
52
+ data
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # An MCP prompt definition.
5
+ class Prompt
6
+ attr_reader :name, :description, :arguments, :title
7
+
8
+ def initialize(data)
9
+ @name = data['name']
10
+ @description = data['description']
11
+ @title = data['title']
12
+ @arguments = (data['arguments'] || []).map { |a| Argument.new(a) }
13
+ end
14
+
15
+ # A prompt argument definition.
16
+ class Argument
17
+ attr_reader :name, :description, :required
18
+
19
+ def initialize(data)
20
+ @name = data['name']
21
+ @description = data['description']
22
+ @required = data['required'] || false
23
+ end
24
+
25
+ def required?
26
+ required
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Result of a prompt/get request.
5
+ class PromptResult
6
+ attr_reader :description, :messages
7
+
8
+ def initialize(data)
9
+ @description = data['description']
10
+ @messages = (data['messages'] || []).map { |m| Message.new(m) }
11
+ end
12
+
13
+ # A single message within a prompt result.
14
+ class Message
15
+ attr_reader :role, :content
16
+
17
+ def initialize(data)
18
+ @role = data['role']
19
+ @content = Content.new(data['content']) if data['content']
20
+ end
21
+
22
+ def text
23
+ content&.text
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # An MCP resource definition.
5
+ class Resource
6
+ attr_reader :uri, :name, :description, :mime_type, :annotations, :title
7
+
8
+ def initialize(data)
9
+ @uri = data['uri']
10
+ @name = data['name']
11
+ @description = data['description']
12
+ @mime_type = data['mimeType']
13
+ @annotations = data['annotations']
14
+ @title = data['title']
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Contents returned from a resource read.
5
+ class ResourceContents
6
+ attr_reader :contents
7
+
8
+ def initialize(data)
9
+ @contents = (data['contents'] || []).map { |c| Content.new(c) }
10
+ end
11
+
12
+ def text
13
+ contents.select(&:text?).map(&:text).join("\n")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # An MCP resource template definition.
5
+ class ResourceTemplate
6
+ attr_reader :uri_template, :name, :description, :mime_type, :annotations, :title
7
+
8
+ def initialize(data)
9
+ @uri_template = data['uriTemplate']
10
+ @name = data['name']
11
+ @description = data['description']
12
+ @mime_type = data['mimeType']
13
+ @annotations = data['annotations']
14
+ @title = data['title']
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Tracks MCP session state and protocol negotiation.
5
+ class Session
6
+ attr_reader :id, :capabilities, :protocol_version, :server_info
7
+
8
+ def initialize
9
+ reset
10
+ end
11
+
12
+ def next_id
13
+ @request_counter += 1
14
+ end
15
+
16
+ def establish(response)
17
+ result = response['result'] || response[:result] || {}
18
+ @id = response['session_id'] || response['sessionId'] ||
19
+ response[:session_id] || response[:sessionId]
20
+ @capabilities = result['capabilities'] || result[:capabilities] || {}
21
+ @protocol_version = result['protocolVersion'] || result[:protocolVersion]
22
+ @server_info = result['serverInfo'] || result[:serverInfo]
23
+ @established = true
24
+ end
25
+
26
+ def active?
27
+ @established
28
+ end
29
+
30
+ def server_supports?(capability)
31
+ capabilities.key?(capability.to_s) || capabilities.key?(capability.to_sym)
32
+ end
33
+
34
+ def reset
35
+ @id = nil
36
+ @capabilities = {}
37
+ @protocol_version = nil
38
+ @server_info = nil
39
+ @request_counter = 0
40
+ @established = false
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Manceps
6
+ # Parser for Server-Sent Events streams.
7
+ module SSEParser
8
+ module_function
9
+
10
+ def extract_json(body)
11
+ return nil if body.nil? || body.strip.empty?
12
+
13
+ data_lines = body.each_line.filter_map do |line|
14
+ line.strip.start_with?('data:') ? line.strip.sub(/\Adata:\s?/, '') : nil
15
+ end
16
+
17
+ return nil if data_lines.empty?
18
+
19
+ JSON.parse(data_lines.join, symbolize_names: true)
20
+ end
21
+
22
+ def parse_events(body)
23
+ return [] if body.nil? || body.strip.empty?
24
+
25
+ events = []
26
+ current = { id: nil, event: nil, data: [] }
27
+
28
+ body.each_line do |raw_line|
29
+ line = raw_line.chomp
30
+
31
+ if line.empty?
32
+ unless current[:data].empty?
33
+ events << {
34
+ id: current[:id],
35
+ event: current[:event],
36
+ data: current[:data].join("\n")
37
+ }
38
+ end
39
+ current = { id: nil, event: nil, data: [] }
40
+ next
41
+ end
42
+
43
+ if line.start_with?('id:')
44
+ current[:id] = line.sub(/\Aid:\s?/, '')
45
+ elsif line.start_with?('event:')
46
+ current[:event] = line.sub(/\Aevent:\s?/, '')
47
+ elsif line.start_with?('data:')
48
+ current[:data] << line.sub(/\Adata:\s?/, '')
49
+ end
50
+ end
51
+
52
+ # Flush any trailing event without a final blank line
53
+ unless current[:data].empty?
54
+ events << {
55
+ id: current[:id],
56
+ event: current[:event],
57
+ data: current[:data].join("\n")
58
+ }
59
+ end
60
+
61
+ events
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # An MCP task for tracking long-running operations.
5
+ class Task
6
+ attr_reader :id, :status, :result, :error, :metadata
7
+
8
+ STATUSES = %w[pending running completed failed cancelled].freeze
9
+
10
+ def initialize(data)
11
+ @id = data['id']
12
+ @status = data['status']
13
+ @result = data['result']
14
+ @error = data['error']
15
+ @metadata = data['metadata'] || data['_meta']
16
+ end
17
+
18
+ def pending?
19
+ status == 'pending'
20
+ end
21
+
22
+ def running?
23
+ status == 'running'
24
+ end
25
+
26
+ def completed?
27
+ status == 'completed'
28
+ end
29
+
30
+ def failed?
31
+ status == 'failed'
32
+ end
33
+
34
+ def cancelled?
35
+ status == 'cancelled'
36
+ end
37
+
38
+ def done?
39
+ completed? || failed? || cancelled?
40
+ end
41
+ end
42
+ end