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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +445 -0
- data/lib/copilotkit/a2ui.rb +334 -0
- data/lib/copilotkit/agent.rb +46 -0
- data/lib/copilotkit/inspector_metadata.rb +82 -0
- data/lib/copilotkit/intelligence.rb +371 -0
- data/lib/copilotkit/mcp_apps.rb +199 -0
- data/lib/copilotkit/runner.rb +353 -0
- data/lib/copilotkit/runtime.rb +386 -0
- data/lib/copilotkit/runtime_entitlements.rb +53 -0
- data/lib/copilotkit/telemetry.rb +186 -0
- data/lib/copilotkit/ui_agent.rb +38 -0
- data/lib/copilotkit/websocket.rb +84 -0
- metadata +83 -0
|
@@ -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
|