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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e5c4728819e312335ad446c2b453ceb45eafc6440d7926fd4116fac91852d4f
4
- data.tar.gz: 5902911212d47fe254285b3218e7b47338bedef182ec1561b5b778de8c354d99
3
+ metadata.gz: 30241b18fd3629b65595bd41881c8c6c0b9836547b47a139e6a3a32c56111f42
4
+ data.tar.gz: e19fa7d10eb36ca41bcec9286ed75438df01f4a0d5d820a4f0ba76fad202cf17
5
5
  SHA512:
6
- metadata.gz: 6631d90cb34d055462da1f31105380e5d61ba0b7bb73aaaf7494e5a7c5f826258e76b8cbb84bb7263ad73641866592e248fd94d47ef64d6b7ba4f7e0ed302a25
7
- data.tar.gz: 56f38514be76d1e9dd0565084e7c1d6febfed61dc45f1a3ff27b9ff6849827b8bb184c255e2ef8b5e8d273015890af70ec006a8280eb4dc8cf4b3c8f5571f49b
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. Messages are limited to 16 MiB, and stderr is drained while retaining only its last 64 KiB. Call `client.close` when finished to release server processes.
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 shutdown probes are opt-in with `UTCP_WEBRTC_SHUTDOWN_REGRESSIONS=1`; they require callback shutdown guarantees that the stock `webrtc-ruby` 1.0.0 release does not provide and are not part of the CI job. No WebRTC patches are installed or applied.
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
- attr_accessor :signaling_server, :peer_id, :data_channel_name, :timeout, :ice_servers
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] = resolve_schema(parameter["schema"] || {})
64
+ properties[body_field] = parameter["schema"] || {}
59
65
  required << body_field if parameter["required"]
60
66
  else
61
- properties[name] = resolve_schema(parameter["schema"] || parameter_schema(parameter))
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] = resolve_schema(media["schema"] || {})
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 resolve_schema(schema, seen = [])
109
- data = Utils.stringify_keys(schema || {})
110
- reference = data["$ref"]
111
- return data unless reference&.start_with?("#/")
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
- resolved = reference.sub(%r{\A#/}, "").split("/").reduce(@spec) do |node, component|
115
- break nil unless node.is_a?(Hash)
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
- node[component.gsub("~1", "/").gsub("~0", "~")]
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
- resolved ? resolve_schema(resolved, seen + [reference]) : data
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
- server = Array(@spec["servers"]).first
146
- return substitute_server_variables(server) if server.is_a?(Hash) && server["url"]
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
- uri = URI.parse(@spec_url)
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
- while (frame = connection.read_message)
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"
@@ -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
- ).each { |response| yield response }
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 { |response| yield decode_tool_response(response) }
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
@@ -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 = send_request(uri, request, timeout)
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.start { |connection| connection.request(request) }
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
- send_stream_request(parts[:uri], request, parts[:timeout]) do |response|
35
- validate_stream_response!(response)
36
- yield response
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