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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +22 -0
  4. data/Makefile +226 -0
  5. data/README.md +331 -0
  6. data/examples/basic.rb +33 -0
  7. data/examples/cli.rb +32 -0
  8. data/examples/generated/__init__.py +1 -0
  9. data/examples/generated/utcp_pb2.py +46 -0
  10. data/examples/generated/utcp_pb2_grpc.py +183 -0
  11. data/examples/graphql.rb +15 -0
  12. data/examples/grpc.rb +42 -0
  13. data/examples/grpc_python.py +52 -0
  14. data/examples/http.rb +17 -0
  15. data/examples/mcp.rb +28 -0
  16. data/examples/servers/graphql_server.rb +39 -0
  17. data/examples/servers/grpc_server.py +97 -0
  18. data/examples/servers/grpc_server.rb +62 -0
  19. data/examples/servers/http_helpers.rb +34 -0
  20. data/examples/servers/http_server.rb +28 -0
  21. data/examples/servers/mcp_stdio_server.rb +43 -0
  22. data/examples/servers/requirements-grpc.txt +2 -0
  23. data/examples/servers/sse_server.rb +36 -0
  24. data/examples/servers/streamable_http_server.rb +39 -0
  25. data/examples/servers/tcp_server.rb +58 -0
  26. data/examples/servers/udp_server.rb +33 -0
  27. data/examples/servers/webrtc_server.rb +78 -0
  28. data/examples/servers/websocket_server.rb +92 -0
  29. data/examples/sse.rb +16 -0
  30. data/examples/streamable_http.rb +17 -0
  31. data/examples/tcp.rb +20 -0
  32. data/examples/text.rb +23 -0
  33. data/examples/udp.rb +18 -0
  34. data/examples/webrtc.rb +19 -0
  35. data/examples/websocket.rb +17 -0
  36. data/lib/ruby-utcp.rb +4 -0
  37. data/lib/utcp/client.rb +217 -0
  38. data/lib/utcp/config.rb +79 -0
  39. data/lib/utcp/errors.rb +48 -0
  40. data/lib/utcp/migration.rb +88 -0
  41. data/lib/utcp/models.rb +794 -0
  42. data/lib/utcp/openapi_converter.rb +179 -0
  43. data/lib/utcp/protocols/base.rb +97 -0
  44. data/lib/utcp/protocols/cli.rb +186 -0
  45. data/lib/utcp/protocols/file.rb +52 -0
  46. data/lib/utcp/protocols/graphql.rb +277 -0
  47. data/lib/utcp/protocols/grpc.rb +207 -0
  48. data/lib/utcp/protocols/http.rb +340 -0
  49. data/lib/utcp/protocols/http_stream_support.rb +122 -0
  50. data/lib/utcp/protocols/mcp.rb +339 -0
  51. data/lib/utcp/protocols/socket_support.rb +51 -0
  52. data/lib/utcp/protocols/sse.rb +107 -0
  53. data/lib/utcp/protocols/streamable_http.rb +78 -0
  54. data/lib/utcp/protocols/tcp.rb +143 -0
  55. data/lib/utcp/protocols/text.rb +44 -0
  56. data/lib/utcp/protocols/udp.rb +61 -0
  57. data/lib/utcp/protocols/webrtc.rb +217 -0
  58. data/lib/utcp/protocols/websocket.rb +350 -0
  59. data/lib/utcp/registry.rb +67 -0
  60. data/lib/utcp/repository.rb +137 -0
  61. data/lib/utcp/serializer.rb +71 -0
  62. data/lib/utcp/utils.rb +118 -0
  63. data/lib/utcp/variables.rb +170 -0
  64. data/lib/utcp/version.rb +6 -0
  65. data/lib/utcp.rb +70 -0
  66. data/proto/utcp.proto +31 -0
  67. metadata +148 -0
@@ -0,0 +1,794 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "version"
5
+ require_relative "errors"
6
+ require_relative "utils"
7
+
8
+ module UTCP
9
+ class JsonSchema
10
+ include ModelSerialization
11
+ COMMON_FIELDS = %w[
12
+ $schema $id title description type properties items required enum const default
13
+ examples format additionalProperties pattern minimum maximum minLength maxLength
14
+ ].freeze
15
+
16
+ def self.from_h(value)
17
+ return value if value.is_a?(self)
18
+
19
+ new(Utils.hash!(value || {}, "JSON Schema"))
20
+ end
21
+
22
+ def initialize(value = nil, **keywords)
23
+ source = value || keywords
24
+ @data = Utils.stringify_keys(Utils.hash!(source, "JSON Schema"))
25
+ end
26
+
27
+ def [](key)
28
+ @data[schema_key(key)]
29
+ end
30
+
31
+ def []=(key, value)
32
+ @data[schema_key(key)] = value
33
+ end
34
+
35
+ def to_h
36
+ Utils.deep_copy(@data)
37
+ end
38
+
39
+ def method_missing(name, *arguments)
40
+ key = name.to_s
41
+ return @data[schema_key(key)] if arguments.empty? && schema_field?(key)
42
+ return @data[schema_key(key[0..-2])] = arguments.first if key.end_with?("=") && arguments.length == 1
43
+
44
+ super
45
+ end
46
+
47
+ def respond_to_missing?(name, include_private = false)
48
+ key = name.to_s.sub(/=$/, "")
49
+ schema_field?(key) || super
50
+ end
51
+
52
+ private
53
+
54
+ def schema_key(key)
55
+ { "schema_" => "$schema", "id_" => "$id" }.fetch(key.to_s, key.to_s)
56
+ end
57
+
58
+ def schema_field?(key)
59
+ actual = schema_key(key)
60
+ @data.key?(actual) || COMMON_FIELDS.include?(actual)
61
+ end
62
+ end
63
+ JSONSchema = JsonSchema
64
+
65
+ class Auth
66
+ include ModelSerialization
67
+ attr_accessor :auth_type
68
+
69
+ def self.from_h(value)
70
+ return value if value.is_a?(Auth)
71
+
72
+ data = Utils.stringify_keys(Utils.hash!(value, "auth"))
73
+ type = Utils.required_string!(data["auth_type"], "auth.auth_type")
74
+ klass = UTCP.auth_class(type)
75
+ raise ValidationError.new("unsupported authentication type #{type.inspect}", path: "auth.auth_type") unless klass
76
+
77
+ klass.new(**Utils.symbolize_keys(data))
78
+ rescue ValidationError
79
+ raise
80
+ rescue StandardError => error
81
+ raise SerializerValidationError, "Invalid auth: #{error.message}"
82
+ end
83
+
84
+ def initialize(auth_type:, **extra)
85
+ @auth_type = Utils.required_string!(auth_type, "auth_type")
86
+ @extra = Utils.stringify_keys(extra)
87
+ end
88
+
89
+ def to_h
90
+ { "auth_type" => auth_type }.merge(Utils.deep_copy(@extra))
91
+ end
92
+
93
+ def [](key)
94
+ @extra[key.to_s]
95
+ end
96
+ end
97
+
98
+ class ApiKeyAuth < Auth
99
+ LOCATIONS = %w[header query cookie].freeze
100
+ attr_accessor :api_key, :var_name, :location
101
+
102
+ def initialize(api_key:, auth_type: "api_key", var_name: "X-Api-Key", location: "header", **extra)
103
+ super(auth_type: auth_type, **extra)
104
+ @api_key = Utils.required_string!(api_key, "api_key")
105
+ @var_name = Utils.required_string!(var_name, "var_name")
106
+ @location = location.to_s
107
+ raise ValidationError.new("must be header, query, or cookie", path: "location") unless LOCATIONS.include?(@location)
108
+ end
109
+
110
+ def to_h
111
+ super.merge("api_key" => api_key, "var_name" => var_name, "location" => location)
112
+ end
113
+ end
114
+
115
+ class BasicAuth < Auth
116
+ attr_accessor :username, :password
117
+
118
+ def initialize(username:, password:, auth_type: "basic", **extra)
119
+ super(auth_type: auth_type, **extra)
120
+ @username = Utils.required_string!(username, "username")
121
+ @password = Utils.required_string!(password, "password")
122
+ end
123
+
124
+ def to_h
125
+ super.merge("username" => username, "password" => password)
126
+ end
127
+ end
128
+
129
+ class OAuth2Auth < Auth
130
+ attr_accessor :token_url, :client_id, :client_secret, :scope
131
+
132
+ def initialize(token_url:, client_id:, client_secret:, auth_type: "oauth2", scope: nil, **extra)
133
+ super(auth_type: auth_type, **extra)
134
+ @token_url = Utils.required_string!(token_url, "token_url")
135
+ @client_id = Utils.required_string!(client_id, "client_id")
136
+ @client_secret = Utils.required_string!(client_secret, "client_secret")
137
+ @scope = Utils.optional_string!(scope, "scope")
138
+ end
139
+
140
+ def to_h
141
+ Utils.compact_hash(super.merge(
142
+ "token_url" => token_url,
143
+ "client_id" => client_id,
144
+ "client_secret" => client_secret,
145
+ "scope" => scope
146
+ ))
147
+ end
148
+ end
149
+
150
+ class CallTemplate
151
+ include ModelSerialization
152
+ attr_accessor :name, :call_template_type, :auth, :allowed_communication_protocols
153
+
154
+ def self.from_h(value)
155
+ return value if value.is_a?(CallTemplate)
156
+
157
+ data = Utils.stringify_keys(Utils.hash!(value, "call template"))
158
+ type = Utils.required_string!(data["call_template_type"], "call_template_type")
159
+ klass = UTCP.call_template_class(type)
160
+ raise ValidationError.new("unsupported call template type #{type.inspect}", path: "call_template_type") unless klass
161
+
162
+ klass.new(**Utils.symbolize_keys(data))
163
+ rescue ValidationError
164
+ raise
165
+ rescue StandardError => error
166
+ raise SerializerValidationError, "Invalid call template: #{error.message}"
167
+ end
168
+
169
+ def initialize(call_template_type:, name: nil, auth: nil, allowed_communication_protocols: nil, **extra)
170
+ @name = name.nil? || name.to_s.empty? ? SecureRandom.hex(16) : name.to_s
171
+ @call_template_type = Utils.required_string!(call_template_type.to_s, "call_template_type")
172
+ @auth = auth.nil? ? nil : Auth.from_h(auth)
173
+ @allowed_communication_protocols = normalize_protocols(allowed_communication_protocols)
174
+ @extra = Utils.stringify_keys(extra)
175
+ end
176
+
177
+ def allowed_protocols
178
+ protocols = allowed_communication_protocols
179
+ protocols.nil? || protocols.empty? ? [call_template_type] : protocols.dup
180
+ end
181
+
182
+ def to_h
183
+ Utils.compact_hash({
184
+ "name" => name,
185
+ "call_template_type" => call_template_type,
186
+ "auth" => auth&.to_h,
187
+ "allowed_communication_protocols" => allowed_communication_protocols&.dup
188
+ }.merge(Utils.deep_copy(@extra)))
189
+ end
190
+
191
+ def [](key)
192
+ @extra[key.to_s]
193
+ end
194
+
195
+ def method_missing(name, *arguments)
196
+ key = name.to_s
197
+ return @extra[key] if arguments.empty? && @extra.key?(key)
198
+ return @extra[key[0..-2]] = arguments.first if key.end_with?("=") && arguments.length == 1
199
+
200
+ super
201
+ end
202
+
203
+ def respond_to_missing?(name, include_private = false)
204
+ @extra.key?(name.to_s.sub(/=$/, "")) || super
205
+ end
206
+
207
+ private
208
+
209
+ def normalize_protocols(value)
210
+ return nil if value.nil?
211
+
212
+ protocols = Utils.array!(value, "allowed_communication_protocols").map(&:to_s)
213
+ if protocols.any?(&:empty?)
214
+ raise ValidationError.new("must contain non-empty protocol names", path: "allowed_communication_protocols")
215
+ end
216
+ protocols.uniq
217
+ end
218
+ end
219
+
220
+ class HttpCallTemplate < CallTemplate
221
+ METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS].freeze
222
+ attr_accessor :http_method, :url, :content_type, :auth_tools, :headers, :body_field,
223
+ :header_fields, :timeout
224
+
225
+ def initialize(url:, call_template_type: "http", http_method: "GET", content_type: "application/json",
226
+ auth_tools: nil, headers: nil, body_field: "body", header_fields: nil,
227
+ timeout: nil, **common)
228
+ super(call_template_type: call_template_type, **common)
229
+ @url = Utils.required_string!(url, "url")
230
+ @http_method = http_method.to_s.upcase
231
+ raise ValidationError.new("unsupported HTTP method #{@http_method}", path: "http_method") unless METHODS.include?(@http_method)
232
+
233
+ @content_type = Utils.required_string!(content_type, "content_type")
234
+ @auth_tools = auth_tools.nil? ? nil : Auth.from_h(auth_tools)
235
+ @headers = headers.nil? ? {} : Utils.stringify_keys(Utils.hash!(headers, "headers"))
236
+ @body_field = body_field.nil? ? nil : body_field.to_s
237
+ @header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
238
+ @timeout = timeout.nil? ? nil : Float(timeout)
239
+ end
240
+
241
+ def to_h
242
+ Utils.compact_hash(super.merge(
243
+ "http_method" => http_method,
244
+ "url" => url,
245
+ "content_type" => content_type,
246
+ "auth_tools" => auth_tools&.to_h,
247
+ "headers" => headers.empty? ? nil : Utils.deep_copy(headers),
248
+ "body_field" => body_field,
249
+ "header_fields" => header_fields.empty? ? nil : header_fields.dup,
250
+ "timeout" => timeout
251
+ ))
252
+ end
253
+ end
254
+
255
+ class SseCallTemplate < CallTemplate
256
+ attr_accessor :url, :event_type, :reconnect, :retry_timeout, :headers, :body_field,
257
+ :header_fields, :timeout
258
+
259
+ def initialize(url:, call_template_type: "sse", event_type: nil, reconnect: true,
260
+ retry_timeout: 30_000, headers: nil, body_field: nil,
261
+ header_fields: nil, timeout: nil, **common)
262
+ super(call_template_type: call_template_type, **common)
263
+ @url = Utils.required_string!(url, "url")
264
+ @event_type = Utils.optional_string!(event_type, "event_type")
265
+ @reconnect = !!reconnect
266
+ @retry_timeout = Integer(retry_timeout)
267
+ @headers = headers.nil? ? {} : Utils.stringify_keys(Utils.hash!(headers, "headers"))
268
+ @body_field = Utils.optional_string!(body_field, "body_field")
269
+ @header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
270
+ @timeout = timeout.nil? ? nil : Float(timeout)
271
+ raise ValidationError.new("must not be negative", path: "retry_timeout") if @retry_timeout.negative?
272
+ raise ValidationError.new("must be greater than zero", path: "timeout") if @timeout && !@timeout.positive?
273
+ end
274
+
275
+ def to_h
276
+ Utils.compact_hash(super.merge(
277
+ "url" => url,
278
+ "event_type" => event_type,
279
+ "reconnect" => reconnect,
280
+ "retry_timeout" => retry_timeout,
281
+ "headers" => headers.empty? ? nil : Utils.deep_copy(headers),
282
+ "body_field" => body_field,
283
+ "header_fields" => header_fields.empty? ? nil : header_fields.dup,
284
+ "timeout" => timeout
285
+ ))
286
+ end
287
+ end
288
+ SSECallTemplate = SseCallTemplate
289
+
290
+ class StreamableHttpCallTemplate < CallTemplate
291
+ METHODS = %w[GET POST].freeze
292
+ attr_accessor :url, :http_method, :content_type, :chunk_size, :timeout, :headers,
293
+ :body_field, :header_fields
294
+
295
+ def initialize(url:, call_template_type: "streamable_http", http_method: "GET",
296
+ content_type: "application/octet-stream", chunk_size: 4096,
297
+ timeout: 60_000, headers: nil, body_field: nil,
298
+ header_fields: nil, **common)
299
+ super(call_template_type: call_template_type, **common)
300
+ @url = Utils.required_string!(url, "url")
301
+ @http_method = http_method.to_s.upcase
302
+ raise ValidationError.new("must be GET or POST", path: "http_method") unless METHODS.include?(@http_method)
303
+
304
+ @content_type = Utils.required_string!(content_type, "content_type")
305
+ @chunk_size = Integer(chunk_size)
306
+ @timeout = Integer(timeout)
307
+ @headers = headers.nil? ? {} : Utils.stringify_keys(Utils.hash!(headers, "headers"))
308
+ @body_field = Utils.optional_string!(body_field, "body_field")
309
+ @header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
310
+ raise ValidationError.new("must be greater than zero", path: "chunk_size") unless @chunk_size.positive?
311
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
312
+ end
313
+
314
+ def to_h
315
+ Utils.compact_hash(super.merge(
316
+ "url" => url,
317
+ "http_method" => http_method,
318
+ "content_type" => content_type,
319
+ "chunk_size" => chunk_size,
320
+ "timeout" => timeout,
321
+ "headers" => headers.empty? ? nil : Utils.deep_copy(headers),
322
+ "body_field" => body_field,
323
+ "header_fields" => header_fields.empty? ? nil : header_fields.dup
324
+ ))
325
+ end
326
+ end
327
+ StreamableHTTPCallTemplate = StreamableHttpCallTemplate
328
+
329
+ class WebSocketCallTemplate < CallTemplate
330
+ RESPONSE_FORMATS = %w[json text raw].freeze
331
+ attr_accessor :url, :message, :protocol, :keep_alive, :response_format, :timeout,
332
+ :headers, :header_fields
333
+
334
+ def initialize(url:, call_template_type: "websocket", message: nil, protocol: nil,
335
+ keep_alive: true, response_format: nil, timeout: 30,
336
+ headers: nil, header_fields: nil, **common)
337
+ super(call_template_type: call_template_type, **common)
338
+ @url = Utils.required_string!(url, "url")
339
+ @message = Utils.deep_copy(message)
340
+ @protocol = Utils.optional_string!(protocol, "protocol")
341
+ @keep_alive = !!keep_alive
342
+ @response_format = Utils.optional_string!(response_format, "response_format")
343
+ if @response_format && !RESPONSE_FORMATS.include?(@response_format)
344
+ raise ValidationError.new("must be json, text, or raw", path: "response_format")
345
+ end
346
+ @timeout = Float(timeout)
347
+ @headers = headers.nil? ? {} : Utils.stringify_keys(Utils.hash!(headers, "headers"))
348
+ @header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
349
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
350
+ end
351
+
352
+ def to_h
353
+ Utils.compact_hash(super.merge(
354
+ "url" => url,
355
+ "message" => Utils.deep_copy(message),
356
+ "protocol" => protocol,
357
+ "keep_alive" => keep_alive,
358
+ "response_format" => response_format,
359
+ "timeout" => timeout,
360
+ "headers" => headers.empty? ? nil : Utils.deep_copy(headers),
361
+ "header_fields" => header_fields.empty? ? nil : header_fields.dup
362
+ ))
363
+ end
364
+ end
365
+ WebsocketCallTemplate = WebSocketCallTemplate
366
+
367
+ class GrpcCallTemplate < CallTemplate
368
+ attr_accessor :host, :port, :service_name, :method_name, :target, :use_ssl,
369
+ :timeout, :metadata
370
+
371
+ def initialize(host:, port:, call_template_type: "grpc", service_name: "grpcpb.UTCPService",
372
+ method_name: nil, target: nil, use_ssl: true, timeout: 30,
373
+ metadata: nil, **common)
374
+ super(call_template_type: call_template_type, **common)
375
+ @host = Utils.required_string!(host, "host")
376
+ @port = Integer(port)
377
+ @service_name = Utils.required_string!(service_name, "service_name")
378
+ @method_name = Utils.optional_string!(method_name, "method_name")
379
+ @target = Utils.optional_string!(target, "target")
380
+ @use_ssl = !!use_ssl
381
+ @timeout = Float(timeout)
382
+ @metadata = metadata.nil? ? {} : Utils.stringify_keys(Utils.hash!(metadata, "metadata"))
383
+ validate_network_values!
384
+ end
385
+
386
+ def to_h
387
+ Utils.compact_hash(super.merge(
388
+ "host" => host,
389
+ "port" => port,
390
+ "service_name" => service_name,
391
+ "method_name" => method_name,
392
+ "target" => target,
393
+ "use_ssl" => use_ssl,
394
+ "timeout" => timeout,
395
+ "metadata" => metadata.empty? ? nil : Utils.deep_copy(metadata)
396
+ ))
397
+ end
398
+
399
+ private
400
+
401
+ def validate_network_values!
402
+ raise ValidationError.new("must be between 1 and 65535", path: "port") unless (1..65_535).cover?(@port)
403
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
404
+ end
405
+ end
406
+ GRPCCallTemplate = GrpcCallTemplate
407
+
408
+ class GraphQLCallTemplate < CallTemplate
409
+ OPERATION_TYPES = %w[query mutation subscription].freeze
410
+ attr_accessor :url, :operation_type, :operation_name, :headers, :header_fields,
411
+ :query, :variable_types, :selection_set, :timeout
412
+
413
+ def initialize(url:, call_template_type: "graphql", operation_type: "query",
414
+ operation_name: nil, headers: nil, header_fields: nil, query: nil,
415
+ variable_types: nil, selection_set: nil, timeout: 30, **common)
416
+ super(call_template_type: call_template_type, **common)
417
+ @url = Utils.required_string!(url, "url")
418
+ @operation_type = operation_type.to_s
419
+ unless OPERATION_TYPES.include?(@operation_type)
420
+ raise ValidationError.new("must be query, mutation, or subscription", path: "operation_type")
421
+ end
422
+ @operation_name = Utils.optional_string!(operation_name, "operation_name")
423
+ @headers = headers.nil? ? {} : Utils.stringify_keys(Utils.hash!(headers, "headers"))
424
+ @header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
425
+ @query = Utils.optional_string!(query, "query")
426
+ @variable_types = variable_types.nil? ? {} : Utils.stringify_keys(Utils.hash!(variable_types, "variable_types"))
427
+ @selection_set = Utils.optional_string!(selection_set, "selection_set")
428
+ @timeout = Float(timeout)
429
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
430
+ end
431
+
432
+ def to_h
433
+ Utils.compact_hash(super.merge(
434
+ "url" => url,
435
+ "operation_type" => operation_type,
436
+ "operation_name" => operation_name,
437
+ "headers" => headers.empty? ? nil : Utils.deep_copy(headers),
438
+ "header_fields" => header_fields.empty? ? nil : header_fields.dup,
439
+ "query" => query,
440
+ "variable_types" => variable_types.empty? ? nil : Utils.deep_copy(variable_types),
441
+ "selection_set" => selection_set,
442
+ "timeout" => timeout
443
+ ))
444
+ end
445
+ end
446
+ GraphqlCallTemplate = GraphQLCallTemplate
447
+
448
+ class TcpCallTemplate < CallTemplate
449
+ FORMATS = %w[json text].freeze
450
+ FRAMING = %w[length_prefix delimiter fixed_length stream].freeze
451
+ attr_accessor :host, :port, :request_data_format, :request_data_template,
452
+ :response_byte_format, :framing_strategy, :length_prefix_bytes,
453
+ :length_prefix_endian, :message_delimiter, :interpret_escape_sequences,
454
+ :fixed_message_length, :max_response_size, :timeout
455
+
456
+ def initialize(host:, port:, call_template_type: "tcp", request_data_format: "json",
457
+ request_data_template: nil, response_byte_format: "utf-8",
458
+ framing_strategy: "stream", length_prefix_bytes: 4,
459
+ length_prefix_endian: "big", message_delimiter: "\\x00",
460
+ interpret_escape_sequences: true, fixed_message_length: nil,
461
+ max_response_size: 65_536, timeout: 30_000, **common)
462
+ super(call_template_type: call_template_type, auth: nil, **common)
463
+ @host = Utils.required_string!(host, "host")
464
+ @port = Integer(port)
465
+ @request_data_format = request_data_format.to_s
466
+ @request_data_template = Utils.optional_string!(request_data_template, "request_data_template")
467
+ @response_byte_format = response_byte_format.nil? ? nil : response_byte_format.to_s
468
+ @framing_strategy = framing_strategy.to_s
469
+ @length_prefix_bytes = Integer(length_prefix_bytes)
470
+ @length_prefix_endian = length_prefix_endian.to_s
471
+ @message_delimiter = message_delimiter.to_s
472
+ @interpret_escape_sequences = !!interpret_escape_sequences
473
+ @fixed_message_length = fixed_message_length.nil? ? nil : Integer(fixed_message_length)
474
+ @max_response_size = Integer(max_response_size)
475
+ @timeout = Integer(timeout)
476
+ validate_socket_values!
477
+ end
478
+
479
+ def to_h
480
+ Utils.compact_hash(super.merge(
481
+ "host" => host,
482
+ "port" => port,
483
+ "request_data_format" => request_data_format,
484
+ "request_data_template" => request_data_template,
485
+ "response_byte_format" => response_byte_format,
486
+ "framing_strategy" => framing_strategy,
487
+ "length_prefix_bytes" => length_prefix_bytes,
488
+ "length_prefix_endian" => length_prefix_endian,
489
+ "message_delimiter" => message_delimiter,
490
+ "interpret_escape_sequences" => interpret_escape_sequences,
491
+ "fixed_message_length" => fixed_message_length,
492
+ "max_response_size" => max_response_size,
493
+ "timeout" => timeout
494
+ ))
495
+ end
496
+
497
+ private
498
+
499
+ def validate_socket_values!
500
+ raise ValidationError.new("must be between 1 and 65535", path: "port") unless (1..65_535).cover?(@port)
501
+ raise ValidationError.new("must be json or text", path: "request_data_format") unless FORMATS.include?(@request_data_format)
502
+ raise ValidationError.new("unsupported framing strategy", path: "framing_strategy") unless FRAMING.include?(@framing_strategy)
503
+ raise ValidationError.new("must be 1, 2, 4, or 8", path: "length_prefix_bytes") unless [1, 2, 4, 8].include?(@length_prefix_bytes)
504
+ raise ValidationError.new("must be big or little", path: "length_prefix_endian") unless %w[big little].include?(@length_prefix_endian)
505
+ if @framing_strategy == "fixed_length" && (!@fixed_message_length || !@fixed_message_length.positive?)
506
+ raise ValidationError.new("must be positive for fixed_length framing", path: "fixed_message_length")
507
+ end
508
+ raise ValidationError.new("must be greater than zero", path: "max_response_size") unless @max_response_size.positive?
509
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
510
+ end
511
+ end
512
+ TCPCallTemplate = TcpCallTemplate
513
+
514
+ class UdpCallTemplate < CallTemplate
515
+ FORMATS = %w[json text].freeze
516
+ attr_accessor :host, :port, :number_of_response_datagrams, :request_data_format,
517
+ :request_data_template, :response_byte_format, :timeout
518
+
519
+ def initialize(host:, port:, call_template_type: "udp", number_of_response_datagrams: 1,
520
+ request_data_format: "json", request_data_template: nil,
521
+ response_byte_format: "utf-8", timeout: 30_000, **common)
522
+ super(call_template_type: call_template_type, auth: nil, **common)
523
+ @host = Utils.required_string!(host, "host")
524
+ @port = Integer(port)
525
+ @number_of_response_datagrams = Integer(number_of_response_datagrams)
526
+ @request_data_format = request_data_format.to_s
527
+ @request_data_template = Utils.optional_string!(request_data_template, "request_data_template")
528
+ @response_byte_format = response_byte_format.nil? ? nil : response_byte_format.to_s
529
+ @timeout = Integer(timeout)
530
+ raise ValidationError.new("must be between 1 and 65535", path: "port") unless (1..65_535).cover?(@port)
531
+ raise ValidationError.new("must not be negative", path: "number_of_response_datagrams") if @number_of_response_datagrams.negative?
532
+ raise ValidationError.new("must be json or text", path: "request_data_format") unless FORMATS.include?(@request_data_format)
533
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
534
+ end
535
+
536
+ def to_h
537
+ Utils.compact_hash(super.merge(
538
+ "host" => host,
539
+ "port" => port,
540
+ "number_of_response_datagrams" => number_of_response_datagrams,
541
+ "request_data_format" => request_data_format,
542
+ "request_data_template" => request_data_template,
543
+ "response_byte_format" => response_byte_format,
544
+ "timeout" => timeout
545
+ ))
546
+ end
547
+ end
548
+ UDPCallTemplate = UdpCallTemplate
549
+
550
+ class WebRtcCallTemplate < CallTemplate
551
+ attr_accessor :signaling_server, :peer_id, :data_channel_name, :timeout, :ice_servers
552
+
553
+ def initialize(signaling_server:, peer_id:, data_channel_name:, call_template_type: "webrtc",
554
+ timeout: 30, ice_servers: nil, **common)
555
+ super(call_template_type: call_template_type, auth: nil, **common)
556
+ @signaling_server = Utils.required_string!(signaling_server, "signaling_server")
557
+ @peer_id = Utils.required_string!(peer_id, "peer_id")
558
+ @data_channel_name = Utils.required_string!(data_channel_name, "data_channel_name")
559
+ @timeout = Float(timeout)
560
+ @ice_servers = ice_servers.nil? ? [] : Utils.array!(ice_servers, "ice_servers").map do |server|
561
+ Utils.stringify_keys(Utils.hash!(server, "ice_server"))
562
+ end
563
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
564
+ end
565
+
566
+ def to_h
567
+ Utils.compact_hash(super.merge(
568
+ "signaling_server" => signaling_server,
569
+ "peer_id" => peer_id,
570
+ "data_channel_name" => data_channel_name,
571
+ "timeout" => timeout,
572
+ "ice_servers" => ice_servers.empty? ? nil : Utils.deep_copy(ice_servers)
573
+ ))
574
+ end
575
+ end
576
+ WebRTCCallTemplate = WebRtcCallTemplate
577
+
578
+ class McpCallTemplate < CallTemplate
579
+ attr_accessor :config, :register_resources_as_tools, :protocol_version, :timeout
580
+
581
+ def initialize(config:, call_template_type: "mcp", register_resources_as_tools: false,
582
+ protocol_version: "2025-06-18", timeout: 30, **common)
583
+ super(call_template_type: call_template_type, **common)
584
+ @config = Utils.stringify_keys(Utils.hash!(config, "config"))
585
+ servers = @config["mcpServers"]
586
+ unless servers.is_a?(Hash) && !servers.empty?
587
+ raise ValidationError.new("must contain a non-empty mcpServers object", path: "config")
588
+ end
589
+ @register_resources_as_tools = !!register_resources_as_tools
590
+ @protocol_version = Utils.required_string!(protocol_version, "protocol_version")
591
+ @timeout = Float(timeout)
592
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
593
+ end
594
+
595
+ def servers
596
+ config["mcpServers"]
597
+ end
598
+
599
+ def to_h
600
+ super.merge(
601
+ "config" => Utils.deep_copy(config),
602
+ "register_resources_as_tools" => register_resources_as_tools,
603
+ "protocol_version" => protocol_version,
604
+ "timeout" => timeout
605
+ )
606
+ end
607
+ end
608
+ MCPCallTemplate = McpCallTemplate
609
+
610
+ class CommandStep
611
+ include ModelSerialization
612
+ attr_accessor :command, :append_to_final_output
613
+
614
+ def self.from_h(value)
615
+ return value if value.is_a?(self)
616
+
617
+ new(**Utils.symbolize_keys(Utils.stringify_keys(Utils.hash!(value, "command step"))))
618
+ end
619
+
620
+ def initialize(command:, append_to_final_output: nil, **_extra)
621
+ @command = Utils.required_string!(command, "command")
622
+ unless append_to_final_output.nil? || append_to_final_output == true || append_to_final_output == false
623
+ raise ValidationError.new("must be true, false, or null", path: "append_to_final_output")
624
+ end
625
+ @append_to_final_output = append_to_final_output
626
+ end
627
+
628
+ def to_h
629
+ Utils.compact_hash("command" => command, "append_to_final_output" => append_to_final_output)
630
+ end
631
+ end
632
+
633
+ class CliCallTemplate < CallTemplate
634
+ attr_accessor :commands, :env_vars, :inherit_env_vars, :working_dir, :timeout
635
+
636
+ def initialize(commands:, call_template_type: "cli", env_vars: nil, inherit_env_vars: nil,
637
+ working_dir: nil, timeout: 120, **common)
638
+ super(call_template_type: call_template_type, **common)
639
+ @commands = Utils.array!(commands, "commands").map { |item| CommandStep.from_h(item) }
640
+ raise ValidationError.new("must contain at least one command", path: "commands") if @commands.empty?
641
+
642
+ @env_vars = env_vars.nil? ? {} : Utils.stringify_keys(Utils.hash!(env_vars, "env_vars"))
643
+ @inherit_env_vars = inherit_env_vars.nil? ? nil : Utils.array!(inherit_env_vars, "inherit_env_vars").map(&:to_s)
644
+ @working_dir = Utils.optional_string!(working_dir, "working_dir")
645
+ @timeout = Float(timeout)
646
+ raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
647
+ end
648
+
649
+ def to_h
650
+ Utils.compact_hash(super.merge(
651
+ "commands" => commands.map(&:to_h),
652
+ "env_vars" => env_vars.empty? ? nil : Utils.deep_copy(env_vars),
653
+ "inherit_env_vars" => inherit_env_vars&.dup,
654
+ "working_dir" => working_dir,
655
+ "timeout" => timeout
656
+ ))
657
+ end
658
+ end
659
+
660
+ class TextCallTemplate < CallTemplate
661
+ attr_accessor :content, :base_url, :auth_tools
662
+
663
+ def initialize(content:, call_template_type: "text", base_url: nil, auth_tools: nil, **common)
664
+ super(call_template_type: call_template_type, auth: nil, **common)
665
+ @content = Utils.required_string!(content, "content")
666
+ @base_url = Utils.optional_string!(base_url, "base_url")
667
+ @auth_tools = auth_tools.nil? ? nil : Auth.from_h(auth_tools)
668
+ end
669
+
670
+ def to_h
671
+ Utils.compact_hash(super.merge(
672
+ "content" => content,
673
+ "base_url" => base_url,
674
+ "auth_tools" => auth_tools&.to_h
675
+ ))
676
+ end
677
+ end
678
+
679
+ class FileCallTemplate < CallTemplate
680
+ attr_accessor :file_path, :auth_tools
681
+
682
+ def initialize(file_path:, call_template_type: "file", auth_tools: nil, **common)
683
+ super(call_template_type: call_template_type, auth: nil, **common)
684
+ @file_path = Utils.required_string!(file_path, "file_path")
685
+ @auth_tools = auth_tools.nil? ? nil : Auth.from_h(auth_tools)
686
+ end
687
+
688
+ def to_h
689
+ Utils.compact_hash(super.merge("file_path" => file_path, "auth_tools" => auth_tools&.to_h))
690
+ end
691
+ end
692
+
693
+ class Tool
694
+ include ModelSerialization
695
+ attr_accessor :name, :description, :inputs, :outputs, :tags, :average_response_size,
696
+ :tool_call_template
697
+
698
+ def self.from_h(value)
699
+ return value if value.is_a?(self)
700
+
701
+ data = Utils.stringify_keys(Utils.hash!(value, "tool"))
702
+ new(**Utils.symbolize_keys(data))
703
+ rescue ValidationError
704
+ raise
705
+ rescue StandardError => error
706
+ raise SerializerValidationError, "Invalid tool: #{error.message}"
707
+ end
708
+
709
+ def initialize(name:, tool_call_template:, description: "", inputs: nil, outputs: nil,
710
+ tags: nil, average_response_size: nil, **extra)
711
+ @name = Utils.required_string!(name, "tool.name")
712
+ @description = Utils.optional_string!(description, "tool.description") || ""
713
+ @inputs = JsonSchema.from_h(inputs || {})
714
+ @outputs = JsonSchema.from_h(outputs || {})
715
+ @tags = (tags || []).tap { |value| Utils.array!(value, "tool.tags") }.map(&:to_s)
716
+ @average_response_size = average_response_size.nil? ? nil : Integer(average_response_size)
717
+ @tool_call_template = CallTemplate.from_h(tool_call_template)
718
+ @extra = Utils.stringify_keys(extra)
719
+ end
720
+
721
+ def to_h
722
+ Utils.compact_hash({
723
+ "name" => name,
724
+ "description" => description,
725
+ "inputs" => inputs.to_h,
726
+ "outputs" => outputs.to_h,
727
+ "tags" => tags.dup,
728
+ "average_response_size" => average_response_size,
729
+ "tool_call_template" => tool_call_template.to_h
730
+ }.merge(Utils.deep_copy(@extra)))
731
+ end
732
+ end
733
+
734
+ class Manual
735
+ include ModelSerialization
736
+ attr_accessor :utcp_version, :manual_version, :info, :tools
737
+
738
+ def self.from_h(value)
739
+ return value if value.is_a?(self)
740
+
741
+ data = Utils.stringify_keys(Utils.hash!(value, "manual"))
742
+ new(**Utils.symbolize_keys(data))
743
+ rescue ValidationError
744
+ raise
745
+ rescue StandardError => error
746
+ raise SerializerValidationError, "Invalid UTCP manual: #{error.message}"
747
+ end
748
+
749
+ def initialize(tools:, utcp_version: VERSION, manual_version: "1.0.0", info: nil, **extra)
750
+ @utcp_version = Utils.required_string!(utcp_version, "utcp_version")
751
+ @manual_version = Utils.required_string!(manual_version, "manual_version")
752
+ @info = info.nil? ? {} : Utils.stringify_keys(Utils.hash!(info, "info"))
753
+ @tools = Utils.array!(tools, "tools").map { |tool| Tool.from_h(tool) }
754
+ @extra = Utils.stringify_keys(extra)
755
+ end
756
+
757
+ def to_h
758
+ values = {
759
+ "manual_version" => manual_version,
760
+ "utcp_version" => utcp_version,
761
+ "info" => info.empty? ? nil : Utils.deep_copy(info),
762
+ "tools" => tools.map(&:to_h)
763
+ }.merge(Utils.deep_copy(@extra))
764
+ Utils.compact_hash(values)
765
+ end
766
+
767
+ end
768
+ UtcpManual = Manual
769
+
770
+ class RegisterManualResult
771
+ include ModelSerialization
772
+ attr_accessor :manual_call_template, :manual, :success, :errors
773
+
774
+ def initialize(manual_call_template:, manual:, success:, errors: [])
775
+ @manual_call_template = CallTemplate.from_h(manual_call_template)
776
+ @manual = Manual.from_h(manual)
777
+ @success = !!success
778
+ @errors = Array(errors).map(&:to_s)
779
+ end
780
+
781
+ def success?
782
+ success
783
+ end
784
+
785
+ def to_h
786
+ {
787
+ "manual_call_template" => manual_call_template.to_h,
788
+ "manual" => manual.to_h,
789
+ "success" => success,
790
+ "errors" => errors.dup
791
+ }
792
+ end
793
+ end
794
+ end