ruby-utcp 1.1.5 → 1.1.6
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 +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +19 -2
- data/lib/utcp/models.rb +21 -3
- data/lib/utcp/openapi_converter.rb +86 -23
- data/lib/utcp/protocols/graphql.rb +9 -2
- data/lib/utcp/protocols/grpc.rb +16 -5
- data/lib/utcp/protocols/http.rb +37 -5
- data/lib/utcp/protocols/http_stream_support.rb +13 -4
- data/lib/utcp/protocols/mcp.rb +26 -17
- data/lib/utcp/protocols/sse.rb +58 -19
- data/lib/utcp/protocols/streamable_http.rb +42 -16
- data/lib/utcp/protocols/tcp.rb +21 -9
- data/lib/utcp/protocols/udp.rb +2 -0
- data/lib/utcp/protocols/webrtc.rb +107 -24
- data/lib/utcp/protocols/webrtc_native_cleanup.rb +39 -0
- data/lib/utcp/protocols/websocket.rb +19 -5
- data/lib/utcp/response_limits.rb +114 -0
- data/lib/utcp/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30241b18fd3629b65595bd41881c8c6c0b9836547b47a139e6a3a32c56111f42
|
|
4
|
+
data.tar.gz: e19fa7d10eb36ca41bcec9286ed75438df01f4a0d5d820a4f0ba76fad202cf17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 634eeb0827c3eab6f57cf8b51c116ccd52981825e95b8a50f238a734f79631f174b9eaeccef60408f43fd08919dc5053d5a04b338550961d185638d5217fa2c9
|
|
7
|
+
data.tar.gz: 439c34aa64040a117248b21a05d59fc7088f46ad8e0b853f86aa4bce691c5bf976fb9fb5750136dad984a82359f768afe9c80f5ad46ddabc92984b23f298d6a8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## Unreleased
|
|
3
|
+
## 1.1.6 (Unreleased)
|
|
4
|
+
|
|
5
|
+
- Add configurable `max_response_bytes` (100 MiB by default) to every transport except file, CLI, and text. Bound discovery, protocol envelopes, streaming totals, UDP datagrams, and MCP pagination; retain TCP's stricter legacy size limit.
|
|
6
|
+
- Add event/item limits and total deadlines for HTTP streams; bound buffered HTTP bodies during reads and cancel gRPC streams on early exit.
|
|
7
|
+
- Parse SSE correctly across arbitrary CR/LF, BOM, and UTF-8 chunk boundaries, discard unfinished events at EOF, and preserve pretty-printed JSON sequences.
|
|
8
|
+
- Resolve local OpenAPI parameter, body, response, and schema references, preserve recursive schemas, and resolve relative servers with operation/path precedence.
|
|
9
|
+
- Retain only pending WebRTC responses, reject excess pending requests, discard late/unsolicited replies, and wake callers on close. Serialize native cleanup and release the GVL while destructors wait for callbacks, using UTCP-local bindings.
|
|
10
|
+
- Add parser partition tests, byte-boundary transport checks, WebRTC concurrency regressions, and default native adapter lifecycle tests.
|
|
11
|
+
|
|
12
|
+
## 1.1.5
|
|
13
|
+
|
|
14
|
+
- Harden authentication across transports, including gRPC request metadata and streaming calls; reject unsupported auth configurations before transport I/O.
|
|
15
|
+
- Protect OAuth token redirects and cache synchronization, isolate MCP sessions by credentials and endpoint, and test the authentication matrix at transport boundaries.
|
|
16
|
+
|
|
17
|
+
## 1.1.4
|
|
4
18
|
|
|
5
19
|
- Raise the Code Mode value budget to 30 MiB, account for string/symbol hash keys, and enforce shared byte, item, and step limits while collecting streams.
|
|
6
20
|
- Close temporary discovery sessions when inspecting required variables, preserving active MCP, WebRTC, and WebSocket sessions. Isolate persistent WebSocket connections between clients and close transient discovery sockets on failure.
|
data/README.md
CHANGED
|
@@ -125,6 +125,21 @@ Complete runnable/configuration examples are in [examples/README.md](examples/RE
|
|
|
125
125
|
|
|
126
126
|
Run `make` to start every available matching local server, execute its clients, and cleanly stop the servers. Missing optional gRPC/WebRTC backends are reported and skipped. `make full-demo` is the strict 12/12 target; `make standard-demo` always runs only the pairs that do not need native backends.
|
|
127
127
|
|
|
128
|
+
### Response limits
|
|
129
|
+
|
|
130
|
+
All transports except `file`, `cli`, and `text` include `UTCP::ResponseLimits`. Set `max_response_bytes` on a call template to bound incoming responses; the default is **100 MiB (104,857,600 bytes)**. The value must be positive and is preserved when templates are serialized. Discovery responses are bounded as well as tool responses. Exceeding the limit aborts the read with a UTCP error; responses are never silently truncated.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
template = UTCP::HttpCallTemplate.new(
|
|
134
|
+
url: "https://api.example.com/results",
|
|
135
|
+
max_response_bytes: 2 * 1024 * 1024
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The budget counts response bytes before JSON/protobuf decoding, including protocol envelopes but excluding transport framing such as WebSocket frame headers and TCP length prefixes/delimiters. HTTP counts decompressed body bytes. SSE, Streamable HTTP, gRPC streams, GraphQL subscriptions (including control messages), and UDP calls share one budget across their response items. MCP limits each JSON-RPC exchange, including stdio notifications and line separators; discovery additionally bounds the serialized results collected across pages and servers. TCP also retains its existing `max_response_size` limit; the smaller limit applies. WebRTC limits both signaling bodies and data-channel messages. Custom adapters are checked when they return their data and must enforce read limits themselves to prevent buffering oversized data internally.
|
|
140
|
+
|
|
141
|
+
HTTP, SSE, and Streamable HTTP templates also accept `total_timeout` in seconds. Buffered HTTP exchanges, including redirects, default to the request timeout as a total deadline. Collecting an SSE or Streamable HTTP response with `call_tool` also has a total deadline. Direct `call_tool_streaming` enumeration has a total deadline only when `total_timeout` is set; the existing read timeout still applies between network reads. SSE and Streamable HTTP additionally default to `max_event_bytes: 1_048_576` and `max_response_items: 10_000`. These bound individual events/records and the number of emitted values; binary output is split into bounded chunks. Breaking out of an HTTP stream, GraphQL subscription, or gRPC stream releases its connection or cancels its RPC.
|
|
142
|
+
|
|
128
143
|
### Authentication by transport
|
|
129
144
|
|
|
130
145
|
| Transport | Supported `auth` | Authentication checks |
|
|
@@ -256,7 +271,7 @@ MCP sessions implement initialization, notifications, `tools/list`, `tools/call`
|
|
|
256
271
|
|
|
257
272
|
Sessions and resource mappings are isolated per client. Closing one client does not close another client's sessions, even when manual and server names match. Tool and resource discovery follows all result pages. An MCP result with `isError: true` raises `UTCP::ToolCallError`; the original result is available in `error.response_body`.
|
|
258
273
|
|
|
259
|
-
For stdio, `timeout` bounds the complete request write and response read, including partial lines and intervening notifications.
|
|
274
|
+
For stdio, `timeout` bounds the complete request write and response read, including partial lines and intervening notifications. Outgoing messages are limited to 16 MiB; incoming messages and notifications share the template's `max_response_bytes` budget. Stderr is drained while retaining only its last 64 KiB. Call `client.close` when finished to release server processes.
|
|
260
275
|
|
|
261
276
|
### WebRTC
|
|
262
277
|
|
|
@@ -264,6 +279,8 @@ WebRTC follows the reference signaling contract: `POST /connect` exchanges SDP a
|
|
|
264
279
|
|
|
265
280
|
The built-in peer uses `webrtc-ruby` and `libdatachannel`. For another native stack, pass `peer_factory:` to `UTCP::WebRTCProtocol`; the adapter contract is demonstrated by the protocol tests.
|
|
266
281
|
|
|
282
|
+
The built-in peer tracks only pending request IDs and discards unsolicited, duplicate, and late responses. `max_pending_requests` defaults to 1,024. Closing a peer wakes pending callers and serializes native destruction with connection setup and sends. UTCP uses local FFI bindings that release Ruby's GVL during native destruction, allowing outstanding callbacks to finish; it does not alter the installed gem's bindings.
|
|
283
|
+
|
|
267
284
|
### Text and file
|
|
268
285
|
|
|
269
286
|
Text templates parse a manual supplied directly in `content`. File templates read JSON or safe YAML relative to the client's `root_dir`.
|
|
@@ -390,4 +407,4 @@ CI runs the tests and gem build on Ruby 2.6, 2.7, 3.0–3.4, and 4.0. A separate
|
|
|
390
407
|
|
|
391
408
|
A dedicated Ruby 3.4 CI job sets `UTCP_NATIVE_TESTS=1` to install the original, unmodified backends from the Gemfile, builds libdatachannel 0.24.5 and the WebRTC extension, and runs `bundle exec rake native`. The test subprocess has a 45-second watchdog. The native dependencies remain optional for applications using the gem.
|
|
392
409
|
|
|
393
|
-
The default native suite checks discovery, real calls, streaming, concurrent requests, timeouts, and client isolation. Additional backend
|
|
410
|
+
The default native suite checks discovery, real calls, response limits, streaming, concurrent requests, timeouts, and client isolation. It also checks the UTCP WebRTC adapter's destruction during an active callback, cancellation of pending calls, and reuse after late responses. Additional probes of the upstream backend itself are opt-in with `UTCP_WEBRTC_SHUTDOWN_REGRESSIONS=1`; they require callback shutdown guarantees that stock `webrtc-ruby` 1.0.0 bindings do not provide and are not part of the CI job. No patches are installed into the dependency.
|
data/lib/utcp/models.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "securerandom"
|
|
|
4
4
|
require_relative "version"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "utils"
|
|
7
|
+
require_relative "response_limits"
|
|
7
8
|
|
|
8
9
|
module UTCP
|
|
9
10
|
class JsonSchema
|
|
@@ -218,6 +219,7 @@ module UTCP
|
|
|
218
219
|
end
|
|
219
220
|
|
|
220
221
|
class HttpCallTemplate < CallTemplate
|
|
222
|
+
include HTTPResponseLimits
|
|
221
223
|
METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS].freeze
|
|
222
224
|
attr_accessor :http_method, :url, :content_type, :auth_tools, :headers, :body_field,
|
|
223
225
|
:header_fields, :timeout
|
|
@@ -236,6 +238,9 @@ module UTCP
|
|
|
236
238
|
@body_field = body_field.nil? ? nil : body_field.to_s
|
|
237
239
|
@header_fields = header_fields.nil? ? [] : Utils.array!(header_fields, "header_fields").map(&:to_s)
|
|
238
240
|
@timeout = timeout.nil? ? nil : Float(timeout)
|
|
241
|
+
if @timeout && (!@timeout.finite? || !@timeout.positive?)
|
|
242
|
+
raise ValidationError.new("must be finite and greater than zero", path: "timeout")
|
|
243
|
+
end
|
|
239
244
|
end
|
|
240
245
|
|
|
241
246
|
def to_h
|
|
@@ -253,6 +258,7 @@ module UTCP
|
|
|
253
258
|
end
|
|
254
259
|
|
|
255
260
|
class SseCallTemplate < CallTemplate
|
|
261
|
+
include HTTPResponseLimits
|
|
256
262
|
attr_accessor :url, :event_type, :reconnect, :retry_timeout, :headers, :body_field,
|
|
257
263
|
:header_fields, :timeout
|
|
258
264
|
|
|
@@ -288,6 +294,7 @@ module UTCP
|
|
|
288
294
|
SSECallTemplate = SseCallTemplate
|
|
289
295
|
|
|
290
296
|
class StreamableHttpCallTemplate < CallTemplate
|
|
297
|
+
include HTTPResponseLimits
|
|
291
298
|
METHODS = %w[GET POST].freeze
|
|
292
299
|
attr_accessor :url, :http_method, :content_type, :chunk_size, :timeout, :headers,
|
|
293
300
|
:body_field, :header_fields
|
|
@@ -327,6 +334,7 @@ module UTCP
|
|
|
327
334
|
StreamableHTTPCallTemplate = StreamableHttpCallTemplate
|
|
328
335
|
|
|
329
336
|
class WebSocketCallTemplate < CallTemplate
|
|
337
|
+
include ResponseLimits
|
|
330
338
|
RESPONSE_FORMATS = %w[json text raw].freeze
|
|
331
339
|
attr_accessor :url, :message, :protocol, :keep_alive, :response_format, :timeout,
|
|
332
340
|
:headers, :header_fields
|
|
@@ -365,6 +373,7 @@ module UTCP
|
|
|
365
373
|
WebsocketCallTemplate = WebSocketCallTemplate
|
|
366
374
|
|
|
367
375
|
class GrpcCallTemplate < CallTemplate
|
|
376
|
+
include ResponseLimits
|
|
368
377
|
attr_accessor :host, :port, :service_name, :method_name, :target, :use_ssl,
|
|
369
378
|
:timeout, :metadata
|
|
370
379
|
|
|
@@ -406,6 +415,7 @@ module UTCP
|
|
|
406
415
|
GRPCCallTemplate = GrpcCallTemplate
|
|
407
416
|
|
|
408
417
|
class GraphQLCallTemplate < CallTemplate
|
|
418
|
+
include ResponseLimits
|
|
409
419
|
OPERATION_TYPES = %w[query mutation subscription].freeze
|
|
410
420
|
attr_accessor :url, :operation_type, :operation_name, :headers, :header_fields,
|
|
411
421
|
:query, :variable_types, :selection_set, :timeout
|
|
@@ -446,6 +456,7 @@ module UTCP
|
|
|
446
456
|
GraphqlCallTemplate = GraphQLCallTemplate
|
|
447
457
|
|
|
448
458
|
class TcpCallTemplate < CallTemplate
|
|
459
|
+
include ResponseLimits
|
|
449
460
|
FORMATS = %w[json text].freeze
|
|
450
461
|
FRAMING = %w[length_prefix delimiter fixed_length stream].freeze
|
|
451
462
|
attr_accessor :host, :port, :request_data_format, :request_data_template,
|
|
@@ -512,6 +523,7 @@ module UTCP
|
|
|
512
523
|
TCPCallTemplate = TcpCallTemplate
|
|
513
524
|
|
|
514
525
|
class UdpCallTemplate < CallTemplate
|
|
526
|
+
include ResponseLimits
|
|
515
527
|
FORMATS = %w[json text].freeze
|
|
516
528
|
attr_accessor :host, :port, :number_of_response_datagrams, :request_data_format,
|
|
517
529
|
:request_data_template, :response_byte_format, :timeout
|
|
@@ -548,19 +560,23 @@ module UTCP
|
|
|
548
560
|
UDPCallTemplate = UdpCallTemplate
|
|
549
561
|
|
|
550
562
|
class WebRtcCallTemplate < CallTemplate
|
|
551
|
-
|
|
563
|
+
include ResponseLimits
|
|
564
|
+
attr_accessor :signaling_server, :peer_id, :data_channel_name, :timeout, :ice_servers,
|
|
565
|
+
:max_pending_requests
|
|
552
566
|
|
|
553
567
|
def initialize(signaling_server:, peer_id:, data_channel_name:, call_template_type: "webrtc",
|
|
554
|
-
timeout: 30, ice_servers: nil, **common)
|
|
568
|
+
timeout: 30, ice_servers: nil, max_pending_requests: 1024, **common)
|
|
555
569
|
super(call_template_type: call_template_type, auth: nil, **common)
|
|
556
570
|
@signaling_server = Utils.required_string!(signaling_server, "signaling_server")
|
|
557
571
|
@peer_id = Utils.required_string!(peer_id, "peer_id")
|
|
558
572
|
@data_channel_name = Utils.required_string!(data_channel_name, "data_channel_name")
|
|
559
573
|
@timeout = Float(timeout)
|
|
574
|
+
@max_pending_requests = Integer(max_pending_requests)
|
|
560
575
|
@ice_servers = ice_servers.nil? ? [] : Utils.array!(ice_servers, "ice_servers").map do |server|
|
|
561
576
|
Utils.stringify_keys(Utils.hash!(server, "ice_server"))
|
|
562
577
|
end
|
|
563
|
-
raise ValidationError.new("must be greater than zero", path: "timeout") unless @timeout.positive?
|
|
578
|
+
raise ValidationError.new("must be finite and greater than zero", path: "timeout") unless @timeout.finite? && @timeout.positive?
|
|
579
|
+
raise ValidationError.new("must be greater than zero", path: "max_pending_requests") unless @max_pending_requests.positive?
|
|
564
580
|
end
|
|
565
581
|
|
|
566
582
|
def to_h
|
|
@@ -568,6 +584,7 @@ module UTCP
|
|
|
568
584
|
"signaling_server" => signaling_server,
|
|
569
585
|
"peer_id" => peer_id,
|
|
570
586
|
"data_channel_name" => data_channel_name,
|
|
587
|
+
"max_pending_requests" => max_pending_requests,
|
|
571
588
|
"timeout" => timeout,
|
|
572
589
|
"ice_servers" => ice_servers.empty? ? nil : Utils.deep_copy(ice_servers)
|
|
573
590
|
))
|
|
@@ -576,6 +593,7 @@ module UTCP
|
|
|
576
593
|
WebRTCCallTemplate = WebRtcCallTemplate
|
|
577
594
|
|
|
578
595
|
class McpCallTemplate < CallTemplate
|
|
596
|
+
include ResponseLimits
|
|
579
597
|
attr_accessor :config, :register_resources_as_tools, :protocol_version, :timeout
|
|
580
598
|
|
|
581
599
|
def initialize(config:, call_template_type: "mcp", register_resources_as_tools: false,
|
|
@@ -20,6 +20,7 @@ module UTCP
|
|
|
20
20
|
|
|
21
21
|
tools = []
|
|
22
22
|
paths.each do |path, path_item|
|
|
23
|
+
path_item = dereference(path_item)
|
|
23
24
|
next unless path_item.is_a?(Hash)
|
|
24
25
|
|
|
25
26
|
HTTP_METHODS.each do |method|
|
|
@@ -41,7 +42,12 @@ module UTCP
|
|
|
41
42
|
private
|
|
42
43
|
|
|
43
44
|
def convert_operation(path, method, path_item, operation)
|
|
44
|
-
parameters = Array(path_item["parameters"]) + Array(operation["parameters"])
|
|
45
|
+
parameters = (Array(path_item["parameters"]) + Array(operation["parameters"])).each_with_object({}) do |value, result|
|
|
46
|
+
parameter = dereference(value)
|
|
47
|
+
next unless parameter.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
result[[parameter["in"], parameter["name"]]] = parameter
|
|
50
|
+
end.values
|
|
45
51
|
properties = {}
|
|
46
52
|
required = []
|
|
47
53
|
header_fields = []
|
|
@@ -55,20 +61,20 @@ module UTCP
|
|
|
55
61
|
next if name.empty?
|
|
56
62
|
|
|
57
63
|
if parameter["in"] == "body"
|
|
58
|
-
properties[body_field] =
|
|
64
|
+
properties[body_field] = parameter["schema"] || {}
|
|
59
65
|
required << body_field if parameter["required"]
|
|
60
66
|
else
|
|
61
|
-
properties[name] =
|
|
67
|
+
properties[name] = parameter["schema"] || parameter_schema(parameter)
|
|
62
68
|
required << name if parameter["required"] || parameter["in"] == "path"
|
|
63
69
|
header_fields << name if parameter["in"] == "header"
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
67
|
-
request_body = operation["requestBody"]
|
|
73
|
+
request_body = dereference(operation["requestBody"])
|
|
68
74
|
if request_body.is_a?(Hash)
|
|
69
75
|
content_type, media = Array(request_body["content"]).first
|
|
70
76
|
if media.is_a?(Hash)
|
|
71
|
-
properties[body_field] =
|
|
77
|
+
properties[body_field] = media["schema"] || {}
|
|
72
78
|
required << body_field if request_body["required"]
|
|
73
79
|
end
|
|
74
80
|
else
|
|
@@ -84,12 +90,12 @@ module UTCP
|
|
|
84
90
|
Tool.new(
|
|
85
91
|
name: tool_name,
|
|
86
92
|
description: description.to_s,
|
|
87
|
-
inputs: input_schema,
|
|
93
|
+
inputs: resolve_schema(input_schema),
|
|
88
94
|
outputs: response_schema(operation),
|
|
89
95
|
tags: Array(operation["tags"]),
|
|
90
96
|
tool_call_template: HttpCallTemplate.new(
|
|
91
97
|
name: tool_name,
|
|
92
|
-
url: join_url(resolve_base_url, path),
|
|
98
|
+
url: join_url(resolve_base_url(operation, path_item), path),
|
|
93
99
|
http_method: method.upcase,
|
|
94
100
|
content_type: content_type || Array(operation["consumes"]).first || "application/json",
|
|
95
101
|
body_field: body_field,
|
|
@@ -105,18 +111,64 @@ module UTCP
|
|
|
105
111
|
end
|
|
106
112
|
end
|
|
107
113
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return {} if seen.include?(reference)
|
|
114
|
+
def reference_value(reference)
|
|
115
|
+
unless reference.is_a?(String) && reference.start_with?("#/")
|
|
116
|
+
raise ValidationError, "Only local OpenAPI references are supported: #{reference.inspect}"
|
|
117
|
+
end
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
reference.delete_prefix("#/").split("/").reduce(@spec) do |node, component|
|
|
120
|
+
key = component.gsub("~1", "/").gsub("~0", "~")
|
|
121
|
+
unless node.is_a?(Hash) && node.key?(key)
|
|
122
|
+
raise ValidationError, "Unresolved OpenAPI reference: #{reference}"
|
|
123
|
+
end
|
|
124
|
+
node[key]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def dereference(value, seen = [])
|
|
129
|
+
return value unless value.is_a?(Hash) && value.key?("$ref")
|
|
116
130
|
|
|
117
|
-
|
|
131
|
+
reference = value["$ref"]
|
|
132
|
+
raise ValidationError, "Circular OpenAPI object reference: #{reference}" if seen.include?(reference)
|
|
133
|
+
|
|
134
|
+
dereference(reference_value(reference), seen + [reference])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resolve_schema(schema)
|
|
138
|
+
# Bundle references in the exported schema so recursive models remain usable
|
|
139
|
+
# after the original OpenAPI document is no longer available.
|
|
140
|
+
definitions = {}
|
|
141
|
+
references = {}
|
|
142
|
+
reserved_names = schema.is_a?(Hash) && schema["$defs"].is_a?(Hash) ? schema["$defs"].keys : []
|
|
143
|
+
transform = lambda do |value|
|
|
144
|
+
return value unless value.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
if value.key?("$ref")
|
|
147
|
+
reference = value["$ref"]
|
|
148
|
+
unless references.key?(reference)
|
|
149
|
+
name = "utcp_ref_#{references.length + 1}"
|
|
150
|
+
name = "_#{name}" while reserved_names.include?(name) || references.value?(name)
|
|
151
|
+
references[reference] = name
|
|
152
|
+
definitions[name] = transform.call(reference_value(reference))
|
|
153
|
+
end
|
|
154
|
+
return { "$ref" => "#/$defs/#{references.fetch(reference)}" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
value.each_with_object({}) do |(key, item), result|
|
|
158
|
+
result[key] = case key
|
|
159
|
+
when "properties", "patternProperties", "definitions", "$defs", "dependentSchemas"
|
|
160
|
+
item.is_a?(Hash) ? item.transform_values { |child| transform.call(child) } : item
|
|
161
|
+
when "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", "propertyNames"
|
|
162
|
+
item.is_a?(Array) ? item.map { |child| transform.call(child) } : transform.call(item)
|
|
163
|
+
when "allOf", "anyOf", "oneOf", "prefixItems"
|
|
164
|
+
Array(item).map { |child| transform.call(child) }
|
|
165
|
+
else Utils.deep_copy(item)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
118
168
|
end
|
|
119
|
-
|
|
169
|
+
result = transform.call(schema || {})
|
|
170
|
+
result["$defs"] = (result["$defs"] || {}).merge(definitions) unless definitions.empty?
|
|
171
|
+
result
|
|
120
172
|
end
|
|
121
173
|
|
|
122
174
|
def response_schema(operation)
|
|
@@ -124,6 +176,7 @@ module UTCP
|
|
|
124
176
|
return {} unless responses.is_a?(Hash)
|
|
125
177
|
|
|
126
178
|
_status, response = responses.find { |status, _value| status.to_s.match?(/\A2\d\d\z/) } || responses.first
|
|
179
|
+
response = dereference(response)
|
|
127
180
|
return {} unless response.is_a?(Hash)
|
|
128
181
|
|
|
129
182
|
if response["content"].is_a?(Hash)
|
|
@@ -139,11 +192,12 @@ module UTCP
|
|
|
139
192
|
security.is_a?(Array) && !security.empty?
|
|
140
193
|
end
|
|
141
194
|
|
|
142
|
-
def resolve_base_url
|
|
143
|
-
return @base_url if @base_url && !@base_url.empty?
|
|
195
|
+
def resolve_base_url(operation = {}, path_item = {})
|
|
196
|
+
return absolute_server_url(@base_url) if @base_url && !@base_url.empty?
|
|
144
197
|
|
|
145
|
-
|
|
146
|
-
|
|
198
|
+
servers = [operation["servers"], path_item["servers"], @spec["servers"]].find { |value| value.is_a?(Array) && !value.empty? }
|
|
199
|
+
server = Array(servers).first
|
|
200
|
+
return absolute_server_url(substitute_server_variables(server)) if server.is_a?(Hash) && server["url"]
|
|
147
201
|
|
|
148
202
|
if @spec["host"]
|
|
149
203
|
scheme = Array(@spec["schemes"]).first || "https"
|
|
@@ -151,13 +205,23 @@ module UTCP
|
|
|
151
205
|
end
|
|
152
206
|
|
|
153
207
|
if @spec_url&.match?(/\Ahttps?:/)
|
|
154
|
-
|
|
155
|
-
return "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
|
|
208
|
+
return absolute_server_url("/")
|
|
156
209
|
end
|
|
157
210
|
|
|
158
211
|
raise ValidationError, "OpenAPI document does not define a server URL; pass base_url"
|
|
159
212
|
end
|
|
160
213
|
|
|
214
|
+
def absolute_server_url(url)
|
|
215
|
+
uri = URI.parse(url)
|
|
216
|
+
uri = URI.join(@spec_url, url) if !uri.absolute? && @spec_url
|
|
217
|
+
unless %w[http https].include?(uri.scheme) && uri.host
|
|
218
|
+
raise ValidationError, "OpenAPI server URL must resolve to HTTP(S); pass an absolute spec_url or base_url"
|
|
219
|
+
end
|
|
220
|
+
uri.to_s
|
|
221
|
+
rescue URI::InvalidURIError => error
|
|
222
|
+
raise ValidationError, "Invalid OpenAPI server URL: #{error.message}"
|
|
223
|
+
end
|
|
224
|
+
|
|
161
225
|
def substitute_server_variables(server)
|
|
162
226
|
variables = Utils.stringify_keys(server["variables"] || {})
|
|
163
227
|
server["url"].gsub(/\{([^}]+)\}/) do
|
|
@@ -176,4 +240,3 @@ module UTCP
|
|
|
176
240
|
end
|
|
177
241
|
OpenApiConverter = OpenAPIConverter
|
|
178
242
|
end
|
|
179
|
-
|
|
@@ -93,7 +93,7 @@ module UTCP
|
|
|
93
93
|
body: payload,
|
|
94
94
|
content_type: "application/json",
|
|
95
95
|
timeout: template.timeout,
|
|
96
|
-
sensitive_headers: sensitive.uniq
|
|
96
|
+
sensitive_headers: sensitive.uniq, max_response_bytes: template.max_response_bytes
|
|
97
97
|
)
|
|
98
98
|
data = JSON.parse(response.body)
|
|
99
99
|
raise_graphql_errors!(data, template.operation_name || template.name)
|
|
@@ -243,9 +243,12 @@ module UTCP
|
|
|
243
243
|
ws_scheme = http_uri.scheme == "https" ? "wss" : "ws"
|
|
244
244
|
ws_url = http_uri.to_s.sub(/\Ahttps?/, ws_scheme)
|
|
245
245
|
connection = @websocket_factory.call(ws_url, headers, "graphql-transport-ws", template.timeout)
|
|
246
|
+
connection.max_response_bytes = template.max_response_bytes if connection.respond_to?(:max_response_bytes=)
|
|
247
|
+
budget = ResponseByteBudget.new(template.max_response_bytes, "GraphQL subscription")
|
|
246
248
|
identifier = SecureRandom.uuid
|
|
247
249
|
connection.send_text(JSON.generate("type" => "connection_init", "payload" => {}))
|
|
248
250
|
ack = connection.read_message
|
|
251
|
+
budget.consume(ack[1]) if ack
|
|
249
252
|
ack_data = ack && decode_json_or_text(ack[1].force_encoding(Encoding::UTF_8))
|
|
250
253
|
unless ack_data.is_a?(Hash) && ack_data["type"] == "connection_ack"
|
|
251
254
|
raise ToolCallError, "GraphQL subscription did not receive connection_ack"
|
|
@@ -255,7 +258,11 @@ module UTCP
|
|
|
255
258
|
"type" => "subscribe",
|
|
256
259
|
"payload" => graphql_payload(tool_name, args, template)
|
|
257
260
|
))
|
|
258
|
-
|
|
261
|
+
loop do
|
|
262
|
+
connection.max_response_bytes = budget.remaining if connection.respond_to?(:max_response_bytes=)
|
|
263
|
+
frame = connection.read_message
|
|
264
|
+
break unless frame
|
|
265
|
+
budget.consume(frame[1])
|
|
259
266
|
message = decode_json_or_text(frame[1].force_encoding(Encoding::UTF_8))
|
|
260
267
|
next unless message.is_a?(Hash) && message["id"] == identifier
|
|
261
268
|
break if message["type"] == "complete"
|
data/lib/utcp/protocols/grpc.rb
CHANGED
|
@@ -72,7 +72,8 @@ module UTCP
|
|
|
72
72
|
require "grpc"
|
|
73
73
|
address = "#{template.host}:#{template.port}"
|
|
74
74
|
credentials = template.use_ssl ? GRPC::Core::ChannelCredentials.new : :this_channel_is_insecure
|
|
75
|
-
@stub = GRPC::ClientStub.new(address, credentials
|
|
75
|
+
@stub = GRPC::ClientStub.new(address, credentials,
|
|
76
|
+
channel_args: { "grpc.max_receive_message_length" => template.max_response_bytes })
|
|
76
77
|
rescue LoadError => error
|
|
77
78
|
raise MissingDependencyError,
|
|
78
79
|
"gRPC requires the optional 'grpc' gem (add gem \"grpc\" to your Gemfile): #{error.message}"
|
|
@@ -92,14 +93,18 @@ module UTCP
|
|
|
92
93
|
def server_stream(route, payload, timeout:, metadata: {})
|
|
93
94
|
return enum_for(__method__, route, payload, timeout: timeout, metadata: metadata) unless block_given?
|
|
94
95
|
|
|
95
|
-
@stub.server_streamer(
|
|
96
|
+
operation = @stub.server_streamer(
|
|
96
97
|
route, payload,
|
|
97
98
|
->(value) { value.to_s.b }, ->(bytes) { bytes },
|
|
98
99
|
deadline: Time.now + timeout,
|
|
99
|
-
metadata: metadata
|
|
100
|
-
)
|
|
100
|
+
metadata: metadata, return_op: true
|
|
101
|
+
)
|
|
102
|
+
operation.execute.each { |response| yield response }
|
|
103
|
+
complete = true
|
|
101
104
|
rescue GRPC::Unauthenticated, GRPC::PermissionDenied => error
|
|
102
105
|
raise AuthenticationError, "gRPC authentication failed: #{error.details}"
|
|
106
|
+
ensure
|
|
107
|
+
operation.cancel if operation && !complete
|
|
103
108
|
end
|
|
104
109
|
end
|
|
105
110
|
|
|
@@ -116,6 +121,7 @@ module UTCP
|
|
|
116
121
|
timeout: template.timeout,
|
|
117
122
|
metadata: grpc_metadata(template)
|
|
118
123
|
)
|
|
124
|
+
ResponseByteBudget.new(template.max_response_bytes, "gRPC").consume(response)
|
|
119
125
|
fields = ProtobufWire.fields(response)
|
|
120
126
|
tools = fields[2].map do |tool_bytes|
|
|
121
127
|
tool_fields = ProtobufWire.fields(tool_bytes)
|
|
@@ -144,6 +150,7 @@ module UTCP
|
|
|
144
150
|
timeout: template.timeout,
|
|
145
151
|
metadata: grpc_metadata(template)
|
|
146
152
|
)
|
|
153
|
+
ResponseByteBudget.new(template.max_response_bytes, "gRPC").consume(response)
|
|
147
154
|
decode_tool_response(response)
|
|
148
155
|
rescue Error
|
|
149
156
|
raise
|
|
@@ -156,12 +163,16 @@ module UTCP
|
|
|
156
163
|
|
|
157
164
|
assert_grpc_template!(template)
|
|
158
165
|
method = template.method_name || "CallToolStream"
|
|
166
|
+
budget = ResponseByteBudget.new(template.max_response_bytes, "gRPC stream")
|
|
159
167
|
rpc_client(template).server_stream(
|
|
160
168
|
route(template, method),
|
|
161
169
|
tool_call_request(tool_name, tool_args),
|
|
162
170
|
timeout: template.timeout,
|
|
163
171
|
metadata: grpc_metadata(template)
|
|
164
|
-
).each
|
|
172
|
+
).each do |response|
|
|
173
|
+
budget.consume(response)
|
|
174
|
+
yield decode_tool_response(response)
|
|
175
|
+
end
|
|
165
176
|
rescue Error
|
|
166
177
|
raise
|
|
167
178
|
rescue StandardError => error
|
data/lib/utcp/protocols/http.rb
CHANGED
|
@@ -136,7 +136,8 @@ module UTCP
|
|
|
136
136
|
body: body,
|
|
137
137
|
content_type: template.content_type,
|
|
138
138
|
timeout: timeout,
|
|
139
|
-
sensitive_headers: sensitive_headers.uniq
|
|
139
|
+
sensitive_headers: sensitive_headers.uniq, max_response_bytes: template.max_response_bytes,
|
|
140
|
+
deadline: response_deadline(template.total_timeout || timeout)
|
|
140
141
|
)
|
|
141
142
|
end
|
|
142
143
|
|
|
@@ -245,7 +246,9 @@ module UTCP
|
|
|
245
246
|
end
|
|
246
247
|
|
|
247
248
|
def perform_request(method, uri, headers:, cookies:, body:, content_type:, timeout:,
|
|
248
|
-
sensitive_headers:, redirects: 0, allow_cross_origin_redirects: true
|
|
249
|
+
sensitive_headers:, redirects: 0, allow_cross_origin_redirects: true,
|
|
250
|
+
max_response_bytes: ResponseLimits::DEFAULT_MAX_RESPONSE_BYTES, deadline: nil)
|
|
251
|
+
deadline ||= response_deadline(timeout)
|
|
249
252
|
URLSecurity.validate!(uri.to_s, context: "HTTP request")
|
|
250
253
|
raise ToolCallError, "Too many HTTP redirects" if redirects > @max_redirects
|
|
251
254
|
|
|
@@ -270,7 +273,15 @@ module UTCP
|
|
|
270
273
|
request.body = content_type.to_s.include?("json") && !body.is_a?(String) ? JSON.generate(body) : body.to_s
|
|
271
274
|
end
|
|
272
275
|
|
|
273
|
-
response =
|
|
276
|
+
response = with_response_timeout(deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) do
|
|
277
|
+
send_request(uri, request, timeout) do |incoming|
|
|
278
|
+
incoming.body = LimitedHTTPResponse.new(incoming, max_response_bytes).body
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
# Custom adapters may return a buffered response without yielding it.
|
|
282
|
+
if response.body.to_s.bytesize > max_response_bytes
|
|
283
|
+
raise ToolCallError, "HTTP response exceeds max_response_bytes (#{max_response_bytes})"
|
|
284
|
+
end
|
|
274
285
|
if REDIRECTS.include?(response.code.to_i) && response["location"]
|
|
275
286
|
target = URI.join(uri.to_s, response["location"])
|
|
276
287
|
URLSecurity.validate!(target.to_s, context: "HTTP redirect")
|
|
@@ -292,7 +303,8 @@ module UTCP
|
|
|
292
303
|
next_method, target, headers: next_headers, cookies: next_cookies,
|
|
293
304
|
body: next_body, content_type: content_type, timeout: timeout,
|
|
294
305
|
sensitive_headers: sensitive_headers, redirects: redirects + 1,
|
|
295
|
-
allow_cross_origin_redirects: allow_cross_origin_redirects
|
|
306
|
+
allow_cross_origin_redirects: allow_cross_origin_redirects,
|
|
307
|
+
max_response_bytes: max_response_bytes, deadline: deadline
|
|
296
308
|
)
|
|
297
309
|
end
|
|
298
310
|
|
|
@@ -320,7 +332,27 @@ module UTCP
|
|
|
320
332
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
321
333
|
http.open_timeout = [Float(timeout), @open_timeout].min
|
|
322
334
|
http.read_timeout = Float(timeout)
|
|
323
|
-
http.
|
|
335
|
+
http.write_timeout = Float(timeout) if http.respond_to?(:write_timeout=)
|
|
336
|
+
http.start do |connection|
|
|
337
|
+
connection.request(request) do |response|
|
|
338
|
+
if block_given?
|
|
339
|
+
yield response
|
|
340
|
+
else
|
|
341
|
+
response.body = LimitedHTTPResponse.new(response, ResponseLimits::DEFAULT_MAX_RESPONSE_BYTES).body
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def response_deadline(seconds)
|
|
348
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + Float(seconds)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def with_response_timeout(seconds)
|
|
352
|
+
return yield if seconds.nil?
|
|
353
|
+
raise TimeoutError, "HTTP total response timeout exceeded" unless seconds.positive?
|
|
354
|
+
|
|
355
|
+
Timeout.timeout(seconds, TimeoutError, "HTTP total response timeout exceeded") { yield }
|
|
324
356
|
end
|
|
325
357
|
|
|
326
358
|
def parse_document(body, content_type, url)
|
|
@@ -9,7 +9,8 @@ module UTCP
|
|
|
9
9
|
perform_request(
|
|
10
10
|
parts[:method], parts[:uri], headers: parts[:headers], cookies: parts[:cookies],
|
|
11
11
|
body: parts[:body], content_type: parts[:content_type], timeout: parts[:timeout],
|
|
12
|
-
sensitive_headers: parts[:sensitive_headers]
|
|
12
|
+
sensitive_headers: parts[:sensitive_headers], max_response_bytes: template.max_response_bytes,
|
|
13
|
+
deadline: response_deadline(template.total_timeout || parts[:timeout])
|
|
13
14
|
)
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -31,9 +32,12 @@ module UTCP
|
|
|
31
32
|
JSON.generate(parts[:body]) : parts[:body].to_s
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
with_response_timeout(template.total_timeout) do
|
|
36
|
+
send_stream_request(parts[:uri], request, parts[:timeout]) do |response|
|
|
37
|
+
limited = LimitedHTTPResponse.new(response, template.max_response_bytes)
|
|
38
|
+
validate_stream_response!(limited)
|
|
39
|
+
yield limited
|
|
40
|
+
end
|
|
37
41
|
end
|
|
38
42
|
rescue Net::OpenTimeout, Net::ReadTimeout => error
|
|
39
43
|
raise TimeoutError, "Streaming HTTP request timed out: #{error.message}"
|
|
@@ -43,6 +47,10 @@ module UTCP
|
|
|
43
47
|
raise ToolCallError, "Streaming HTTP request failed: #{error.message}"
|
|
44
48
|
end
|
|
45
49
|
|
|
50
|
+
def with_collection_timeout(template, &block)
|
|
51
|
+
with_response_timeout(template.total_timeout || protocol_timeout_seconds(template, false), &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
46
54
|
def http_parts(template, arguments, discovery:, accept: nil)
|
|
47
55
|
headers = Utils.stringify_keys(template.headers || {})
|
|
48
56
|
headers["Accept"] = accept if accept
|
|
@@ -100,6 +108,7 @@ module UTCP
|
|
|
100
108
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
101
109
|
http.open_timeout = [Float(timeout), 10].min
|
|
102
110
|
http.read_timeout = Float(timeout)
|
|
111
|
+
http.write_timeout = Float(timeout) if http.respond_to?(:write_timeout=)
|
|
103
112
|
http.start do |connection|
|
|
104
113
|
connection.request(request) { |response| yield response }
|
|
105
114
|
end
|