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,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
|