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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +325 -0
- data/lib/manceps/auth/api_key_header.rb +17 -0
- data/lib/manceps/auth/bearer.rb +16 -0
- data/lib/manceps/auth/none.rb +12 -0
- data/lib/manceps/auth/oauth.rb +202 -0
- data/lib/manceps/backoff.rb +25 -0
- data/lib/manceps/client.rb +278 -0
- data/lib/manceps/content.rb +33 -0
- data/lib/manceps/elicitation.rb +26 -0
- data/lib/manceps/errors.rb +32 -0
- data/lib/manceps/json_rpc.rb +55 -0
- data/lib/manceps/prompt.rb +30 -0
- data/lib/manceps/prompt_result.rb +27 -0
- data/lib/manceps/resource.rb +17 -0
- data/lib/manceps/resource_contents.rb +16 -0
- data/lib/manceps/resource_template.rb +17 -0
- data/lib/manceps/session.rb +43 -0
- data/lib/manceps/sse_parser.rb +64 -0
- data/lib/manceps/task.rb +42 -0
- data/lib/manceps/tool.rb +24 -0
- data/lib/manceps/tool_result.rb +26 -0
- data/lib/manceps/transport/base.rb +36 -0
- data/lib/manceps/transport/stdio.rb +143 -0
- data/lib/manceps/transport/streamable_http.rb +190 -0
- data/lib/manceps/version.rb +5 -0
- data/lib/manceps.rb +66 -0
- metadata +102 -0
|
@@ -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
|
data/lib/manceps/task.rb
ADDED
|
@@ -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
|