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,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "ipaddr"
5
+ require "json"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "uri"
9
+
10
+ module UTCP
11
+ module URLSecurity
12
+ module_function
13
+
14
+ def validate!(url, context: "HTTP request")
15
+ uri = URI.parse(url.to_s)
16
+ raise SecurityError, "#{context} URL must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme)
17
+ raise SecurityError, "#{context} URL must contain a host" if uri.host.nil? || uri.host.empty?
18
+ raise SecurityError, "#{context} URL must not contain user information" if uri.userinfo
19
+
20
+ if uri.scheme == "http" && !loopback_host?(uri.host)
21
+ raise SecurityError, "#{context} refuses plain HTTP except for loopback hosts"
22
+ end
23
+ uri
24
+ rescue URI::InvalidURIError => error
25
+ raise SecurityError, "Invalid #{context} URL: #{error.message}"
26
+ end
27
+
28
+ def loopback_host?(host)
29
+ normalized = host.to_s.downcase.sub(/\A\[/, "").sub(/\]\z/, "").sub(/\.\z/, "")
30
+ return true if normalized == "localhost" || normalized.end_with?(".localhost")
31
+
32
+ IPAddr.new(normalized).loopback?
33
+ rescue IPAddr::InvalidAddressError
34
+ false
35
+ end
36
+
37
+ def same_origin?(first, second)
38
+ [first.scheme, first.host&.downcase, first.port] == [second.scheme, second.host&.downcase, second.port]
39
+ end
40
+ end
41
+
42
+ class HTTPProtocol < CommunicationProtocol
43
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
44
+ REQUEST_CLASSES = {
45
+ "GET" => Net::HTTP::Get,
46
+ "POST" => Net::HTTP::Post,
47
+ "PUT" => Net::HTTP::Put,
48
+ "DELETE" => Net::HTTP::Delete,
49
+ "PATCH" => Net::HTTP::Patch,
50
+ "HEAD" => Net::HTTP::Head,
51
+ "OPTIONS" => Net::HTTP::Options
52
+ }.freeze
53
+
54
+ def initialize(open_timeout: 10, read_timeout: 30, max_redirects: 5)
55
+ @open_timeout = open_timeout
56
+ @read_timeout = read_timeout
57
+ @max_redirects = max_redirects
58
+ @oauth_tokens = {}
59
+ @oauth_mutex = Mutex.new
60
+ end
61
+
62
+ def register_manual(client, template)
63
+ assert_template!(template)
64
+ response = request(template, {}, discovery: true)
65
+ data = parse_document(response.body, response["content-type"], template.url)
66
+ manual = if data.is_a?(Hash) && data.key?("utcp_version") && data.key?("tools")
67
+ Manual.from_h(data)
68
+ else
69
+ OpenAPIConverter.new(
70
+ data,
71
+ spec_url: template.url,
72
+ call_template_name: template.name,
73
+ auth_tools: template.auth_tools
74
+ ).convert
75
+ end
76
+ success(template, manual)
77
+ rescue StandardError => error
78
+ client.logger.warn("Unable to register HTTP manual #{template.name.inspect}: #{error.message}")
79
+ failure(template, error)
80
+ end
81
+
82
+ def call_tool(_client, tool_name, tool_args, template)
83
+ assert_template!(template)
84
+ response = request(template, Utils.stringify_keys(tool_args || {}), discovery: false)
85
+ parse_response(response)
86
+ rescue Error
87
+ raise
88
+ rescue StandardError => error
89
+ raise ToolCallError.new(
90
+ "HTTP tool #{tool_name.inspect} failed: #{error.message}",
91
+ tool_name: tool_name
92
+ )
93
+ end
94
+
95
+ private
96
+
97
+ def assert_template!(template)
98
+ return if template.is_a?(HttpCallTemplate)
99
+
100
+ raise ValidationError, "HTTP protocol requires an HttpCallTemplate"
101
+ end
102
+
103
+ def request(template, arguments, discovery:)
104
+ headers = Utils.stringify_keys(template.headers || {})
105
+ query = {}
106
+ cookies = {}
107
+ body = nil
108
+ args = arguments.dup
109
+
110
+ unless discovery
111
+ template.header_fields.each do |field|
112
+ next unless args.key?(field)
113
+
114
+ headers[field] = args.delete(field).to_s
115
+ end
116
+ body = args.delete(template.body_field) if template.body_field && args.key?(template.body_field)
117
+ end
118
+
119
+ url = discovery ? template.url : interpolate_url(template.url, args)
120
+ query.merge!(args) unless discovery
121
+ sensitive_headers = apply_auth(template.auth, headers, query, cookies)
122
+ if template.auth.is_a?(OAuth2Auth)
123
+ headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
124
+ sensitive_headers << "Authorization"
125
+ end
126
+
127
+ uri = URLSecurity.validate!(append_query(url, query), context: discovery ? "manual discovery" : "tool invocation")
128
+ timeout = template.timeout || (discovery ? @open_timeout : @read_timeout)
129
+ perform_request(
130
+ template.http_method,
131
+ uri,
132
+ headers: headers,
133
+ cookies: cookies,
134
+ body: body,
135
+ content_type: template.content_type,
136
+ timeout: timeout,
137
+ sensitive_headers: sensitive_headers.uniq
138
+ )
139
+ end
140
+
141
+ def interpolate_url(url, arguments)
142
+ result = url.gsub(/\{([^}]+)\}/) do
143
+ name = Regexp.last_match(1)
144
+ raise ToolCallError, "Missing required path parameter: #{name}" unless arguments.key?(name)
145
+
146
+ percent_encode(arguments.delete(name).to_s)
147
+ end
148
+ remaining = result.scan(/\{([^}]+)\}/).flatten
149
+ raise ToolCallError, "Missing required path parameters: #{remaining.join(', ')}" unless remaining.empty?
150
+
151
+ result
152
+ end
153
+
154
+ def percent_encode(value)
155
+ value.encode(Encoding::UTF_8).bytes.map do |byte|
156
+ character = byte.chr
157
+ character.match?(/[A-Za-z0-9\-._~]/) ? character : format("%%%02X", byte)
158
+ end.join
159
+ end
160
+
161
+ def append_query(url, query)
162
+ return url if query.empty?
163
+
164
+ uri = URI.parse(url)
165
+ existing = URI.decode_www_form(uri.query.to_s)
166
+ additions = query.flat_map do |key, value|
167
+ values = value.is_a?(Array) ? value : [value]
168
+ values.map { |item| [key.to_s, encode_query_value(item)] }
169
+ end
170
+ uri.query = URI.encode_www_form(existing + additions)
171
+ uri.to_s
172
+ rescue URI::InvalidURIError => error
173
+ raise ToolCallError, "Invalid HTTP URL: #{error.message}"
174
+ end
175
+
176
+ def encode_query_value(value)
177
+ value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
178
+ end
179
+
180
+ def apply_auth(auth, headers, query, cookies)
181
+ return [] unless auth
182
+
183
+ case auth
184
+ when ApiKeyAuth
185
+ assert_header_safe!(auth.var_name, "API key name")
186
+ case auth.location
187
+ when "header"
188
+ headers[auth.var_name] = auth.api_key
189
+ [auth.var_name]
190
+ when "query"
191
+ query[auth.var_name] = auth.api_key
192
+ []
193
+ when "cookie"
194
+ cookies[auth.var_name] = auth.api_key
195
+ ["Cookie"]
196
+ end
197
+ when BasicAuth
198
+ token = Base64.strict_encode64("#{auth.username}:#{auth.password}")
199
+ headers["Authorization"] = "Basic #{token}"
200
+ ["Authorization"]
201
+ when OAuth2Auth
202
+ ["Authorization"]
203
+ else
204
+ raise AuthenticationError, "Unsupported authentication type: #{auth.auth_type}"
205
+ end
206
+ end
207
+
208
+ def oauth_token(auth)
209
+ @oauth_mutex.synchronize do
210
+ cache_key = [auth.token_url, auth.client_id, auth.scope]
211
+ cached = @oauth_tokens[cache_key]
212
+ return cached[:token] if cached && cached[:expires_at] > Time.now.to_f + 5
213
+
214
+ token_uri = URLSecurity.validate!(auth.token_url, context: "OAuth2 token")
215
+ fields = {
216
+ "grant_type" => "client_credentials",
217
+ "client_id" => auth.client_id,
218
+ "client_secret" => auth.client_secret
219
+ }
220
+ fields["scope"] = auth.scope if auth.scope
221
+ response = perform_request(
222
+ "POST", token_uri,
223
+ headers: {}, cookies: {}, body: URI.encode_www_form(fields),
224
+ content_type: "application/x-www-form-urlencoded", timeout: @open_timeout,
225
+ sensitive_headers: ["Authorization"]
226
+ )
227
+ data = JSON.parse(response.body)
228
+ token = data["access_token"]
229
+ raise AuthenticationError, "OAuth2 response did not contain access_token" if token.nil? || token.empty?
230
+
231
+ expires_in = Float(data.fetch("expires_in", 3600)) rescue 3600.0
232
+ @oauth_tokens[cache_key] = { token: token, expires_at: Time.now.to_f + expires_in }
233
+ token
234
+ rescue Error
235
+ raise
236
+ rescue StandardError => error
237
+ raise AuthenticationError, "OAuth2 token request failed: #{error.message}"
238
+ end
239
+ end
240
+
241
+ def perform_request(method, uri, headers:, cookies:, body:, content_type:, timeout:,
242
+ sensitive_headers:, redirects: 0)
243
+ URLSecurity.validate!(uri.to_s, context: "HTTP request")
244
+ raise ToolCallError, "Too many HTTP redirects" if redirects > @max_redirects
245
+
246
+ request_class = REQUEST_CLASSES[method.to_s.upcase]
247
+ raise ValidationError, "Unsupported HTTP method: #{method}" unless request_class
248
+
249
+ request = request_class.new(uri.request_uri)
250
+ headers.each do |name, value|
251
+ assert_header_safe!(name, "header name")
252
+ assert_header_safe!(value.to_s, "header value")
253
+ request[name] = value.to_s
254
+ end
255
+ unless cookies.empty?
256
+ cookies.each do |name, value|
257
+ assert_header_safe!(name, "cookie name")
258
+ assert_header_safe!(value, "cookie value")
259
+ end
260
+ request["Cookie"] = cookies.map { |key, value| "#{key}=#{value}" }.join("; ")
261
+ end
262
+ unless body.nil?
263
+ request["Content-Type"] ||= content_type
264
+ request.body = content_type.to_s.include?("json") && !body.is_a?(String) ? JSON.generate(body) : body.to_s
265
+ end
266
+
267
+ response = send_request(uri, request, timeout)
268
+ if REDIRECTS.include?(response.code.to_i) && response["location"]
269
+ target = URI.join(uri.to_s, response["location"])
270
+ URLSecurity.validate!(target.to_s, context: "HTTP redirect")
271
+ next_headers = headers.dup
272
+ next_cookies = cookies.dup
273
+ unless URLSecurity.same_origin?(uri, target)
274
+ sensitive_headers.each { |name| next_headers.delete_if { |key, _| key.casecmp?(name) } }
275
+ next_cookies = {}
276
+ end
277
+ next_method = response.code.to_i == 303 || ([301, 302].include?(response.code.to_i) && method.to_s.upcase == "POST") ? "GET" : method
278
+ next_body = next_method == "GET" ? nil : body
279
+ return perform_request(
280
+ next_method, target, headers: next_headers, cookies: next_cookies,
281
+ body: next_body, content_type: content_type, timeout: timeout,
282
+ sensitive_headers: sensitive_headers, redirects: redirects + 1
283
+ )
284
+ end
285
+
286
+ status = response.code.to_i
287
+ if status == 401 || status == 403
288
+ raise AuthenticationError, "HTTP authentication failed with status #{status}"
289
+ end
290
+ unless status.between?(200, 299)
291
+ raise ToolCallError.new(
292
+ "HTTP request failed with status #{status}",
293
+ status: status,
294
+ response_body: response.body
295
+ )
296
+ end
297
+ response
298
+ rescue Net::OpenTimeout, Net::ReadTimeout => error
299
+ raise TimeoutError, "HTTP request timed out: #{error.message}"
300
+ rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError => error
301
+ raise ToolCallError, "HTTP request failed: #{error.message}"
302
+ end
303
+
304
+ def send_request(uri, request, timeout)
305
+ http = Net::HTTP.new(uri.host, uri.port)
306
+ http.use_ssl = uri.scheme == "https"
307
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
308
+ http.open_timeout = [Float(timeout), @open_timeout].min
309
+ http.read_timeout = Float(timeout)
310
+ http.start { |connection| connection.request(request) }
311
+ end
312
+
313
+ def parse_document(body, content_type, url)
314
+ if content_type.to_s.downcase.include?("yaml") || url.end_with?(".yaml", ".yml")
315
+ value = YAML.safe_load(body, permitted_classes: [], permitted_symbols: [], aliases: false)
316
+ Utils.stringify_keys(value)
317
+ else
318
+ Utils.stringify_keys(JSON.parse(body))
319
+ end
320
+ rescue JSON::ParserError, Psych::Exception => error
321
+ raise SerializerValidationError, "Invalid manual response: #{error.message}"
322
+ end
323
+
324
+ def parse_response(response)
325
+ content_type = response["content-type"].to_s.downcase
326
+ return response.body unless content_type.include?("json")
327
+
328
+ JSON.parse(response.body)
329
+ rescue JSON::ParserError
330
+ response.body
331
+ end
332
+
333
+ def assert_header_safe!(value, field)
334
+ return unless value.to_s.match?(/[\r\n]/)
335
+
336
+ raise SecurityError, "#{field} contains CR/LF"
337
+ end
338
+ end
339
+ HttpCommunicationProtocol = HTTPProtocol
340
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ module HTTPStreamSupport
5
+ private
6
+
7
+ def buffered_discovery(template)
8
+ parts = http_parts(template, {}, discovery: true, accept: "application/json")
9
+ perform_request(
10
+ parts[:method], parts[:uri], headers: parts[:headers], cookies: parts[:cookies],
11
+ body: parts[:body], content_type: parts[:content_type], timeout: parts[:timeout],
12
+ sensitive_headers: parts[:sensitive_headers]
13
+ )
14
+ end
15
+
16
+ def with_stream_response(template, arguments, accept: nil)
17
+ parts = http_parts(template, arguments, discovery: false, accept: accept)
18
+ request_class = HTTPProtocol::REQUEST_CLASSES.fetch(parts[:method])
19
+ request = request_class.new(parts[:uri].request_uri)
20
+ parts[:headers].each do |name, value|
21
+ assert_header_safe!(name, "header name")
22
+ assert_header_safe!(value.to_s, "header value")
23
+ request[name] = value.to_s
24
+ end
25
+ unless parts[:cookies].empty?
26
+ request["Cookie"] = parts[:cookies].map { |key, value| "#{key}=#{value}" }.join("; ")
27
+ end
28
+ unless parts[:body].nil?
29
+ request["Content-Type"] ||= parts[:content_type]
30
+ request.body = parts[:content_type].include?("json") && !parts[:body].is_a?(String) ?
31
+ JSON.generate(parts[:body]) : parts[:body].to_s
32
+ end
33
+
34
+ send_stream_request(parts[:uri], request, parts[:timeout]) do |response|
35
+ validate_stream_response!(response)
36
+ yield response
37
+ end
38
+ rescue Net::OpenTimeout, Net::ReadTimeout => error
39
+ raise TimeoutError, "Streaming HTTP request timed out: #{error.message}"
40
+ rescue Error
41
+ raise
42
+ rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError => error
43
+ raise ToolCallError, "Streaming HTTP request failed: #{error.message}"
44
+ end
45
+
46
+ def http_parts(template, arguments, discovery:, accept: nil)
47
+ headers = Utils.stringify_keys(template.headers || {})
48
+ headers["Accept"] = accept if accept
49
+ query = {}
50
+ cookies = {}
51
+ args = Utils.stringify_keys(arguments || {})
52
+ body = nil
53
+
54
+ unless discovery
55
+ Array(template.header_fields).each do |field|
56
+ headers[field] = args.delete(field).to_s if args.key?(field)
57
+ end
58
+ body = args.delete(template.body_field) if template.body_field && args.key?(template.body_field)
59
+ end
60
+
61
+ url = discovery ? template.url : interpolate_url(template.url, args)
62
+ query.merge!(args) unless discovery
63
+ sensitive = apply_auth(template.auth, headers, query, cookies)
64
+ if template.auth.is_a?(OAuth2Auth)
65
+ headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
66
+ sensitive << "Authorization"
67
+ end
68
+ method = if discovery
69
+ template.respond_to?(:http_method) ? template.http_method : "GET"
70
+ elsif template.is_a?(SseCallTemplate)
71
+ body.nil? ? "GET" : "POST"
72
+ else
73
+ template.http_method
74
+ end
75
+ content_type = template.respond_to?(:content_type) ? template.content_type : "application/json"
76
+ timeout = protocol_timeout_seconds(template, discovery)
77
+ uri = URLSecurity.validate!(append_query(url, query), context: discovery ? "manual discovery" : "tool invocation")
78
+ {
79
+ method: method.to_s.upcase,
80
+ uri: uri,
81
+ headers: headers,
82
+ cookies: cookies,
83
+ body: body,
84
+ content_type: content_type,
85
+ timeout: timeout,
86
+ sensitive_headers: sensitive.uniq
87
+ }
88
+ end
89
+
90
+ def protocol_timeout_seconds(template, discovery)
91
+ return 10 if discovery
92
+ return template.timeout / 1000.0 if template.is_a?(StreamableHttpCallTemplate)
93
+
94
+ template.timeout || 30
95
+ end
96
+
97
+ def send_stream_request(uri, request, timeout)
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.use_ssl = uri.scheme == "https"
100
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
101
+ http.open_timeout = [Float(timeout), 10].min
102
+ http.read_timeout = Float(timeout)
103
+ http.start do |connection|
104
+ connection.request(request) { |response| yield response }
105
+ end
106
+ end
107
+
108
+ def validate_stream_response!(response)
109
+ status = response.code.to_i
110
+ if HTTPProtocol::REDIRECTS.include?(status)
111
+ raise SecurityError, "Streaming HTTP redirects are not followed; use the final endpoint URL"
112
+ end
113
+ if status == 401 || status == 403
114
+ raise AuthenticationError, "HTTP authentication failed with status #{status}"
115
+ end
116
+ return if status.between?(200, 299)
117
+
118
+ body = response.respond_to?(:body) ? response.body : nil
119
+ raise ToolCallError.new("HTTP request failed with status #{status}", status: status, response_body: body)
120
+ end
121
+ end
122
+ end