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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class TCPProtocol < CommunicationProtocol
5
+ include SocketSupport
6
+
7
+ def initialize(socket_factory: nil)
8
+ @socket_factory = socket_factory || lambda do |host, port, timeout|
9
+ Socket.tcp(host, port, connect_timeout: timeout)
10
+ end
11
+ end
12
+
13
+ def register_manual(client, template)
14
+ assert_template!(template)
15
+ response = exchange(template, JSON.generate("type" => "utcp"))
16
+ success(template, manual_from_payload(template, response, source: "TCP discovery response"))
17
+ rescue StandardError => error
18
+ client.logger.warn("Unable to register TCP manual #{template.name.inspect}: #{error.message}")
19
+ failure(template, error)
20
+ end
21
+
22
+ def call_tool(_client, tool_name, tool_args, template)
23
+ assert_template!(template)
24
+ exchange(template, format_socket_message(template, tool_args))
25
+ rescue Error
26
+ raise
27
+ rescue StandardError => error
28
+ raise ToolCallError.new("TCP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
29
+ end
30
+
31
+ private
32
+
33
+ def assert_template!(template)
34
+ return if template.is_a?(TcpCallTemplate)
35
+
36
+ raise ValidationError, "TCP protocol requires a TcpCallTemplate"
37
+ end
38
+
39
+ def exchange(template, message)
40
+ timeout = socket_timeout_seconds(template)
41
+ socket = @socket_factory.call(template.host, template.port, timeout)
42
+ socket.write(frame_message(message.to_s.b, template))
43
+ payload = read_framed(socket, template, timeout)
44
+ decode_socket_payload(payload, template.response_byte_format)
45
+ rescue Timeout::Error, Errno::ETIMEDOUT => error
46
+ raise TimeoutError, "TCP request timed out: #{error.message}"
47
+ rescue Error
48
+ raise
49
+ rescue SocketError, IOError, SystemCallError => error
50
+ raise ToolCallError, "TCP request failed: #{error.message}"
51
+ ensure
52
+ socket.close if socket && !socket.closed?
53
+ end
54
+
55
+ def frame_message(message, template)
56
+ case template.framing_strategy
57
+ when "length_prefix"
58
+ pack = {
59
+ [1, "big"] => "C", [1, "little"] => "C",
60
+ [2, "big"] => "n", [2, "little"] => "v",
61
+ [4, "big"] => "N", [4, "little"] => "V",
62
+ [8, "big"] => "Q>", [8, "little"] => "Q<"
63
+ }.fetch([template.length_prefix_bytes, template.length_prefix_endian])
64
+ [message.bytesize].pack(pack) + message
65
+ when "delimiter"
66
+ message + escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences)
67
+ else
68
+ message
69
+ end
70
+ end
71
+
72
+ def read_framed(socket, template, timeout)
73
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
74
+ case template.framing_strategy
75
+ when "length_prefix"
76
+ prefix = read_exact(socket, template.length_prefix_bytes, deadline)
77
+ unpack = {
78
+ [1, "big"] => "C", [1, "little"] => "C",
79
+ [2, "big"] => "n", [2, "little"] => "v",
80
+ [4, "big"] => "N", [4, "little"] => "V",
81
+ [8, "big"] => "Q>", [8, "little"] => "Q<"
82
+ }.fetch([template.length_prefix_bytes, template.length_prefix_endian])
83
+ length = prefix.unpack1(unpack)
84
+ raise ToolCallError, "TCP response exceeds max_response_size" if length > template.max_response_size
85
+
86
+ read_exact(socket, length, deadline)
87
+ when "delimiter"
88
+ read_until(socket, escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences),
89
+ template.max_response_size, deadline)
90
+ when "fixed_length"
91
+ read_exact(socket, template.fixed_message_length, deadline)
92
+ when "stream"
93
+ read_stream(socket, template.max_response_size, deadline)
94
+ end
95
+ end
96
+
97
+ def read_exact(socket, length, deadline)
98
+ result = +"".b
99
+ while result.bytesize < length
100
+ wait_readable!(socket, deadline, "TCP read")
101
+ chunk = socket.readpartial(length - result.bytesize)
102
+ raise ToolCallError, "TCP connection closed before the complete response" if chunk.nil? || chunk.empty?
103
+
104
+ result << chunk
105
+ end
106
+ result
107
+ rescue EOFError
108
+ raise ToolCallError, "TCP connection closed before the complete response"
109
+ end
110
+
111
+ def read_until(socket, delimiter, maximum, deadline)
112
+ raise ValidationError, "message_delimiter cannot be empty" if delimiter.empty?
113
+
114
+ result = +"".b
115
+ until result.end_with?(delimiter)
116
+ raise ToolCallError, "TCP response exceeds max_response_size" if result.bytesize >= maximum
117
+
118
+ wait_readable!(socket, deadline, "TCP read")
119
+ result << socket.readpartial([4096, maximum - result.bytesize].min)
120
+ end
121
+ result.byteslice(0, result.bytesize - delimiter.bytesize)
122
+ rescue EOFError
123
+ raise ToolCallError, "TCP connection closed before the message delimiter"
124
+ end
125
+
126
+ def read_stream(socket, maximum, deadline)
127
+ result = +"".b
128
+ while result.bytesize < maximum
129
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+ break unless remaining.positive? && IO.select([socket], nil, nil, remaining)
131
+
132
+ begin
133
+ result << socket.readpartial([4096, maximum - result.bytesize].min)
134
+ rescue EOFError
135
+ break
136
+ end
137
+ end
138
+ result
139
+ end
140
+ end
141
+ TcpCommunicationProtocol = TCPProtocol
142
+ TCPCommunicationProtocol = TCPProtocol
143
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class TextProtocol < CommunicationProtocol
5
+ def register_manual(client, template)
6
+ assert_template!(template)
7
+ data = Utils.parse_document(template.content, source: "text manual")
8
+ manual = if openapi?(data)
9
+ OpenAPIConverter.new(
10
+ data,
11
+ spec_url: "text://content",
12
+ call_template_name: template.name,
13
+ auth_tools: template.auth_tools,
14
+ base_url: template.base_url
15
+ ).convert
16
+ else
17
+ Manual.from_h(data)
18
+ end
19
+ success(template, manual)
20
+ rescue StandardError => error
21
+ client.logger.warn("Unable to register text manual #{template.name.inspect}: #{error.message}")
22
+ failure(template, error)
23
+ end
24
+
25
+ def call_tool(_client, _tool_name, _tool_args, template)
26
+ assert_template!(template)
27
+ template.content
28
+ end
29
+
30
+ private
31
+
32
+ def assert_template!(template)
33
+ return if template.is_a?(TextCallTemplate)
34
+
35
+ raise ValidationError, "text protocol requires a TextCallTemplate"
36
+ end
37
+
38
+ def openapi?(data)
39
+ data.is_a?(Hash) && (data.key?("openapi") || data.key?("swagger") || data.key?("paths"))
40
+ end
41
+ end
42
+ TextCommunicationProtocol = TextProtocol
43
+ end
44
+
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class UDPProtocol < CommunicationProtocol
5
+ include SocketSupport
6
+
7
+ def initialize(socket_factory: nil)
8
+ @socket_factory = socket_factory || -> { UDPSocket.new }
9
+ end
10
+
11
+ def register_manual(client, template)
12
+ assert_template!(template)
13
+ response = exchange(template, JSON.generate("type" => "utcp"), response_count: 1)
14
+ success(template, manual_from_payload(template, response, source: "UDP discovery response"))
15
+ rescue StandardError => error
16
+ client.logger.warn("Unable to register UDP manual #{template.name.inspect}: #{error.message}")
17
+ failure(template, error)
18
+ end
19
+
20
+ def call_tool(_client, tool_name, tool_args, template)
21
+ assert_template!(template)
22
+ exchange(template, format_socket_message(template, tool_args),
23
+ response_count: template.number_of_response_datagrams)
24
+ rescue Error
25
+ raise
26
+ rescue StandardError => error
27
+ raise ToolCallError.new("UDP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
28
+ end
29
+
30
+ private
31
+
32
+ def assert_template!(template)
33
+ return if template.is_a?(UdpCallTemplate)
34
+
35
+ raise ValidationError, "UDP protocol requires a UdpCallTemplate"
36
+ end
37
+
38
+ def exchange(template, message, response_count:)
39
+ socket = @socket_factory.call
40
+ socket.connect(template.host, template.port)
41
+ socket.write(message.to_s.b)
42
+ return nil if response_count.zero?
43
+
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + socket_timeout_seconds(template)
45
+ values = response_count.times.map do
46
+ wait_readable!(socket, deadline, "UDP read")
47
+ payload = socket.recv(65_535)
48
+ decode_socket_payload(payload, template.response_byte_format)
49
+ end
50
+ values.length == 1 ? values.first : values
51
+ rescue Error
52
+ raise
53
+ rescue SocketError, IOError, SystemCallError => error
54
+ raise ToolCallError, "UDP request failed: #{error.message}"
55
+ ensure
56
+ socket.close if socket && !socket.closed?
57
+ end
58
+ end
59
+ UdpCommunicationProtocol = UDPProtocol
60
+ UDPCommunicationProtocol = UDPProtocol
61
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class WebRTCPeer
5
+ def initialize(template)
6
+ gem "webrtc-ruby", ">= 1.0.0"
7
+ require "webrtc"
8
+ WebRTC.init
9
+ @template = template
10
+ @mutex = Mutex.new
11
+ @condition = ConditionVariable.new
12
+ @responses = {}
13
+ @candidates = []
14
+ configuration = template.ice_servers.empty? ? nil : { ice_servers: template.ice_servers }
15
+ @connection = WebRTC::RTCPeerConnection.new(configuration)
16
+ install_candidate_handler
17
+ @channel = @connection.create_data_channel(template.data_channel_name)
18
+ install_channel_handlers
19
+ rescue LoadError => error
20
+ raise MissingDependencyError,
21
+ "WebRTC requires the optional 'webrtc-ruby' gem and libdatachannel: #{error.message}"
22
+ end
23
+
24
+ def connect
25
+ offer = @connection.create_offer.await
26
+ @connection.set_local_description(offer).await
27
+ wait_for_ice_gathering
28
+ response = post_json("connect", "peer_id" => @template.peer_id, "sdp" => @connection.local_description.sdp)
29
+ answer = WebRTC::RTCSessionDescription.new(type: :answer, sdp: response.fetch("sdp"))
30
+ @connection.set_remote_description(answer).await
31
+ Array(response["candidates"]).each do |candidate|
32
+ @connection.add_ice_candidate(WebRTC::RTCIceCandidate.new(Utils.symbolize_keys(candidate))).await
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ @candidates.each { |candidate| post_candidate(candidate) }
37
+ wait_for_channel
38
+ response
39
+ end
40
+
41
+ def request(payload, timeout: @template.timeout)
42
+ identifier = payload.fetch("id")
43
+ @channel.send_text(JSON.generate(payload))
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
45
+ @mutex.synchronize do
46
+ until @responses.key?(identifier)
47
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ raise TimeoutError, "WebRTC response timed out" unless remaining.positive?
49
+ @condition.wait(@mutex, remaining)
50
+ end
51
+ @responses.delete(identifier)
52
+ end
53
+ end
54
+
55
+ def close
56
+ @channel.close if @channel
57
+ @channel.destroy if @channel&.respond_to?(:destroy)
58
+ @connection.close if @connection
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ private
64
+
65
+ def install_candidate_handler
66
+ @connection.on_ice_candidate do |candidate|
67
+ @mutex.synchronize { @candidates << candidate } if candidate
68
+ end
69
+ end
70
+
71
+ def install_channel_handlers
72
+ @channel_open = false
73
+ @channel.on_open do
74
+ @mutex.synchronize do
75
+ @channel_open = true
76
+ @condition.broadcast
77
+ end
78
+ end
79
+ @channel.on_message do |message|
80
+ envelope = JSON.parse(message.data)
81
+ identifier = envelope["id"]
82
+ @mutex.synchronize do
83
+ @responses[identifier] = envelope.key?("result") ? envelope["result"] : envelope
84
+ @condition.broadcast
85
+ end
86
+ rescue JSON::ParserError
87
+ nil
88
+ end
89
+ end
90
+
91
+ def wait_for_ice_gathering
92
+ return unless @connection.respond_to?(:on_ice_gathering_state_change)
93
+
94
+ complete = @connection.ice_gathering_state == :complete
95
+ @connection.on_ice_gathering_state_change do
96
+ @mutex.synchronize do
97
+ complete = @connection.ice_gathering_state == :complete
98
+ @condition.broadcast if complete
99
+ end
100
+ end
101
+ wait_for_flag(@template.timeout) { complete }
102
+ rescue TimeoutError
103
+ # Trickle ICE remains valid when a backend does not expose a reliable gathering event.
104
+ nil
105
+ end
106
+
107
+ def wait_for_channel
108
+ wait_for_flag(@template.timeout) { @channel_open || @channel.ready_state == :open }
109
+ end
110
+
111
+ def wait_for_flag(timeout)
112
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
113
+ @mutex.synchronize do
114
+ until yield
115
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
+ raise TimeoutError, "WebRTC connection timed out" unless remaining.positive?
117
+ @condition.wait(@mutex, remaining)
118
+ end
119
+ end
120
+ end
121
+
122
+ def post_candidate(candidate)
123
+ value = candidate.respond_to?(:to_h) ? candidate.to_h : candidate
124
+ post_json("candidate", "peer_id" => @template.peer_id, "candidate" => value)
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ def post_json(path, payload)
130
+ url = "#{@template.signaling_server.sub(%r{/+\z}, "")}/#{path}"
131
+ uri = URLSecurity.validate!(url, context: "WebRTC signaling")
132
+ request = Net::HTTP::Post.new(uri.request_uri)
133
+ request["Content-Type"] = "application/json"
134
+ request.body = JSON.generate(payload)
135
+ http = Net::HTTP.new(uri.host, uri.port)
136
+ http.use_ssl = uri.scheme == "https"
137
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
138
+ http.open_timeout = [@template.timeout, 10].min
139
+ http.read_timeout = @template.timeout
140
+ response = http.start { |connection| connection.request(request) }
141
+ unless response.code.to_i.between?(200, 299)
142
+ raise ToolCallError.new("WebRTC signaling failed with status #{response.code}",
143
+ status: response.code.to_i, response_body: response.body)
144
+ end
145
+ response.body.to_s.empty? ? {} : JSON.parse(response.body)
146
+ rescue JSON::ParserError => error
147
+ raise SerializerValidationError, "Invalid WebRTC signaling response: #{error.message}"
148
+ end
149
+ end
150
+
151
+ class WebRTCProtocol < CommunicationProtocol
152
+ def initialize(peer_factory: nil)
153
+ @peer_factory = peer_factory || ->(template) { WebRTCPeer.new(template) }
154
+ @peers = {}
155
+ @mutex = Mutex.new
156
+ end
157
+
158
+ def register_manual(client, template)
159
+ assert_webrtc_template!(template)
160
+ response = peer_for(template).connect
161
+ payload = if response.is_a?(Hash) && response.key?("tools") && !response.key?("utcp_version")
162
+ {
163
+ "utcp_version" => VERSION,
164
+ "manual_version" => "1.0.0",
165
+ "tools" => response["tools"]
166
+ }
167
+ else
168
+ response
169
+ end
170
+ success(template, manual_from_payload(template, payload, source: "WebRTC signaling response"))
171
+ rescue StandardError => error
172
+ client.logger.warn("Unable to register WebRTC manual #{template.name.inspect}: #{error.message}")
173
+ failure(template, error)
174
+ end
175
+
176
+ def deregister_manual(_client, template)
177
+ peer = @mutex.synchronize { @peers.delete(peer_key(template)) }
178
+ peer&.close
179
+ nil
180
+ end
181
+
182
+ def call_tool(_client, tool_name, tool_args, template)
183
+ assert_webrtc_template!(template)
184
+ identifier = SecureRandom.uuid
185
+ peer_for(template).request(
186
+ {
187
+ "id" => identifier,
188
+ "tool" => tool_name.to_s.split(".").last,
189
+ "args" => Utils.stringify_keys(tool_args || {})
190
+ },
191
+ timeout: template.timeout
192
+ )
193
+ rescue Error
194
+ raise
195
+ rescue StandardError => error
196
+ raise ToolCallError.new("WebRTC tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
197
+ end
198
+
199
+ private
200
+
201
+ def assert_webrtc_template!(template)
202
+ return if template.is_a?(WebRtcCallTemplate)
203
+
204
+ raise ValidationError, "WebRTC protocol requires a WebRtcCallTemplate"
205
+ end
206
+
207
+ def peer_for(template)
208
+ @mutex.synchronize { @peers[peer_key(template)] ||= @peer_factory.call(template) }
209
+ end
210
+
211
+ def peer_key(template)
212
+ [template.name, template.signaling_server, template.peer_id, template.data_channel_name].join("\0")
213
+ end
214
+ end
215
+ WebrtcCommunicationProtocol = WebRTCProtocol
216
+ WebRTCCommunicationProtocol = WebRTCProtocol
217
+ end