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.
@@ -7,12 +7,14 @@ module UTCP
7
7
  MAX_MESSAGE_BYTES = 16 * 1024 * 1024
8
8
  MAX_STDERR_BYTES = 64 * 1024
9
9
 
10
- def initialize(config, timeout, max_message_bytes: MAX_MESSAGE_BYTES)
10
+ def initialize(config, timeout, max_message_bytes: MAX_MESSAGE_BYTES, max_response_bytes: nil)
11
11
  @timeout = Float(timeout)
12
12
  raise ValidationError, "MCP timeout must be finite and greater than zero" unless @timeout.finite? && @timeout.positive?
13
13
 
14
14
  @max_message_bytes = Integer(max_message_bytes)
15
15
  raise ValidationError, "MCP message limit must be greater than zero" unless @max_message_bytes.positive?
16
+ @max_response_bytes = max_response_bytes.nil? ? @max_message_bytes : Integer(max_response_bytes)
17
+ raise ValidationError, "MCP response limit must be greater than zero" unless @max_response_bytes.positive?
16
18
 
17
19
  command = config["command"]
18
20
  command = command.first if command.is_a?(Array)
@@ -41,8 +43,9 @@ module UTCP
41
43
  message = { "jsonrpc" => "2.0", "id" => identifier, "method" => method }
42
44
  message["params"] = params unless params.nil?
43
45
  write_message(message, deadline)
46
+ budget = ResponseByteBudget.new(@max_response_bytes, "MCP stdio")
44
47
  loop do
45
- response = read_message(deadline)
48
+ response = read_message(deadline, budget)
46
49
  next unless response["id"] == identifier
47
50
  raise ToolCallError, "MCP error #{response["error"].inspect}" if response["error"]
48
51
 
@@ -99,20 +102,22 @@ module UTCP
99
102
  raise ToolCallError, "MCP stdio write failed: #{error.message}"
100
103
  end
101
104
 
102
- def read_message(deadline)
105
+ def read_message(deadline, budget = ResponseByteBudget.new(@max_response_bytes, "MCP stdio"))
103
106
  loop do
104
107
  remaining_time(deadline)
105
108
  if (index = @read_buffer.index("\n"))
106
- response = JSON.parse(@read_buffer.slice!(0, index + 1))
109
+ bytes = @read_buffer.slice!(0, index + 1)
110
+ budget.consume(bytes)
111
+ response = JSON.parse(bytes)
107
112
  raise SerializerValidationError, "MCP stdio response must be an object" unless response.is_a?(Hash)
108
113
 
109
114
  return response
110
115
  end
111
- if @read_buffer.bytesize >= @max_message_bytes
112
- raise SerializerValidationError, "MCP stdio message exceeds #{@max_message_bytes} bytes"
116
+ if @read_buffer.bytesize >= budget.remaining
117
+ raise SerializerValidationError, "MCP stdio message exceeds #{@max_response_bytes} bytes (max_response_bytes)"
113
118
  end
114
119
 
115
- chunk = @stdout.read_nonblock([4096, @max_message_bytes - @read_buffer.bytesize].min, exception: false)
120
+ chunk = @stdout.read_nonblock([4096, budget.remaining - @read_buffer.bytesize].min, exception: false)
116
121
  case chunk
117
122
  when :wait_readable then wait_for_io(deadline)
118
123
  when nil then raise ToolCallError, "MCP stdio server closed the stream"
@@ -211,7 +216,7 @@ module UTCP
211
216
  values = []
212
217
  parser = SSEParser.new
213
218
  parser.feed(body) { |value| values << value }
214
- parser.finish { |value| values << value }
219
+ parser.finish
215
220
  values
216
221
  end
217
222
  end
@@ -229,10 +234,11 @@ module UTCP
229
234
  assert_mcp_template!(template)
230
235
  tools = []
231
236
  errors = []
237
+ budget = ResponseByteBudget.new(template.max_response_bytes, "MCP discovery")
232
238
  template.servers.each do |server_name, config|
233
239
  begin
234
240
  session = session_for(client, template, server_name, config)
235
- each_list_item(session, "tools/list", "tools") do |tool|
241
+ each_list_item(session, "tools/list", "tools", budget) do |tool|
236
242
  tools << Tool.new(
237
243
  name: "#{server_name}.#{tool.fetch("name")}",
238
244
  description: tool["description"].to_s,
@@ -241,7 +247,7 @@ module UTCP
241
247
  tool_call_template: template
242
248
  )
243
249
  end
244
- add_resource_tools(client, template, server_name, session, tools) if template.register_resources_as_tools
250
+ add_resource_tools(client, template, server_name, session, tools, budget) if template.register_resources_as_tools
245
251
  rescue StandardError => error
246
252
  errors << "#{server_name}: #{error.message}"
247
253
  end
@@ -280,6 +286,7 @@ module UTCP
280
286
  else
281
287
  session.request("tools/call", "name" => local_name, "arguments" => Utils.stringify_keys(tool_args || {}))
282
288
  end
289
+ ResponseByteBudget.new(template.max_response_bytes, "MCP").consume_value(result)
283
290
  process_mcp_result(result, tool_name: tool_name)
284
291
  rescue Error
285
292
  raise
@@ -308,7 +315,7 @@ module UTCP
308
315
  body: message,
309
316
  content_type: "application/json",
310
317
  timeout: template.timeout,
311
- sensitive_headers: sensitive.uniq
318
+ sensitive_headers: sensitive.uniq, max_response_bytes: template.max_response_bytes
312
319
  )
313
320
  {
314
321
  body: response.body.to_s,
@@ -330,7 +337,7 @@ module UTCP
330
337
  assert_no_auth!(template, context: "MCP stdio") unless http_transport || @session_factory
331
338
  # A session belongs to the credentials and endpoint used to initialize it.
332
339
  fingerprint = Digest::SHA256.hexdigest(JSON.generate([
333
- config, template.auth&.to_h, template.protocol_version, template.timeout
340
+ config, template.auth&.to_h, template.protocol_version, template.timeout, template.max_response_bytes
334
341
  ]))
335
342
  key = [client, template.name, server_name, fingerprint]
336
343
  @sessions_mutex.synchronize do
@@ -343,7 +350,7 @@ module UTCP
343
350
  elsif http_transport
344
351
  MCPHTTPSession.new(server_config, snapshot, self)
345
352
  else
346
- MCPStdioSession.new(server_config, snapshot.timeout)
353
+ MCPStdioSession.new(server_config, snapshot.timeout, max_response_bytes: snapshot.max_response_bytes)
347
354
  end
348
355
  begin
349
356
  initialize_session(session, snapshot)
@@ -356,20 +363,22 @@ module UTCP
356
363
  end
357
364
 
358
365
  def initialize_session(session, template)
359
- session.request("initialize", {
366
+ response = session.request("initialize", {
360
367
  "protocolVersion" => template.protocol_version,
361
368
  "capabilities" => {},
362
369
  "clientInfo" => { "name" => "ruby-utcp", "version" => VERSION }
363
370
  })
371
+ ResponseByteBudget.new(template.max_response_bytes, "MCP initialization").consume_value(response)
364
372
  session.notify("notifications/initialized", {})
365
373
  end
366
374
 
367
- def each_list_item(session, method, collection)
375
+ def each_list_item(session, method, collection, budget)
368
376
  cursor = nil
369
377
  seen = {}
370
378
  loop do
371
379
  params = cursor.nil? ? {} : { "cursor" => cursor }
372
380
  result = Utils.hash!(session.request(method, params) || {}, "MCP #{method} result")
381
+ budget.consume_value(result)
373
382
  Utils.array!(result.fetch(collection, []), "MCP #{collection}").each { |item| yield item }
374
383
  cursor = result["nextCursor"]
375
384
  break if cursor.nil?
@@ -381,8 +390,8 @@ module UTCP
381
390
  end
382
391
  end
383
392
 
384
- def add_resource_tools(client, template, server_name, session, tools)
385
- each_list_item(session, "resources/list", "resources") do |resource|
393
+ def add_resource_tools(client, template, server_name, session, tools, budget)
394
+ each_list_item(session, "resources/list", "resources", budget) do |resource|
386
395
  safe_name = resource.fetch("name", resource.fetch("uri")).to_s.gsub(/[^[:alnum:]_]/, "_")
387
396
  local_name = "resource_#{safe_name}"
388
397
  @sessions_mutex.synchronize do
@@ -16,7 +16,9 @@ module UTCP
16
16
 
17
17
  def call_tool(client, tool_name, tool_args, template)
18
18
  values = []
19
- call_tool_streaming(client, tool_name, tool_args, template) { |value| values << value }
19
+ with_collection_timeout(template) do
20
+ call_tool_streaming(client, tool_name, tool_args, template) { |value| values << value }
21
+ end
20
22
  values
21
23
  end
22
24
 
@@ -25,11 +27,16 @@ module UTCP
25
27
 
26
28
  assert_sse_template!(template)
27
29
  with_stream_response(template, tool_args || {}, accept: "text/event-stream") do |response|
28
- parser = SSEParser.new(event_type: template.event_type)
30
+ parser = SSEParser.new(event_type: template.event_type, max_event_bytes: template.max_event_bytes)
31
+ count = 0
29
32
  response.read_body do |chunk|
30
- parser.feed(chunk) { |event| yield event }
33
+ parser.feed(chunk) do |event|
34
+ count += 1
35
+ raise ToolCallError, "SSE response exceeds max_response_items" if count > template.max_response_items
36
+ yield event
37
+ end
31
38
  end
32
- parser.finish { |event| yield event }
39
+ parser.finish
33
40
  end
34
41
  rescue Error
35
42
  raise
@@ -47,30 +54,57 @@ module UTCP
47
54
  end
48
55
 
49
56
  class SSEParser
50
- def initialize(event_type: nil)
57
+ def initialize(event_type: nil, max_event_bytes: ResponseLimits::DEFAULT_MAX_EVENT_BYTES)
51
58
  @event_type = event_type
52
- @buffer = +""
59
+ @maximum = Integer(max_event_bytes)
60
+ raise ValidationError, "max_event_bytes must be greater than zero" unless @maximum.positive?
61
+ @buffer = +"".b
62
+ @skip_lf = false
63
+ @first_line = true
64
+ @event_bytes = 0
53
65
  @fields = reset_fields
54
66
  end
55
67
 
56
68
  def feed(chunk)
57
- @buffer << chunk.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
58
- while (index = @buffer.index("\n"))
59
- line = @buffer.slice!(0..index).chomp
60
- process_line(line) { |event| yield event }
69
+ bytes = chunk.to_s.b
70
+ offset = 0
71
+ while offset < bytes.bytesize
72
+ if @skip_lf
73
+ @skip_lf = false
74
+ offset += 1 if bytes.getbyte(offset) == 10
75
+ next if offset == bytes.bytesize
76
+ end
77
+ index = bytes.index(/[\r\n]/, offset)
78
+ length = (index || bytes.bytesize) - offset
79
+ @event_bytes += length
80
+ raise ToolCallError, "SSE event exceeds max_event_bytes (#{@maximum})" if @event_bytes > @maximum
81
+ @buffer << bytes.byteslice(offset, length)
82
+ break unless index
83
+
84
+ line = @buffer
85
+ @buffer = +"".b
86
+ if @first_line
87
+ line = line.delete_prefix("\xEF\xBB\xBF".b)
88
+ @first_line = false
89
+ end
90
+ @skip_lf = bytes.getbyte(index) == 13
91
+ offset = index + 1
92
+ process_line(line.force_encoding(Encoding::UTF_8)) { |event| yield event }
61
93
  end
62
94
  end
63
95
 
64
96
  def finish
65
- process_line(@buffer) { |event| yield event } unless @buffer.empty?
66
- dispatch { |event| yield event } unless @fields[:data].empty?
97
+ # EOF is not an event delimiter; discard an unfinished event.
67
98
  @buffer.clear
99
+ @fields = reset_fields
100
+ @event_bytes = 0
68
101
  end
69
102
 
70
103
  private
71
104
 
72
105
  def process_line(line)
73
106
  if line.empty?
107
+ @event_bytes = 0
74
108
  dispatch { |event| yield event }
75
109
  return
76
110
  end
@@ -79,7 +113,9 @@ module UTCP
79
113
  field, value = line.split(":", 2)
80
114
  value = value.to_s.sub(/\A /, "")
81
115
  case field
82
- when "data" then @fields[:data] << value
116
+ when "data"
117
+ @fields[:has_data] = true
118
+ @fields[:data] << value << "\n"
83
119
  when "event" then @fields[:event] = value
84
120
  when "id" then @fields[:id] = value unless value.include?("\0")
85
121
  when "retry" then @fields[:retry] = Integer(value) rescue nil
@@ -89,17 +125,20 @@ module UTCP
89
125
  def dispatch
90
126
  fields = @fields
91
127
  @fields = reset_fields
92
- return if fields[:data].empty?
128
+ return unless fields[:has_data]
93
129
  return if @event_type && fields[:event] != @event_type
94
130
 
95
- payload = fields[:data].join("\n")
96
- yield JSON.parse(payload)
97
- rescue JSON::ParserError
98
- yield payload
131
+ payload = fields[:data].delete_suffix("\n")
132
+ value = begin
133
+ JSON.parse(payload)
134
+ rescue JSON::ParserError
135
+ payload
136
+ end
137
+ yield value
99
138
  end
100
139
 
101
140
  def reset_fields
102
- { data: [], event: nil, id: nil, retry: nil }
141
+ { data: +"", has_data: false, event: nil, id: nil, retry: nil }
103
142
  end
104
143
  end
105
144
  SseCommunicationProtocol = SSEProtocol
@@ -16,9 +16,11 @@ module UTCP
16
16
  def call_tool(client, tool_name, tool_args, template)
17
17
  chunks = []
18
18
  binary = false
19
- call_tool_streaming(client, tool_name, tool_args, template) do |chunk|
20
- binary ||= chunk.is_a?(String) && chunk.encoding == Encoding::BINARY
21
- chunks << chunk
19
+ with_collection_timeout(template) do
20
+ call_tool_streaming(client, tool_name, tool_args, template) do |chunk|
21
+ binary ||= chunk.is_a?(String) && chunk.encoding == Encoding::BINARY
22
+ chunks << chunk
23
+ end
22
24
  end
23
25
  binary && chunks.all? { |chunk| chunk.is_a?(String) } ? chunks.join.b : chunks
24
26
  end
@@ -27,21 +29,32 @@ module UTCP
27
29
  return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
28
30
 
29
31
  assert_stream_template!(template)
32
+ count = 0
33
+ emit = lambda do |item|
34
+ count += 1
35
+ raise ToolCallError, "Streamable HTTP response exceeds max_response_items" if count > template.max_response_items
36
+ yield item
37
+ end
30
38
  with_stream_response(template, tool_args || {}) do |response|
31
39
  content_type = response["content-type"].to_s.downcase
32
40
  if content_type.include?("application/x-ndjson") || content_type.include?("application/json-seq")
33
- stream_json_lines(response) { |item| yield item }
41
+ separator = content_type.include?("application/json-seq") ? "\x1E" : "\n"
42
+ stream_json_lines(response, template.max_event_bytes, separator, &emit)
34
43
  elsif content_type.include?("application/json")
35
44
  body = +""
36
- response.read_body { |chunk| body << chunk }
37
- yield decode_json_or_text(body) unless body.empty?
45
+ response.read_body do |chunk|
46
+ check_event_size!(body.bytesize + chunk.bytesize, template.max_event_bytes)
47
+ body << chunk
48
+ end
49
+ emit.call(decode_json_or_text(body)) unless body.empty?
38
50
  else
39
51
  response.read_body do |chunk|
40
52
  bytes = chunk.to_s.b
41
53
  offset = 0
42
54
  while offset < bytes.bytesize
43
- yield bytes.byteslice(offset, template.chunk_size)
44
- offset += template.chunk_size
55
+ size = [template.chunk_size, template.max_event_bytes].min
56
+ emit.call(bytes.byteslice(offset, size))
57
+ offset += size
45
58
  end
46
59
  end
47
60
  end
@@ -60,17 +73,30 @@ module UTCP
60
73
  raise ValidationError, "streamable HTTP protocol requires a StreamableHttpCallTemplate"
61
74
  end
62
75
 
63
- def stream_json_lines(response)
64
- buffer = +""
76
+ def stream_json_lines(response, maximum, separator = "\n")
77
+ buffer = +"".b
65
78
  response.read_body do |chunk|
66
- buffer << chunk.to_s
67
- while (index = buffer.index("\n"))
68
- line = buffer.slice!(0..index).strip.sub(/\A\x1E/, "")
69
- yield decode_json_or_text(line) unless line.empty?
79
+ bytes = chunk.to_s.b
80
+ offset = 0
81
+ while offset < bytes.bytesize
82
+ index = bytes.index(separator, offset)
83
+ length = (index || bytes.bytesize) - offset
84
+ check_event_size!(buffer.bytesize + length, maximum)
85
+ buffer << bytes.byteslice(offset, length)
86
+ break unless index
87
+
88
+ line = buffer.strip
89
+ buffer = +"".b
90
+ yield decode_json_or_text(line.force_encoding(Encoding::UTF_8)) unless line.empty?
91
+ offset = index + 1
70
92
  end
71
93
  end
72
- tail = buffer.strip.sub(/\A\x1E/, "")
73
- yield decode_json_or_text(tail) unless tail.empty?
94
+ tail = buffer.strip
95
+ yield decode_json_or_text(tail.force_encoding(Encoding::UTF_8)) unless tail.empty?
96
+ end
97
+
98
+ def check_event_size!(size, maximum)
99
+ raise ToolCallError, "Streamable HTTP event exceeds max_event_bytes (#{maximum})" if size > maximum
74
100
  end
75
101
  end
76
102
  StreamableHttpCommunicationProtocol = StreamableHTTPProtocol
@@ -71,6 +71,7 @@ module UTCP
71
71
 
72
72
  def read_framed(socket, template, timeout)
73
73
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
74
+ maximum = [template.max_response_size, template.max_response_bytes].min
74
75
  case template.framing_strategy
75
76
  when "length_prefix"
76
77
  prefix = read_exact(socket, template.length_prefix_bytes, deadline)
@@ -81,16 +82,19 @@ module UTCP
81
82
  [8, "big"] => "Q>", [8, "little"] => "Q<"
82
83
  }.fetch([template.length_prefix_bytes, template.length_prefix_endian])
83
84
  length = prefix.unpack1(unpack)
84
- raise ToolCallError, "TCP response exceeds max_response_size" if length > template.max_response_size
85
+ raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size" if length > maximum
85
86
 
86
87
  read_exact(socket, length, deadline)
87
88
  when "delimiter"
88
89
  read_until(socket, escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences),
89
- template.max_response_size, deadline)
90
+ maximum, deadline)
90
91
  when "fixed_length"
92
+ if template.fixed_message_length > maximum
93
+ raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
94
+ end
91
95
  read_exact(socket, template.fixed_message_length, deadline)
92
96
  when "stream"
93
- read_stream(socket, template.max_response_size, deadline)
97
+ read_stream(socket, maximum, deadline)
94
98
  end
95
99
  end
96
100
 
@@ -112,25 +116,33 @@ module UTCP
112
116
  raise ValidationError, "message_delimiter cannot be empty" if delimiter.empty?
113
117
 
114
118
  result = +"".b
115
- until result.end_with?(delimiter)
116
- raise ToolCallError, "TCP response exceeds max_response_size" if result.bytesize >= maximum
119
+ loop do
120
+ if (index = result.index(delimiter))
121
+ raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size" if index > maximum
122
+ return result.byteslice(0, index)
123
+ end
124
+ if result.bytesize >= maximum + delimiter.bytesize
125
+ raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
126
+ end
117
127
 
118
128
  wait_readable!(socket, deadline, "TCP read")
119
- result << socket.readpartial([4096, maximum - result.bytesize].min)
129
+ result << socket.readpartial([4096, maximum + delimiter.bytesize - result.bytesize].min)
120
130
  end
121
- result.byteslice(0, result.bytesize - delimiter.bytesize)
122
131
  rescue EOFError
123
132
  raise ToolCallError, "TCP connection closed before the message delimiter"
124
133
  end
125
134
 
126
135
  def read_stream(socket, maximum, deadline)
127
136
  result = +"".b
128
- while result.bytesize < maximum
137
+ loop do
129
138
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
139
  break unless remaining.positive? && IO.select([socket], nil, nil, remaining)
131
140
 
132
141
  begin
133
- result << socket.readpartial([4096, maximum - result.bytesize].min)
142
+ result << socket.readpartial([4096, maximum + 1 - result.bytesize].min)
143
+ if result.bytesize > maximum
144
+ raise ToolCallError, "TCP response exceeds max_response_bytes or max_response_size"
145
+ end
134
146
  rescue EOFError
135
147
  break
136
148
  end
@@ -42,9 +42,11 @@ module UTCP
42
42
  return nil if response_count.zero?
43
43
 
44
44
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + socket_timeout_seconds(template)
45
+ budget = ResponseByteBudget.new(template.max_response_bytes, "UDP")
45
46
  values = response_count.times.map do
46
47
  wait_readable!(socket, deadline, "UDP read")
47
48
  payload = socket.recv(65_535)
49
+ budget.consume(payload)
48
50
  decode_socket_payload(payload, template.response_byte_format)
49
51
  end
50
52
  values.length == 1 ? values.first : values