ruby-utcp 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +22 -0
- data/Makefile +226 -0
- data/README.md +331 -0
- data/examples/basic.rb +33 -0
- data/examples/cli.rb +32 -0
- data/examples/generated/__init__.py +1 -0
- data/examples/generated/utcp_pb2.py +46 -0
- data/examples/generated/utcp_pb2_grpc.py +183 -0
- data/examples/graphql.rb +15 -0
- data/examples/grpc.rb +42 -0
- data/examples/grpc_python.py +52 -0
- data/examples/http.rb +17 -0
- data/examples/mcp.rb +28 -0
- data/examples/servers/graphql_server.rb +39 -0
- data/examples/servers/grpc_server.py +97 -0
- data/examples/servers/grpc_server.rb +62 -0
- data/examples/servers/http_helpers.rb +34 -0
- data/examples/servers/http_server.rb +28 -0
- data/examples/servers/mcp_stdio_server.rb +43 -0
- data/examples/servers/requirements-grpc.txt +2 -0
- data/examples/servers/sse_server.rb +36 -0
- data/examples/servers/streamable_http_server.rb +39 -0
- data/examples/servers/tcp_server.rb +58 -0
- data/examples/servers/udp_server.rb +33 -0
- data/examples/servers/webrtc_server.rb +78 -0
- data/examples/servers/websocket_server.rb +92 -0
- data/examples/sse.rb +16 -0
- data/examples/streamable_http.rb +17 -0
- data/examples/tcp.rb +20 -0
- data/examples/text.rb +23 -0
- data/examples/udp.rb +18 -0
- data/examples/webrtc.rb +19 -0
- data/examples/websocket.rb +17 -0
- data/lib/ruby-utcp.rb +4 -0
- data/lib/utcp/client.rb +217 -0
- data/lib/utcp/config.rb +79 -0
- data/lib/utcp/errors.rb +48 -0
- data/lib/utcp/migration.rb +88 -0
- data/lib/utcp/models.rb +794 -0
- data/lib/utcp/openapi_converter.rb +179 -0
- data/lib/utcp/protocols/base.rb +97 -0
- data/lib/utcp/protocols/cli.rb +186 -0
- data/lib/utcp/protocols/file.rb +52 -0
- data/lib/utcp/protocols/graphql.rb +277 -0
- data/lib/utcp/protocols/grpc.rb +207 -0
- data/lib/utcp/protocols/http.rb +340 -0
- data/lib/utcp/protocols/http_stream_support.rb +122 -0
- data/lib/utcp/protocols/mcp.rb +339 -0
- data/lib/utcp/protocols/socket_support.rb +51 -0
- data/lib/utcp/protocols/sse.rb +107 -0
- data/lib/utcp/protocols/streamable_http.rb +78 -0
- data/lib/utcp/protocols/tcp.rb +143 -0
- data/lib/utcp/protocols/text.rb +44 -0
- data/lib/utcp/protocols/udp.rb +61 -0
- data/lib/utcp/protocols/webrtc.rb +217 -0
- data/lib/utcp/protocols/websocket.rb +350 -0
- data/lib/utcp/registry.rb +67 -0
- data/lib/utcp/repository.rb +137 -0
- data/lib/utcp/serializer.rb +71 -0
- data/lib/utcp/utils.rb +118 -0
- data/lib/utcp/variables.rb +170 -0
- data/lib/utcp/version.rb +6 -0
- data/lib/utcp.rb +70 -0
- data/proto/utcp.proto +31 -0
- metadata +148 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module UTCP
|
|
10
|
+
module WebSocketURLSecurity
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def validate!(url)
|
|
14
|
+
uri = URI.parse(url.to_s)
|
|
15
|
+
raise SecurityError, "WebSocket URL must use ws or wss" unless %w[ws wss].include?(uri.scheme)
|
|
16
|
+
raise SecurityError, "WebSocket URL must contain a host" if uri.host.nil? || uri.host.empty?
|
|
17
|
+
raise SecurityError, "WebSocket URL must not contain user information" if uri.userinfo
|
|
18
|
+
if uri.scheme == "ws" && !URLSecurity.loopback_host?(uri.host)
|
|
19
|
+
raise SecurityError, "plain WebSocket is allowed only for loopback hosts"
|
|
20
|
+
end
|
|
21
|
+
uri
|
|
22
|
+
rescue URI::InvalidURIError => error
|
|
23
|
+
raise SecurityError, "Invalid WebSocket URL: #{error.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class WebSocketConnection
|
|
28
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
29
|
+
MAX_HEADER_SIZE = 65_536
|
|
30
|
+
MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
def initialize(url, headers = {}, protocol = nil, timeout = 30)
|
|
33
|
+
@uri = WebSocketURLSecurity.validate!(url)
|
|
34
|
+
@timeout = Float(timeout)
|
|
35
|
+
@read_buffer = +"".b
|
|
36
|
+
@closed = false
|
|
37
|
+
open_socket
|
|
38
|
+
handshake(headers, protocol)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def closed?
|
|
42
|
+
@closed || @socket.closed?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send_text(value)
|
|
46
|
+
send_frame(0x1, value.to_s.encode(Encoding::UTF_8))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def send_binary(value)
|
|
50
|
+
send_frame(0x2, value.to_s.b)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_message
|
|
54
|
+
message = +"".b
|
|
55
|
+
message_opcode = nil
|
|
56
|
+
loop do
|
|
57
|
+
fin, opcode, payload = read_frame
|
|
58
|
+
case opcode
|
|
59
|
+
when 0x0
|
|
60
|
+
raise ToolCallError, "unexpected WebSocket continuation frame" unless message_opcode
|
|
61
|
+
message << payload
|
|
62
|
+
when 0x1, 0x2
|
|
63
|
+
raise ToolCallError, "interleaved WebSocket data frame" if message_opcode
|
|
64
|
+
message_opcode = opcode
|
|
65
|
+
message << payload
|
|
66
|
+
when 0x8
|
|
67
|
+
send_frame(0x8, payload) unless @closed
|
|
68
|
+
@closed = true
|
|
69
|
+
return nil
|
|
70
|
+
when 0x9
|
|
71
|
+
send_frame(0xA, payload)
|
|
72
|
+
next
|
|
73
|
+
when 0xA
|
|
74
|
+
next
|
|
75
|
+
else
|
|
76
|
+
raise ToolCallError, "unsupported WebSocket opcode #{opcode}"
|
|
77
|
+
end
|
|
78
|
+
raise ToolCallError, "WebSocket message exceeds #{MAX_MESSAGE_SIZE} bytes" if message.bytesize > MAX_MESSAGE_SIZE
|
|
79
|
+
return [message_opcode, message] if fin
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def close
|
|
84
|
+
send_frame(0x8, [1000].pack("n")) unless closed?
|
|
85
|
+
rescue IOError, SystemCallError
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
@closed = true
|
|
89
|
+
@socket.close if @socket && !@socket.closed?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def open_socket
|
|
95
|
+
tcp = Socket.tcp(@uri.host, websocket_port, connect_timeout: @timeout)
|
|
96
|
+
if @uri.scheme == "wss"
|
|
97
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
98
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
99
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
100
|
+
ssl.hostname = @uri.host if ssl.respond_to?(:hostname=)
|
|
101
|
+
ssl.sync_close = true
|
|
102
|
+
ssl.connect
|
|
103
|
+
@socket = ssl
|
|
104
|
+
else
|
|
105
|
+
@socket = tcp
|
|
106
|
+
end
|
|
107
|
+
rescue StandardError
|
|
108
|
+
tcp.close if tcp && !tcp.closed?
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handshake(headers, protocol)
|
|
113
|
+
key = Base64.strict_encode64(Random.new.bytes(16))
|
|
114
|
+
path = websocket_request_target
|
|
115
|
+
host = @uri.host.include?(":") ? "[#{@uri.host}]" : @uri.host
|
|
116
|
+
default_port = @uri.scheme == "wss" ? 443 : 80
|
|
117
|
+
host = "#{host}:#{websocket_port}" unless websocket_port == default_port
|
|
118
|
+
values = {
|
|
119
|
+
"Host" => host,
|
|
120
|
+
"Upgrade" => "websocket",
|
|
121
|
+
"Connection" => "Upgrade",
|
|
122
|
+
"Sec-WebSocket-Key" => key,
|
|
123
|
+
"Sec-WebSocket-Version" => "13"
|
|
124
|
+
}
|
|
125
|
+
values["Sec-WebSocket-Protocol"] = protocol if protocol
|
|
126
|
+
reserved = %w[host upgrade connection sec-websocket-key sec-websocket-version sec-websocket-protocol]
|
|
127
|
+
Utils.stringify_keys(headers || {}).each do |name, value|
|
|
128
|
+
raise SecurityError, "WebSocket header #{name.inspect} is reserved" if reserved.include?(name.downcase)
|
|
129
|
+
|
|
130
|
+
values[name] = value.to_s
|
|
131
|
+
end
|
|
132
|
+
values.each do |name, value|
|
|
133
|
+
raise SecurityError, "WebSocket header contains CR/LF" if name.to_s.match?(/[\r\n]/) || value.match?(/[\r\n]/)
|
|
134
|
+
end
|
|
135
|
+
request = "GET #{path} HTTP/1.1\r\n" + values.map { |name, value| "#{name}: #{value}\r\n" }.join + "\r\n"
|
|
136
|
+
@socket.write(request)
|
|
137
|
+
header_text = read_headers
|
|
138
|
+
lines = header_text.split("\r\n")
|
|
139
|
+
status = lines.shift.to_s.split[1].to_i
|
|
140
|
+
raise ToolCallError, "WebSocket handshake failed with status #{status}" unless status == 101
|
|
141
|
+
|
|
142
|
+
response_headers = lines.each_with_object({}) do |line, result|
|
|
143
|
+
name, value = line.split(":", 2)
|
|
144
|
+
result[name.to_s.downcase] = value.to_s.strip
|
|
145
|
+
end
|
|
146
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
147
|
+
unless secure_compare(response_headers["sec-websocket-accept"].to_s, expected)
|
|
148
|
+
raise SecurityError, "WebSocket handshake returned an invalid Sec-WebSocket-Accept"
|
|
149
|
+
end
|
|
150
|
+
unless response_headers["upgrade"].to_s.casecmp?("websocket") &&
|
|
151
|
+
response_headers["connection"].to_s.downcase.split(/\s*,\s*/).include?("upgrade")
|
|
152
|
+
raise ToolCallError, "WebSocket handshake did not confirm the protocol upgrade"
|
|
153
|
+
end
|
|
154
|
+
if protocol && response_headers["sec-websocket-protocol"] != protocol
|
|
155
|
+
raise ToolCallError, "WebSocket server did not select the requested subprotocol"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def websocket_port
|
|
160
|
+
@uri.port || (@uri.scheme == "wss" ? 443 : 80)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def websocket_request_target
|
|
164
|
+
path = @uri.path.to_s
|
|
165
|
+
path = "/" if path.empty?
|
|
166
|
+
@uri.query ? "#{path}?#{@uri.query}" : path
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_headers
|
|
170
|
+
until (index = @read_buffer.index("\r\n\r\n"))
|
|
171
|
+
raise ToolCallError, "WebSocket handshake headers are too large" if @read_buffer.bytesize >= MAX_HEADER_SIZE
|
|
172
|
+
wait_readable
|
|
173
|
+
@read_buffer << @socket.readpartial(4096)
|
|
174
|
+
end
|
|
175
|
+
@read_buffer.slice!(0, index + 4).byteslice(0, index)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def send_frame(opcode, payload)
|
|
179
|
+
raise IOError, "WebSocket is closed" if closed?
|
|
180
|
+
|
|
181
|
+
bytes = payload.to_s.b
|
|
182
|
+
mask = Random.new.bytes(4)
|
|
183
|
+
header = [0x80 | opcode].pack("C")
|
|
184
|
+
header << if bytes.bytesize < 126
|
|
185
|
+
[0x80 | bytes.bytesize].pack("C")
|
|
186
|
+
elsif bytes.bytesize <= 65_535
|
|
187
|
+
[0x80 | 126, bytes.bytesize].pack("Cn")
|
|
188
|
+
else
|
|
189
|
+
[0x80 | 127, bytes.bytesize].pack("CQ>")
|
|
190
|
+
end
|
|
191
|
+
masked = bytes.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
|
|
192
|
+
@socket.write(header + mask + masked)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def read_frame
|
|
196
|
+
head = read_exact(2)
|
|
197
|
+
first, second = head.unpack("CC")
|
|
198
|
+
fin = (first & 0x80) != 0
|
|
199
|
+
opcode = first & 0x0F
|
|
200
|
+
masked = (second & 0x80) != 0
|
|
201
|
+
length = second & 0x7F
|
|
202
|
+
length = read_exact(2).unpack1("n") if length == 126
|
|
203
|
+
length = read_exact(8).unpack1("Q>") if length == 127
|
|
204
|
+
raise ToolCallError, "WebSocket frame exceeds #{MAX_MESSAGE_SIZE} bytes" if length > MAX_MESSAGE_SIZE
|
|
205
|
+
|
|
206
|
+
mask = masked ? read_exact(4) : nil
|
|
207
|
+
payload = read_exact(length)
|
|
208
|
+
if mask
|
|
209
|
+
payload = payload.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
|
|
210
|
+
end
|
|
211
|
+
[fin, opcode, payload]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def read_exact(length)
|
|
215
|
+
while @read_buffer.bytesize < length
|
|
216
|
+
wait_readable
|
|
217
|
+
@read_buffer << @socket.readpartial([4096, length - @read_buffer.bytesize].max)
|
|
218
|
+
end
|
|
219
|
+
@read_buffer.slice!(0, length)
|
|
220
|
+
rescue EOFError
|
|
221
|
+
@closed = true
|
|
222
|
+
raise ToolCallError, "WebSocket connection closed unexpectedly"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def wait_readable
|
|
226
|
+
raise TimeoutError, "WebSocket read timed out" unless IO.select([@socket], nil, nil, @timeout)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def secure_compare(first, second)
|
|
230
|
+
return false unless first.bytesize == second.bytesize
|
|
231
|
+
|
|
232
|
+
first.bytes.zip(second.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
class WebSocketProtocol < HTTPProtocol
|
|
237
|
+
ConnectionEntry = Struct.new(:connection, :mutex)
|
|
238
|
+
|
|
239
|
+
def initialize(connection_factory: nil, **options)
|
|
240
|
+
super(**options)
|
|
241
|
+
@connection_factory = connection_factory || lambda do |url, headers, protocol, timeout|
|
|
242
|
+
WebSocketConnection.new(url, headers, protocol, timeout)
|
|
243
|
+
end
|
|
244
|
+
@connections = {}
|
|
245
|
+
@connections_mutex = Mutex.new
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def register_manual(client, template)
|
|
249
|
+
assert_websocket_template!(template)
|
|
250
|
+
entry, transient = connection_for(template, {})
|
|
251
|
+
payload = entry.mutex.synchronize do
|
|
252
|
+
entry.connection.send_text(JSON.generate("type" => "utcp"))
|
|
253
|
+
_opcode, bytes = entry.connection.read_message
|
|
254
|
+
bytes
|
|
255
|
+
end
|
|
256
|
+
entry.connection.close if transient
|
|
257
|
+
success(template, manual_from_payload(template, payload, source: "WebSocket discovery response"))
|
|
258
|
+
rescue StandardError => error
|
|
259
|
+
client.logger.warn("Unable to register WebSocket manual #{template.name.inspect}: #{error.message}")
|
|
260
|
+
failure(template, error)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def deregister_manual(_client, template)
|
|
264
|
+
prefix = [template.url, template.protocol].join("\0") + "\0"
|
|
265
|
+
entries = @connections_mutex.synchronize do
|
|
266
|
+
keys = @connections.keys.select { |key| key.start_with?(prefix) }
|
|
267
|
+
keys.map { |key| @connections.delete(key) }
|
|
268
|
+
end
|
|
269
|
+
entries.each { |entry| entry.connection.close }
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
274
|
+
assert_websocket_template!(template)
|
|
275
|
+
args = Utils.stringify_keys(tool_args || {})
|
|
276
|
+
entry, transient, message_args = connection_for(template, args, include_arguments: true)
|
|
277
|
+
result = entry.mutex.synchronize do
|
|
278
|
+
message = format_message(template, message_args)
|
|
279
|
+
entry.connection.send_text(message)
|
|
280
|
+
frame = entry.connection.read_message
|
|
281
|
+
raise ToolCallError, "WebSocket closed without a response" unless frame
|
|
282
|
+
|
|
283
|
+
decode_message(frame[1], template.response_format, frame[0])
|
|
284
|
+
end
|
|
285
|
+
result
|
|
286
|
+
rescue Error
|
|
287
|
+
raise
|
|
288
|
+
rescue StandardError => error
|
|
289
|
+
raise ToolCallError.new("WebSocket tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
290
|
+
ensure
|
|
291
|
+
entry.connection.close if defined?(entry) && entry && defined?(transient) && transient
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
def assert_websocket_template!(template)
|
|
297
|
+
return if template.is_a?(WebSocketCallTemplate)
|
|
298
|
+
|
|
299
|
+
raise ValidationError, "WebSocket protocol requires a WebSocketCallTemplate"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def connection_for(template, arguments, include_arguments: false)
|
|
303
|
+
args = arguments.dup
|
|
304
|
+
headers = Utils.stringify_keys(template.headers || {})
|
|
305
|
+
template.header_fields.each { |field| headers[field] = args.delete(field).to_s if args.key?(field) }
|
|
306
|
+
query = {}
|
|
307
|
+
cookies = {}
|
|
308
|
+
apply_auth(template.auth, headers, query, cookies)
|
|
309
|
+
if template.auth.is_a?(OAuth2Auth)
|
|
310
|
+
headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
|
|
311
|
+
end
|
|
312
|
+
headers["Cookie"] = cookies.map { |key, value| "#{key}=#{value}" }.join("; ") unless cookies.empty?
|
|
313
|
+
url = append_query(template.url, query)
|
|
314
|
+
WebSocketURLSecurity.validate!(url)
|
|
315
|
+
key = connection_key(template, url, headers)
|
|
316
|
+
if template.keep_alive
|
|
317
|
+
entry = @connections_mutex.synchronize do
|
|
318
|
+
current = @connections[key]
|
|
319
|
+
if current.nil? || current.connection.closed?
|
|
320
|
+
current = ConnectionEntry.new(@connection_factory.call(url, headers, template.protocol, template.timeout), Mutex.new)
|
|
321
|
+
@connections[key] = current
|
|
322
|
+
end
|
|
323
|
+
current
|
|
324
|
+
end
|
|
325
|
+
include_arguments ? [entry, false, args] : [entry, false]
|
|
326
|
+
else
|
|
327
|
+
entry = ConnectionEntry.new(@connection_factory.call(url, headers, template.protocol, template.timeout), Mutex.new)
|
|
328
|
+
include_arguments ? [entry, true, args] : [entry, true]
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def connection_key(template, url = template.url, headers = template.headers)
|
|
333
|
+
[url, template.protocol, JSON.generate(Utils.stringify_keys(headers || {}).sort)].join("\0")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def format_message(template, arguments)
|
|
337
|
+
value = template.message.nil? ? arguments : substitute_message_template(template.message, arguments)
|
|
338
|
+
value.is_a?(String) ? value : JSON.generate(value)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def decode_message(bytes, response_format, opcode)
|
|
342
|
+
return bytes.b if response_format == "raw" || opcode == 0x2
|
|
343
|
+
|
|
344
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
345
|
+
response_format == "text" ? text : decode_json_or_text(text)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
WebsocketCommunicationProtocol = WebSocketProtocol
|
|
349
|
+
WebSocketCommunicationProtocol = WebSocketProtocol
|
|
350
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module UTCP
|
|
6
|
+
@registry_mutex = Mutex.new
|
|
7
|
+
@call_template_classes = {}
|
|
8
|
+
@auth_classes = {}
|
|
9
|
+
@protocols = {}
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def register_call_template(type, klass)
|
|
13
|
+
validate_plugin_type!(type)
|
|
14
|
+
unless klass.respond_to?(:new)
|
|
15
|
+
raise ArgumentError, "call template implementation must be a class-like object"
|
|
16
|
+
end
|
|
17
|
+
@registry_mutex.synchronize { @call_template_classes[type.to_s] = klass }
|
|
18
|
+
klass
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call_template_class(type)
|
|
22
|
+
@registry_mutex.synchronize { @call_template_classes[type.to_s] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call_template_types
|
|
26
|
+
@registry_mutex.synchronize { @call_template_classes.keys.sort }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register_auth(type, klass)
|
|
30
|
+
validate_plugin_type!(type)
|
|
31
|
+
@registry_mutex.synchronize { @auth_classes[type.to_s] = klass }
|
|
32
|
+
klass
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def auth_class(type)
|
|
36
|
+
@registry_mutex.synchronize { @auth_classes[type.to_s] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def register_protocol(type, implementation)
|
|
40
|
+
validate_plugin_type!(type)
|
|
41
|
+
protocol = implementation.is_a?(Class) ? implementation.new : implementation
|
|
42
|
+
required = %i[register_manual deregister_manual call_tool]
|
|
43
|
+
missing = required.reject { |method_name| protocol.respond_to?(method_name) }
|
|
44
|
+
raise ArgumentError, "protocol is missing: #{missing.join(', ')}" unless missing.empty?
|
|
45
|
+
|
|
46
|
+
@registry_mutex.synchronize { @protocols[type.to_s] = protocol }
|
|
47
|
+
protocol
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def protocol(type)
|
|
51
|
+
@registry_mutex.synchronize { @protocols[type.to_s] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def protocol_types
|
|
55
|
+
@registry_mutex.synchronize { @protocols.keys.sort }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_plugin_type!(type)
|
|
61
|
+
unless type.to_s.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
62
|
+
raise ArgumentError, "plugin type must contain only letters, numbers, and underscores"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class InMemoryToolRepository
|
|
5
|
+
attr_reader :tool_repository_type
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@tool_repository_type = "in_memory"
|
|
9
|
+
@tools = {}
|
|
10
|
+
@manuals = {}
|
|
11
|
+
@templates = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save_manual(manual_call_template, manual)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
old_manual = @manuals[manual_call_template.name]
|
|
18
|
+
old_manual&.tools&.each { |tool| @tools.delete(tool.name) }
|
|
19
|
+
|
|
20
|
+
@templates[manual_call_template.name] = manual_call_template
|
|
21
|
+
@manuals[manual_call_template.name] = manual
|
|
22
|
+
manual.tools.each { |tool| @tools[tool.name] = tool }
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove_manual(manual_name)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
manual = @manuals.delete(manual_name.to_s)
|
|
30
|
+
return false unless manual
|
|
31
|
+
|
|
32
|
+
manual.tools.each { |tool| @tools.delete(tool.name) }
|
|
33
|
+
@templates.delete(manual_name.to_s)
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remove_tool(tool_name)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
tool = @tools.delete(tool_name.to_s)
|
|
41
|
+
return false unless tool
|
|
42
|
+
|
|
43
|
+
@manuals.each_value { |manual| manual.tools.delete_if { |candidate| candidate.name == tool.name } }
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_tool(tool_name)
|
|
49
|
+
@mutex.synchronize { @tools[tool_name.to_s] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get_tools
|
|
53
|
+
@mutex.synchronize { @tools.values.dup }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_tools_by_manual(manual_name)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
manual = @manuals[manual_name.to_s]
|
|
59
|
+
manual&.tools&.dup
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_manual(manual_name)
|
|
64
|
+
@mutex.synchronize { @manuals[manual_name.to_s] }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get_manuals
|
|
68
|
+
@mutex.synchronize { @manuals.values.dup }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def get_manual_call_template(manual_name)
|
|
72
|
+
@mutex.synchronize { @templates[manual_name.to_s] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def get_manual_call_templates
|
|
76
|
+
@mutex.synchronize { @templates.values.dup }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
{ "tool_repository_type" => tool_repository_type }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
InMemToolRepository = InMemoryToolRepository
|
|
84
|
+
|
|
85
|
+
class TagSearchStrategy
|
|
86
|
+
attr_reader :tool_search_strategy_type, :description_weight, :tag_weight
|
|
87
|
+
|
|
88
|
+
def initialize(description_weight: 1, tag_weight: 3)
|
|
89
|
+
@tool_search_strategy_type = "tag_and_description_word_match"
|
|
90
|
+
@description_weight = Float(description_weight)
|
|
91
|
+
@tag_weight = Float(tag_weight)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def search_tools(tool_repository:, query:, limit: 10, any_of_tags_required: nil)
|
|
95
|
+
raise ArgumentError, "limit must be non-negative" if limit.negative?
|
|
96
|
+
|
|
97
|
+
query_text = query.to_s.downcase
|
|
98
|
+
query_words = query_text.scan(/[[:alnum:]_]+/).uniq
|
|
99
|
+
required_tags = Array(any_of_tags_required).map { |tag| tag.to_s.downcase }
|
|
100
|
+
tools = tool_repository.get_tools
|
|
101
|
+
unless required_tags.empty?
|
|
102
|
+
tools = tools.select do |tool|
|
|
103
|
+
(tool.tags.map(&:downcase) & required_tags).any?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
scored = tools.each_with_index.map do |tool, index|
|
|
108
|
+
score = tool.tags.sum do |tag|
|
|
109
|
+
normalized = tag.downcase
|
|
110
|
+
if query_text.include?(normalized) || (normalized.scan(/[[:alnum:]_]+/) & query_words).any?
|
|
111
|
+
tag_weight
|
|
112
|
+
else
|
|
113
|
+
0
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
description_words = tool.description.downcase.scan(/[[:alnum:]_]+/).uniq
|
|
117
|
+
score += (description_words & query_words).count { |word| word.length > 2 } * description_weight
|
|
118
|
+
name_words = tool.name.downcase.scan(/[[:alnum:]_]+/).uniq
|
|
119
|
+
score += (name_words & query_words).length * description_weight
|
|
120
|
+
[tool, score, index]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
results = scored.sort_by { |_tool, score, index| [-score, index] }.map(&:first)
|
|
124
|
+
limit.zero? ? results : results.first(limit)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def to_h
|
|
128
|
+
{
|
|
129
|
+
"tool_search_strategy_type" => tool_search_strategy_type,
|
|
130
|
+
"description_weight" => description_weight,
|
|
131
|
+
"tag_weight" => tag_weight
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
TagAndDescriptionWordMatchStrategy = TagSearchStrategy
|
|
136
|
+
end
|
|
137
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class Serializer
|
|
5
|
+
def initialize(model_class)
|
|
6
|
+
@model_class = model_class
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_dict(object)
|
|
10
|
+
unless object.respond_to?(:to_h)
|
|
11
|
+
raise SerializerValidationError, "#{object.class} cannot be serialized"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
object.to_h
|
|
15
|
+
end
|
|
16
|
+
alias to_h to_dict
|
|
17
|
+
|
|
18
|
+
def validate_dict(value)
|
|
19
|
+
@model_class.from_h(value)
|
|
20
|
+
rescue Error
|
|
21
|
+
raise
|
|
22
|
+
rescue StandardError => error
|
|
23
|
+
raise SerializerValidationError, error.message
|
|
24
|
+
end
|
|
25
|
+
alias from_h validate_dict
|
|
26
|
+
|
|
27
|
+
def copy(object)
|
|
28
|
+
validate_dict(to_dict(object))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class AuthSerializer < Serializer
|
|
33
|
+
def initialize
|
|
34
|
+
super(Auth)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class CallTemplateSerializer < Serializer
|
|
39
|
+
def initialize
|
|
40
|
+
super(CallTemplate)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class JsonSchemaSerializer < Serializer
|
|
45
|
+
def initialize
|
|
46
|
+
super(JsonSchema)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
JSONSchemaSerializer = JsonSchemaSerializer
|
|
50
|
+
|
|
51
|
+
class ToolSerializer < Serializer
|
|
52
|
+
def initialize
|
|
53
|
+
super(Tool)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class ManualSerializer < Serializer
|
|
58
|
+
def initialize
|
|
59
|
+
super(Manual)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
UtcpManualSerializer = ManualSerializer
|
|
63
|
+
|
|
64
|
+
class ClientConfigSerializer < Serializer
|
|
65
|
+
def initialize
|
|
66
|
+
super(ClientConfig)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
UtcpClientConfigSerializer = ClientConfigSerializer
|
|
70
|
+
end
|
|
71
|
+
|