copilotkit-runtime 0.1.0.rc.1

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,371 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'securerandom'
6
+ require 'thread'
7
+ require 'timeout'
8
+ require_relative 'inspector_metadata'
9
+ require_relative 'runtime_entitlements'
10
+
11
+ module CopilotKit
12
+ # Safe platform error. Response bodies and credentials are not included.
13
+ class Error < StandardError
14
+ attr_reader :status
15
+ def initialize(status, message)
16
+ @status = status
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ # Safe entitlement failure with platform status and retry guidance.
22
+ class RuntimeEntitlementError < Error
23
+ attr_reader :retryable
24
+ def initialize(status, message, retryable)
25
+ @retryable = retryable
26
+ super(status, message)
27
+ end
28
+ end
29
+
30
+ # Trusted per-call permissions for user and project memories.
31
+ class MemoryGrant
32
+ VALUES = { none: 'none', read: 'read', read_write: 'read-write' }.freeze
33
+ attr_reader :user, :project
34
+
35
+ def initialize(user:, project:)
36
+ @user = VALUES.fetch(user, user)
37
+ @project = VALUES.fetch(project, project)
38
+ raise ArgumentError, 'Invalid memory grant' unless VALUES.value?(@user) && VALUES.value?(@project)
39
+ freeze
40
+ end
41
+
42
+ def to_h
43
+ { 'user' => user, 'project' => project }
44
+ end
45
+ end
46
+
47
+ # Native HTTP transport. Each call closes its connection and never follows redirects.
48
+ class Platform
49
+ def initialize(url, key)
50
+ @url, @key = url.sub(%r{/$}, ''), key
51
+ uri = URI(@url)
52
+ raise ArgumentError, 'HTTP(S) URL is required' unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
53
+ end
54
+
55
+ def request(method, path, payload = nil, headers = {})
56
+ uri = URI(@url + path)
57
+ request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers.merge('authorization' => "Bearer #{@key}", 'content-type' => 'application/json'))
58
+ request.body = JSON.generate(payload) unless payload.nil?
59
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 15) do |http|
60
+ http.max_retries = 0
61
+ return inspector_response(http, request) if method == 'GET' && path == '/api/inspector/metadata'
62
+ return entitlement_response(http, request) if method == 'GET' && path == '/api/entitlements/runtime'
63
+ http.request(request)
64
+ end
65
+ raise Error.new(response.code.to_i, 'Intelligence platform request failed') unless response.code.to_i.between?(200, 299)
66
+ response.body.nil? || response.body.empty? ? nil : JSON.parse(response.body)
67
+ rescue JSON::ParserError
68
+ if method == 'GET' && path == '/api/entitlements/runtime'
69
+ raise RuntimeEntitlementError.new(502, 'Invalid Runtime entitlement response', false), cause: nil
70
+ end
71
+ raise Error.new(502, 'Invalid platform response')
72
+ rescue IOError, SystemCallError, Timeout::Error, SocketError
73
+ raise Error.new(502, 'Intelligence platform is unreachable')
74
+ end
75
+
76
+ # Skip absent/error bodies while the SDK bounds the full connection lifetime.
77
+ def inspector_response(http, request)
78
+ http.request(request) do |response|
79
+ status = response.code.to_i
80
+ return nil if [204, 404].include?(status)
81
+ raise Error.new(status, 'Inspector metadata request failed') unless status.between?(200, 299)
82
+ body = response.body
83
+ raise Error.new(502, 'Invalid Inspector metadata response') if body.nil? || body.empty?
84
+ return JSON.parse(body)
85
+ end
86
+ end
87
+ private :inspector_response
88
+
89
+ # Inspect rejected statuses before reading bodies that can stall or contain secrets.
90
+ def entitlement_response(http, request)
91
+ http.request(request) do |response|
92
+ status = response.code.to_i
93
+ unless status.between?(200, 299)
94
+ raise RuntimeEntitlementError.new(status, 'Runtime entitlement request rejected', [408, 425, 429].include?(status) || status >= 500), cause: nil
95
+ end
96
+ body = response.body
97
+ raise RuntimeEntitlementError.new(502, 'Invalid Runtime entitlement response', false), cause: nil if body.nil? || body.empty?
98
+ return JSON.parse(body)
99
+ end
100
+ end
101
+ private :entitlement_response
102
+ end
103
+
104
+ # Programmatic Intelligence SDK. Requiring this file does not load Runtime or Rack.
105
+ class Intelligence
106
+ API_URL = 'https://api.intelligence.copilotkit.ai'
107
+ RUNNER_URL = 'wss://realtime.intelligence.copilotkit.ai/runner'
108
+ CLIENT_URL = 'wss://realtime.intelligence.copilotkit.ai/client'
109
+ attr_reader :api_key, :api_url, :runner_url, :client_url
110
+
111
+ def initialize(api_key:, api_url: API_URL, runner_url: RUNNER_URL, client_url: CLIENT_URL, transport: nil)
112
+ raise ArgumentError, 'api_key is required' unless api_key.is_a?(String) && !api_key.strip.empty?
113
+ [[api_url, %w[http https]], [runner_url, %w[ws wss]], [client_url, %w[ws wss]]].each do |endpoint, schemes|
114
+ uri = URI(endpoint)
115
+ raise ArgumentError, 'Invalid Intelligence endpoint URL' unless schemes.include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
116
+ end
117
+ @api_key, @api_url = api_key.dup.freeze, api_url.sub(%r{/$}, '').freeze
118
+ @runner_url, @client_url = runner_url.dup.freeze, client_url.dup.freeze
119
+ @transport = transport || Platform.new(@api_url, api_key)
120
+ @listeners = { created: [], updated: [], deleted: [] }
121
+ @listener_mutex = Mutex.new
122
+ @entitlement_mutex = Mutex.new
123
+ @entitlement_cache = nil
124
+ end
125
+
126
+ # Shared SDK transport used by Runtime. Credentials always come from this client.
127
+ def request(method, path, payload = nil, headers = {})
128
+ result = @transport.request(method, path, payload, headers)
129
+ notify_thread_mutation(method, path, payload, result)
130
+ result
131
+ end
132
+
133
+ # Read sanitized project metadata within five seconds, including the response body.
134
+ # @return [Hash, nil] Supported V1 fields, or nil for 204, 404, or an unsupported schema.
135
+ def get_inspector_metadata
136
+ Timeout.timeout(5) do
137
+ InspectorMetadata.parse(request('GET', '/api/inspector/metadata'))
138
+ end
139
+ rescue Timeout::Error
140
+ raise Timeout::Error, 'Inspector metadata request timed out', cause: nil
141
+ rescue Error => error
142
+ return nil if error.status == 404
143
+ raise Error.new(error.status, 'Inspector metadata request failed'), cause: nil
144
+ rescue StandardError
145
+ raise Error.new(502, 'Inspector metadata request failed'), cause: nil
146
+ end
147
+
148
+ # @return [Hash] A normalized ready grant or structured non-ready result.
149
+ def get_runtime_entitlements
150
+ @entitlement_mutex.synchronize do
151
+ unless @entitlement_cache && entitlement_now < @entitlement_cache.first
152
+ begin
153
+ value = fetch_runtime_entitlements
154
+ active = value['status'] == 'ready' && value['entitlement']['active']
155
+ @entitlement_cache = [entitlement_now + (active ? 30 : 5), value]
156
+ rescue RuntimeEntitlementError => error
157
+ @entitlement_cache = [entitlement_now + 5, error]
158
+ end
159
+ end
160
+ value = @entitlement_cache.last
161
+ if value.is_a?(RuntimeEntitlementError)
162
+ raise RuntimeEntitlementError.new(value.status, value.message, value.retryable), cause: nil
163
+ end
164
+ RuntimeEntitlements.copy(value)
165
+ end
166
+ end
167
+
168
+ # Bound the whole platform request and keep cached failures safe for every caller.
169
+ def fetch_runtime_entitlements
170
+ Timeout.timeout(1.5) do
171
+ value = RuntimeEntitlements.parse(request('GET', '/api/entitlements/runtime'))
172
+ raise RuntimeEntitlementError.new(502, 'Invalid Runtime entitlement response', false), cause: nil unless value
173
+ value
174
+ end
175
+ rescue RuntimeEntitlementError => error
176
+ raise RuntimeEntitlementError.new(error.status, 'Runtime entitlement request failed', error.retryable), cause: nil
177
+ rescue Timeout::Error
178
+ raise RuntimeEntitlementError.new(504, 'Runtime entitlement request timed out', true), cause: nil
179
+ rescue Error => error
180
+ raise RuntimeEntitlementError.new(error.status, 'Runtime entitlement request rejected', [408, 425, 429].include?(error.status) || error.status >= 500), cause: nil
181
+ rescue StandardError
182
+ raise RuntimeEntitlementError.new(502, 'Runtime entitlement connection failed', true), cause: nil
183
+ end
184
+
185
+ def entitlement_now
186
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+ end
188
+ private :fetch_runtime_entitlements, :entitlement_now
189
+
190
+ # Register a creation listener; the returned Proc removes it.
191
+ def on_thread_created(&callback)
192
+ subscribe(:created, callback)
193
+ end
194
+
195
+ # Register a listener for thread updates and archives.
196
+ def on_thread_updated(&callback)
197
+ subscribe(:updated, callback)
198
+ end
199
+
200
+ # Register a listener with the deleted thread and explicit caller identity.
201
+ def on_thread_deleted(&callback)
202
+ subscribe(:deleted, callback)
203
+ end
204
+
205
+ # List a user's threads for one agent, retaining the platform pagination cursor.
206
+ def list_threads(user_id:, agent_id:, include_archived: false, limit: nil, cursor: nil)
207
+ query = { userId: user_id, agentId: agent_id, limit: limit, cursor: cursor }.compact
208
+ query[:includeArchived] = 'true' if include_archived
209
+ object('GET', '/api/threads?' + URI.encode_www_form(query))
210
+ end
211
+
212
+ def get_thread(thread_id:, user_id:)
213
+ thread('GET', '/api/threads/' + segment(thread_id) + '?' + URI.encode_www_form(userId: user_id))
214
+ end
215
+
216
+ # Assign a new thread to an existing Learning Container through its stable ID.
217
+ def create_thread(thread_id:, user_id:, agent_id:, name: nil, learning_container_id: nil)
218
+ body = { 'threadId' => thread_id, 'userId' => user_id, 'agentId' => agent_id }
219
+ body['name'] = name unless name.nil?
220
+ body['learningContainerId'] = learning_container_id unless learning_container_id.nil?
221
+ thread('POST', '/api/threads', body)
222
+ end
223
+
224
+ # Resolve concurrent creation only after a 404 read followed by a 409 create.
225
+ def get_or_create_thread(thread_id:, user_id:, agent_id:, name: nil, learning_container_id: nil)
226
+ begin
227
+ return { 'thread' => get_thread(thread_id: thread_id, user_id: user_id), 'created' => false }
228
+ rescue Error => error
229
+ raise unless error.status == 404
230
+ end
231
+ begin
232
+ value = create_thread(thread_id: thread_id, user_id: user_id, agent_id: agent_id, name: name, learning_container_id: learning_container_id)
233
+ { 'thread' => value, 'created' => true }
234
+ rescue Error => error
235
+ raise unless error.status == 409
236
+ { 'thread' => get_thread(thread_id: thread_id, user_id: user_id), 'created' => false }
237
+ end
238
+ end
239
+
240
+ def update_thread(thread_id:, user_id:, agent_id:, updates:)
241
+ body = updates.transform_keys(&:to_s).merge('userId' => user_id, 'agentId' => agent_id)
242
+ thread('PATCH', '/api/threads/' + segment(thread_id), body)
243
+ end
244
+
245
+ def archive_thread(thread_id:, user_id:, agent_id:)
246
+ update_thread(thread_id: thread_id, user_id: user_id, agent_id: agent_id, updates: { archived: true })
247
+ nil
248
+ end
249
+
250
+ # Permanently delete a thread and its history.
251
+ def delete_thread(thread_id:, user_id:, agent_id:)
252
+ request('DELETE', '/api/threads/' + segment(thread_id), {
253
+ 'userId' => user_id, 'agentId' => agent_id,
254
+ 'reason' => "Deleted via CopilotKit SDK (userId=#{user_id}, agentId=#{agent_id})"
255
+ })
256
+ nil
257
+ end
258
+
259
+ def get_thread_messages(thread_id:, user_id:)
260
+ object('GET', '/api/threads/' + segment(thread_id) + '/messages?' + URI.encode_www_form(userId: user_id))
261
+ end
262
+
263
+ def get_thread_events(thread_id:)
264
+ object('GET', '/api/_inspect/threads/' + segment(thread_id) + '/events')
265
+ end
266
+
267
+ def get_thread_state(thread_id:)
268
+ object('GET', '/api/_inspect/threads/' + segment(thread_id) + '/state')
269
+ end
270
+
271
+ def list_memories(user_id:, include_invalidated: false, memory_grant: nil)
272
+ path = '/api/memories' + (include_invalidated ? '?includeInvalidated=true' : '')
273
+ object('GET', path, nil, memory_headers(user_id, memory_grant))
274
+ end
275
+
276
+ def create_memory(user_id:, content:, kind:, scope: nil, source_thread_ids: [], memory_grant: nil)
277
+ body = { 'content' => content, 'kind' => kind, 'sourceThreadIds' => source_thread_ids }
278
+ body['scope'] = scope unless scope.nil?
279
+ object('POST', '/api/memories', body, memory_headers(user_id, memory_grant))
280
+ end
281
+
282
+ # Supersede a memory and retain the platform's retiredId marker.
283
+ def update_memory(user_id:, memory_id:, content:, kind:, scope: nil, source_thread_ids: [], memory_grant: nil)
284
+ body = { 'content' => content, 'kind' => kind, 'sourceThreadIds' => source_thread_ids }
285
+ body['scope'] = scope unless scope.nil?
286
+ object('PATCH', '/api/memories/' + segment(memory_id), body, memory_headers(user_id, memory_grant))
287
+ end
288
+
289
+ # Retire a memory without deleting its history.
290
+ def remove_memory(user_id:, memory_id:, memory_grant: nil)
291
+ request('DELETE', '/api/memories/' + segment(memory_id), nil, memory_headers(user_id, memory_grant))
292
+ nil
293
+ end
294
+
295
+ def recall_memories(user_id:, query:, limit: nil, scope: nil, memory_grant: nil)
296
+ body = { 'query' => query, 'limit' => limit, 'scope' => scope }.compact
297
+ object('POST', '/api/memories/recall', body, memory_headers(user_id, memory_grant))
298
+ end
299
+
300
+ # Reuse client_event_id when retrying the same annotation.
301
+ def annotate(user_id:, thread_id:, type:, client_event_id: nil, payload: nil, occurred_at: nil)
302
+ body = { 'userId' => user_id, 'threadId' => thread_id, 'type' => type }
303
+ body['payload'] = payload unless payload.nil?
304
+ body['occurredAt'] = occurred_at unless occurred_at.nil?
305
+ object('PUT', '/connector/annotate/' + segment(client_event_id || SecureRandom.uuid), body)
306
+ end
307
+
308
+ private
309
+
310
+ # Synchronize registration without holding the mutex during application callbacks.
311
+ def subscribe(event, callback)
312
+ raise ArgumentError, 'A thread listener block is required' unless callback
313
+ @listener_mutex.synchronize do
314
+ @listeners[event] << callback unless @listeners[event].any? { |listener| listener.equal?(callback) }
315
+ end
316
+ -> { @listener_mutex.synchronize { @listeners[event].delete_if { |listener| listener.equal?(callback) } }; nil }
317
+ end
318
+
319
+ # Observe SDK and Runtime writes once; locks and subscriptions are not thread mutations.
320
+ def notify_thread_mutation(method, path, body, result)
321
+ target = %r{\A/api/threads/([^/?]+)\z}.match(path)
322
+ event = payload = nil
323
+ if (method == 'POST' && path == '/api/threads') || (method == 'PATCH' && target)
324
+ thread = result['thread'] if result.is_a?(Hash)
325
+ if thread.is_a?(Hash) && thread['id'].is_a?(String) && !thread['id'].strip.empty?
326
+ event = method == 'POST' ? :created : :updated
327
+ payload = thread
328
+ end
329
+ elsif method == 'DELETE' && target && body.is_a?(Hash) && body['userId'].is_a?(String) && body['agentId'].is_a?(String)
330
+ event = :deleted
331
+ payload = { 'threadId' => URI.decode_www_form_component(target[1]), 'userId' => body['userId'], 'agentId' => body['agentId'] }
332
+ end
333
+ return unless event
334
+ listeners = @listener_mutex.synchronize { @listeners[event].dup }
335
+ listeners.each do |callback|
336
+ begin
337
+ callback.call(payload)
338
+ rescue StandardError => error
339
+ warn "Intelligence thread #{event} listener failed (#{error.class})"
340
+ end
341
+ end
342
+ end
343
+
344
+ def segment(value)
345
+ raise ArgumentError, 'A nonempty identifier is required' unless value.is_a?(String) && !value.strip.empty?
346
+ URI.encode_www_form_component(value).gsub('+', '%20')
347
+ end
348
+
349
+ def object(method, path, payload = nil, headers = {})
350
+ value = request(method, path, payload, headers)
351
+ raise Error.new(502, 'Invalid Intelligence response') unless value.is_a?(Hash)
352
+ value
353
+ end
354
+
355
+ def thread(method, path, payload = nil)
356
+ value = object(method, path, payload)['thread']
357
+ raise Error.new(502, 'Invalid thread response') unless value.is_a?(Hash) && value['id'].is_a?(String) && !value['id'].strip.empty?
358
+ value
359
+ end
360
+
361
+ def memory_headers(user_id, grant)
362
+ segment(user_id)
363
+ headers = { 'x-cpki-user-id' => user_id }
364
+ unless grant.nil?
365
+ raise ArgumentError, 'memory_grant must be a MemoryGrant' unless grant.is_a?(MemoryGrant)
366
+ headers['x-cpki-memory-grant'] = JSON.generate(grant.to_h)
367
+ end
368
+ headers
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+ require 'digest'
3
+ module CopilotKit
4
+ # Request-scoped MCP Streamable HTTP session. Never accepts a browser URL or credentials.
5
+ class MCPClient
6
+ def initialize(config)
7
+ raise ArgumentError, 'Only MCP Streamable HTTP is supported' unless config['type'] == 'http'
8
+ @uri = URI(config.fetch('url'))
9
+ raise ArgumentError, 'MCP URL must use HTTP(S)' unless @uri.is_a?(URI::HTTP) && !@uri.userinfo
10
+ @headers = config.fetch('headers', {}).dup
11
+ @session, @reference, @version = nil, 0, '2025-03-26'
12
+ end
13
+
14
+ def connect
15
+ result = rpc('initialize', { 'protocolVersion' => @version,
16
+ 'capabilities' => { 'extensions' => { 'io.modelcontextprotocol/ui' => { 'mimeTypes' => ['text/html;profile=mcp-app'] } } },
17
+ 'clientInfo' => { 'name' => 'copilotkit-runtime-ruby', 'version' => '0.1.0.rc.1' } })
18
+ raise Error.new(502, 'Malformed MCP initialization') unless result.is_a?(Hash) && result['protocolVersion'].is_a?(String)
19
+ @version = result['protocolVersion']
20
+ rpc('notifications/initialized', nil, notification: true)
21
+ self
22
+ end
23
+
24
+ def rpc(method, params = nil, notification: false)
25
+ @reference += 1
26
+ envelope = { 'jsonrpc' => '2.0', 'method' => method }
27
+ envelope['id'] = @reference unless notification
28
+ envelope['params'] = params unless params.nil?
29
+ raw = transport('POST', JSON.generate(envelope))
30
+ return { 'success' => true } if notification
31
+ candidates = if raw[:type].include?('text/event-stream')
32
+ raw[:body].split(/\r?\n\r?\n/).filter_map do |frame|
33
+ data = frame.lines.select { |line| line.start_with?('data:') }.map { |line| line.delete_prefix('data:').strip }.join("\n")
34
+ JSON.parse(data) unless data.empty?
35
+ end
36
+ else
37
+ [JSON.parse(raw[:body])]
38
+ end
39
+ response = candidates.find { |message| message.is_a?(Hash) && message['id'] == envelope['id'] }
40
+ raise Error.new(502, 'Malformed MCP response') unless response && response['jsonrpc'] == '2.0'
41
+ raise Error.new(502, 'MCP request failed') if response.key?('error')
42
+ raise Error.new(502, 'Missing MCP result') unless response.key?('result')
43
+ response['result']
44
+ rescue JSON::ParserError
45
+ raise Error.new(502, 'Malformed MCP JSON response')
46
+ end
47
+
48
+ def close
49
+ transport('DELETE') if @session
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ def transport(method, body = nil)
57
+ headers = @headers.merge('content-type' => 'application/json', 'accept' => 'application/json, text/event-stream', 'mcp-protocol-version' => @version)
58
+ headers['mcp-session-id'] = @session if @session
59
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, @uri.request_uri, headers)
60
+ request.body = body if body
61
+ result = nil
62
+ expected_id = body && JSON.parse(body)['id']
63
+ catch(:mcp_response_complete) do
64
+ Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == 'https', open_timeout: 5, read_timeout: 15) do |http|
65
+ http.request(request) do |response|
66
+ raise Error.new(502, 'MCP server request failed') unless response.code.to_i.between?(200, 299)
67
+ @session = response['mcp-session-id'] if response['mcp-session-id']
68
+ data = +''
69
+ response.read_body do |chunk|
70
+ data << chunk
71
+ raise Error.new(502, 'MCP response exceeded size limit') if data.bytesize > 4_194_304
72
+ if expected_id && response['content-type'].to_s.include?('text/event-stream')
73
+ frames = data.split(/\r?\n\r?\n/, -1)[0...-1]
74
+ complete = frames.any? do |frame|
75
+ payload = frame.lines.select { |line| line.start_with?('data:') }.map { |line| line.delete_prefix('data:').strip }.join("\n")
76
+ message = payload.empty? ? nil : JSON.parse(payload)
77
+ message.is_a?(Hash) && message['id'] == expected_id
78
+ end
79
+ if complete
80
+ result = { body: frames.join("\n\n"), type: 'text/event-stream' }
81
+ throw :mcp_response_complete
82
+ end
83
+ end
84
+ end
85
+ result = { body: data, type: response['content-type'].to_s }
86
+ end
87
+ end
88
+ end
89
+ result
90
+ end
91
+ end
92
+
93
+ # MCP Apps discovery, tool execution, activity, and allowlisted iframe reentry.
94
+ class MCPApps
95
+ METHODS = %w[tools/call resources/read notifications/message ping].freeze
96
+ def initialize(servers)
97
+ @servers, @tools, @calls, @resolved = servers, {}, {}, Set.new
98
+ end
99
+
100
+ def server_hash(server)
101
+ Digest::MD5.hexdigest(JSON.generate('type' => server['type'], 'url' => server['url']))
102
+ end
103
+
104
+ def proxy(request)
105
+ raise Error.new(400, 'Invalid MCP proxy request') unless request.is_a?(Hash)
106
+ raise Error.new(403, 'MCP method is not allowed') unless METHODS.include?(request['method'])
107
+ matches = @servers.select do |entry|
108
+ request['serverId'] ? entry['serverId'] == request['serverId'] : server_hash(entry) == request['serverHash']
109
+ end
110
+ server = matches.first if matches.length == 1
111
+ raise Error.new(404, 'Unknown MCP server') unless server
112
+ with_client(server) { |client| client.rpc(request['method'], request['params'], notification: request['method'] == 'notifications/message') }
113
+ rescue Error, ArgumentError, KeyError, SocketError, IOError, SystemCallError, Timeout::Error, Net::HTTPBadResponse, OpenSSL::SSL::SSLError
114
+ { 'error' => 'MCP proxy request rejected or failed' }
115
+ end
116
+
117
+ def prepare(input)
118
+ @servers.each do |server|
119
+ with_client(server) do |client|
120
+ cursor, pages = nil, 0
121
+ loop do
122
+ result = client.rpc('tools/list', cursor ? { 'cursor' => cursor } : {})
123
+ raise Error.new(502, 'Invalid MCP tool listing') unless result.is_a?(Hash) && result['tools'].is_a?(Array)
124
+ result['tools'].each do |tool|
125
+ next unless tool.is_a?(Hash)
126
+ ui = tool.dig('_meta', 'ui')
127
+ if ui.is_a?(Hash) && ui.key?('visibility')
128
+ next unless ui['visibility'].is_a?(Array) && ui['visibility'].include?('model')
129
+ end
130
+ resource = tool.dig('_meta', 'ui', 'resourceUri')
131
+ resource = tool.dig('_meta', 'ui/resourceUri') unless resource.is_a?(String)
132
+ next unless resource.is_a?(String) && tool['name'].is_a?(String)
133
+ raise Error.new(502, 'Duplicate MCP UI tool name') if @tools.key?(tool['name'])
134
+ @tools[tool['name']] = { server: server, resource: resource, tool: {
135
+ 'name' => tool['name'], 'description' => tool.fetch('description', '') + "\n[UI Resource: #{resource}]",
136
+ 'parameters' => tool.fetch('inputSchema', { 'type' => 'object', 'properties' => {} }) } }
137
+ end
138
+ cursor = result['nextCursor']
139
+ break unless cursor
140
+ pages += 1
141
+ raise Error.new(502, 'MCP tool pagination limit exceeded') if pages >= 32
142
+ end
143
+ end
144
+ end
145
+ input.fetch('messages', []).each do |message|
146
+ @resolved << message['toolCallId'] if message['role'] == 'tool'
147
+ message.fetch('toolCalls', []).each do |call|
148
+ function = call['function'] || {}
149
+ @calls[call['id']] = { name: function['name'], args: function.fetch('arguments', '') }
150
+ end
151
+ end
152
+ names = @tools.keys
153
+ input.merge('tools' => input.fetch('tools', []).reject { |tool| names.include?(tool['name']) } + @tools.values.map { |entry| entry[:tool] })
154
+ end
155
+
156
+ def accept(event)
157
+ id = event['toolCallId']
158
+ case event['type']
159
+ when 'TOOL_CALL_START' then @calls[id] = { name: event['toolCallName'], args: '' }
160
+ when 'TOOL_CALL_ARGS'
161
+ if @calls[id]
162
+ @calls[id][:args] += event.fetch('delta', '')
163
+ raise Error.new(502, 'MCP tool arguments exceeded size limit') if @calls[id][:args].bytesize > 1_048_576
164
+ end
165
+ when 'TOOL_CALL_RESULT' then @resolved << id
166
+ end
167
+ end
168
+
169
+ def finish
170
+ @calls.each do |id, call|
171
+ info = @tools[call[:name]]
172
+ next unless info && !@resolved.include?(id)
173
+ begin
174
+ arguments = JSON.parse(call[:args].empty? ? '{}' : call[:args])
175
+ raise Error.new(400, 'MCP tool arguments must be an object') unless arguments.is_a?(Hash)
176
+ result = with_client(info[:server]) { |client| client.rpc('tools/call', { 'name' => call[:name], 'arguments' => arguments }) }
177
+ content = result['content']
178
+ text = content.is_a?(Array) ? content.filter_map { |entry| entry['text'] if entry.is_a?(Hash) && entry['type'] == 'text' }.join("\n") : ''
179
+ yield({ 'type' => 'TOOL_CALL_RESULT', 'messageId' => SecureRandom.uuid, 'toolCallId' => id, 'content' => text.empty? ? JSON.generate(content) : text })
180
+ activity = { 'result' => result, 'resourceUri' => info[:resource], 'serverHash' => server_hash(info[:server]), 'toolInput' => arguments }
181
+ activity['serverId'] = info[:server]['serverId'] if info[:server]['serverId']
182
+ yield({ 'type' => 'ACTIVITY_SNAPSHOT', 'messageId' => SecureRandom.uuid, 'activityType' => 'mcp-apps', 'content' => activity, 'replace' => true })
183
+ rescue StandardError
184
+ yield({ 'type' => 'TOOL_CALL_RESULT', 'messageId' => SecureRandom.uuid, 'toolCallId' => id, 'content' => JSON.generate('error' => 'MCP tool execution failed') })
185
+ end
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def with_client(server)
192
+ client = MCPClient.new(server)
193
+ client.connect
194
+ yield client
195
+ ensure
196
+ client&.close
197
+ end
198
+ end
199
+ end