ruby-utcp 1.1.1 → 1.1.2

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: 99c878fb7098dd9c213ffa10c01349acf87fb03f6e5742f9214ec077a3502c0b
4
- data.tar.gz: 0dfd532e70fde83d025aa0a9c6282c460a26b2c333cb917e6951d62ac979c780
3
+ metadata.gz: c041cd63497e9162042ccdd75ef1adad0bd1b3e8c7fe13ca67d84519bcced0c8
4
+ data.tar.gz: 2ff6634e287d5b313f58de19de4c70c64c626d894e939cad2246339f2683c12a
5
5
  SHA512:
6
- metadata.gz: fa88cea89f5be786a37ba117ee308e301d760d55b76d9d409be03a1370934cc6b11a534d80b16d5c2c335d0dcc4ea82358ee7dda6a6eaaff768a2f7ecf8f7f68
7
- data.tar.gz: 6eeb7f901ca0e58de3d9f33f8ca92bf990ea8e547aad89e5b28a17a7337ed491ceef0bf747710fa81c26092706550ebb0407e849e38e1ed3ee0d6dbfb30c312e
6
+ metadata.gz: 37b298c327a546cfa35799045ad2450c76cdfba4ad3cd8aee9d6fa79206d70667bfdec596654bb37cbdc4a25029732eb116943ce06cffbbd75c977dc18d3a5c5
7
+ data.tar.gz: 1b131b7c326f7eeb8f4e100d11160dac9d56e0f766cbb315ae4d2916f1351364b3f2cc39a81183e7b766634a06b2f9cecff1b49f26cb10b22f24c6ecdad88e40
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Isolate MCP sessions and resource mappings between clients and clean up failed registrations.
6
+ - Follow MCP tool/resource pagination and raise `ToolCallError` for tool failures, preserving the original payload.
7
+ - Enforce stdio deadlines across partial reads, notifications, and blocked writes; bound message and stderr buffers.
8
+ - Bound Code Mode integer powers, products, and numeric results, including aggregate tool-result budgets.
9
+ - Declare `base64` and `logger` runtime dependencies for Bundler compatibility.
10
+ - Add regression tests, coverage checks, a Ruby version CI matrix, and local transport integration checks.
11
+
3
12
  ## 1.1.0
4
13
 
5
14
  - Initial Ruby implementation of the UTCP 1.1 client and data model.
data/README.md CHANGED
@@ -16,10 +16,10 @@ Or build this checkout:
16
16
 
17
17
  ```sh
18
18
  gem build ruby-utcp.gemspec
19
- gem install ./ruby-utcp-1.1.0.gem
19
+ gem install "./ruby-utcp-$(ruby -Ilib -rutcp/version -e 'print UTCP::VERSION').gem"
20
20
  ```
21
21
 
22
- Ruby 2.6 or newer is supported for the core and 11 transports. The WebRTC default backend uses the optional `webrtc-ruby` gem, which requires Ruby 3.1 and `libdatachannel`; an application can instead inject its own peer adapter. The gRPC transport similarly loads the optional `grpc` gem only when used. There are no mandatory runtime dependencies outside Ruby's standard library.
22
+ Ruby 2.6 or newer is supported for the core and 11 transports. The WebRTC default backend uses the optional `webrtc-ruby` gem, which requires Ruby 3.1 and `libdatachannel`; an application can instead inject its own peer adapter. The gRPC transport similarly loads the optional `grpc` gem only when used. `base64` and `logger` are declared runtime dependencies so Bundler can load them on Ruby versions where they are distributed as separate gems; the remaining core libraries come with Ruby.
23
23
 
24
24
  ## Quick start
25
25
 
@@ -229,6 +229,10 @@ MCP sessions implement initialization, notifications, `tools/list`, `tools/call`
229
229
  }
230
230
  ```
231
231
 
232
+ 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`.
233
+
234
+ 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.
235
+
232
236
  ### WebRTC
233
237
 
234
238
  WebRTC follows the reference signaling contract: `POST /connect` exchanges SDP and returns `sdp`, `candidates`, and `tools`; `POST /candidate` exchanges ICE candidates. DataChannel requests are JSON envelopes containing `id`, `tool`, and `args`, and responses correlate the same `id` with `result`.
@@ -311,6 +315,8 @@ Use `codemode.call_tool_stream` to collect a streaming call into an array. `code
311
315
 
312
316
  Code Mode accepts a constrained Ruby subset for local variables, JSON-like literals, arithmetic, conditionals, loops, indexing, and common collection transforms. It is interpreted without `eval`; filesystem, process, constant, reflection, import, and direct network APIs are not exposed. Executions also have code-size, step, result-size, log-size, and wall-clock limits. External effects remain possible through the UTCP tools that you deliberately register.
313
317
 
318
+ The 1 MiB value budget includes numeric payloads as well as strings. Integer powers and products are checked before allocating their results, and non-finite floating-point results are rejected. Power checks use a conservative size estimate and can reject expressions close to the limit.
319
+
314
320
  ## Custom protocol plugins
315
321
 
316
322
  Register a call-template class and a protocol implementation before creating a client:
@@ -348,6 +354,11 @@ UTCP.register_protocol("queue", QueueProtocol.new)
348
354
  ## Development
349
355
 
350
356
  ```sh
351
- rake test
357
+ bundle install
358
+ bundle exec rake test
359
+ bundle exec rake coverage
360
+ bundle exec make standard-demo
352
361
  gem build ruby-utcp.gemspec
353
362
  ```
363
+
364
+ CI runs the tests and gem build on Ruby 2.6, 2.7, 3.0–3.4, and 4.0. A separate Ruby 3.4 job runs the local transport examples and enforces minimum coverage of 75% of executable lines and 45% of branches. The standard demo uses WEBrick, included as a development dependency; native gRPC/WebRTC backends remain optional.
@@ -14,6 +14,7 @@ module UTCP
14
14
  MAX_LOG_BYTES = 1024 * 1024
15
15
  MAX_VALUE_BYTES = 1024 * 1024
16
16
  MAX_VALUE_ITEMS = 10_000
17
+ MAX_INTEGER_BITS = MAX_VALUE_BYTES * 8
17
18
 
18
19
  attr_reader :client
19
20
 
@@ -611,7 +612,7 @@ module UTCP
611
612
  raise CodeModeSyntaxError, "Method #{method_name.inspect} is not available in Code Mode"
612
613
  end
613
614
 
614
- invoke_safe_value_method(receiver, method_name, arguments, block)
615
+ bounded_value!(invoke_safe_value_method(receiver, method_name, arguments, block))
615
616
  end
616
617
 
617
618
  def invoke_runtime_api(method_name, arguments, block)
@@ -827,7 +828,14 @@ module UTCP
827
828
  end
828
829
 
829
830
  def preflight_binary_size!(left, operator, right)
830
- if operator == :+ && left.is_a?(String) && right.is_a?(String)
831
+ if operator == :** && right.is_a?(Integer)
832
+ bases = left.is_a?(Rational) ? [left.numerator, left.denominator] : [left]
833
+ bases.each { |base| preflight_integer_power!(base, right) if base.is_a?(Integer) }
834
+ elsif operator == :* && left.is_a?(Integer) && right.is_a?(Integer)
835
+ if !left.zero? && !right.zero? && left.abs.bit_length + right.abs.bit_length > MAX_INTEGER_BITS
836
+ raise CodeModeLimitError, "Integer product exceeds #{MAX_VALUE_BYTES} bytes"
837
+ end
838
+ elsif operator == :+ && left.is_a?(String) && right.is_a?(String)
831
839
  raise CodeModeLimitError, "String result exceeds #{MAX_VALUE_BYTES} bytes" if left.bytesize + right.bytesize > MAX_VALUE_BYTES
832
840
  elsif operator == :+ && left.is_a?(Array) && right.is_a?(Array)
833
841
  raise CodeModeLimitError, "Collection result exceeds #{MAX_VALUE_ITEMS} items" if left.length + right.length > MAX_VALUE_ITEMS
@@ -838,7 +846,34 @@ module UTCP
838
846
  end
839
847
  end
840
848
 
849
+ def preflight_integer_power!(base, exponent)
850
+ magnitude = base.abs
851
+ return if magnitude <= 1 || exponent.zero?
852
+
853
+ # Use an upper bound without constructing the potentially enormous result.
854
+ bits = magnitude.bit_length
855
+ bits -= 1 if (magnitude & (magnitude - 1)).zero?
856
+ if exponent.abs > (MAX_INTEGER_BITS - 1) / bits
857
+ raise CodeModeLimitError, "Integer power exceeds #{MAX_VALUE_BYTES} bytes"
858
+ end
859
+ end
860
+
861
+ def numeric_bytes(value)
862
+ case value
863
+ when Integer then [(value.abs.bit_length + 7) / 8, 1].max
864
+ when Rational then numeric_bytes(value.numerator) + numeric_bytes(value.denominator)
865
+ when Complex then numeric_bytes(value.real) + numeric_bytes(value.imaginary)
866
+ when Float
867
+ raise CodeModeLimitError, "Numeric result must be finite" unless value.finite?
868
+ 8
869
+ else value.to_s.bytesize
870
+ end
871
+ end
872
+
841
873
  def bounded_value!(value)
874
+ if value.is_a?(Numeric) && numeric_bytes(value) > MAX_VALUE_BYTES
875
+ raise CodeModeLimitError, "Numeric result exceeds #{MAX_VALUE_BYTES} bytes"
876
+ end
842
877
  if value.is_a?(String) && value.bytesize > MAX_VALUE_BYTES
843
878
  raise CodeModeLimitError, "String result exceeds #{MAX_VALUE_BYTES} bytes"
844
879
  end
@@ -881,7 +916,11 @@ module UTCP
881
916
  budget[:bytes] -= value.bytesize
882
917
  raise CodeModeLimitError, "Tool result contains too much string data" if budget[:bytes].negative?
883
918
  value.dup
884
- when nil, true, false, Numeric
919
+ when Numeric
920
+ budget[:bytes] -= numeric_bytes(value)
921
+ raise CodeModeLimitError, "Tool result contains too much numeric data" if budget[:bytes].negative?
922
+ value
923
+ when nil, true, false
885
924
  value
886
925
  when Array
887
926
  value.map { |item| safe_tool_value(item, depth + 1, budget) }
@@ -4,7 +4,16 @@ require "open3"
4
4
 
5
5
  module UTCP
6
6
  class MCPStdioSession
7
- def initialize(config, timeout)
7
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024
8
+ MAX_STDERR_BYTES = 64 * 1024
9
+
10
+ def initialize(config, timeout, max_message_bytes: MAX_MESSAGE_BYTES)
11
+ @timeout = Float(timeout)
12
+ raise ValidationError, "MCP timeout must be finite and greater than zero" unless @timeout.finite? && @timeout.positive?
13
+
14
+ @max_message_bytes = Integer(max_message_bytes)
15
+ raise ValidationError, "MCP message limit must be greater than zero" unless @max_message_bytes.positive?
16
+
8
17
  command = config["command"]
9
18
  command = command.first if command.is_a?(Array)
10
19
  raise ValidationError, "MCP stdio server requires command" if command.to_s.empty?
@@ -14,20 +23,26 @@ module UTCP
14
23
  options = { pgroup: true }
15
24
  options[:chdir] = config["cwd"] || config["workingDir"] if config["cwd"] || config["workingDir"]
16
25
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(environment, command.to_s, *args, options)
17
- @timeout = timeout
26
+ @stdin.binmode
27
+ @stdout.binmode
28
+ @stderr.binmode
18
29
  @next_id = 0
19
30
  @mutex = Mutex.new
20
- @stderr_reader = Thread.new { @stderr.read }
31
+ @read_buffer = +"".b
32
+ @stderr_buffer = +"".b
33
+ @stderr_mutex = Mutex.new
34
+ @stderr_reader = Thread.new { drain_stderr }
21
35
  end
22
36
 
23
37
  def request(method, params = nil)
24
38
  @mutex.synchronize do
39
+ deadline = monotonic_now + @timeout
25
40
  identifier = (@next_id += 1)
26
41
  message = { "jsonrpc" => "2.0", "id" => identifier, "method" => method }
27
42
  message["params"] = params unless params.nil?
28
- write_message(message)
43
+ write_message(message, deadline)
29
44
  loop do
30
- response = read_message
45
+ response = read_message(deadline)
31
46
  next unless response["id"] == identifier
32
47
  raise ToolCallError, "MCP error #{response["error"].inspect}" if response["error"]
33
48
 
@@ -40,7 +55,7 @@ module UTCP
40
55
  @mutex.synchronize do
41
56
  message = { "jsonrpc" => "2.0", "method" => method }
42
57
  message["params"] = params unless params.nil?
43
- write_message(message)
58
+ write_message(message, monotonic_now + @timeout)
44
59
  end
45
60
  nil
46
61
  end
@@ -50,34 +65,94 @@ module UTCP
50
65
  Process.kill("TERM", -@wait_thread.pid) if @wait_thread&.alive?
51
66
  @wait_thread.join(1) if @wait_thread
52
67
  Process.kill("KILL", -@wait_thread.pid) if @wait_thread&.alive?
68
+ @wait_thread.join(1) if @wait_thread
53
69
  rescue Errno::ESRCH, Errno::EPERM, IOError
54
70
  nil
55
71
  ensure
56
72
  @stdout.close unless @stdout.closed?
73
+ @stderr_reader.join(1) if @stderr_reader
57
74
  @stderr.close unless @stderr.closed?
58
75
  @stderr_reader.kill if @stderr_reader&.alive?
59
76
  end
60
77
 
78
+ def stderr_output
79
+ @stderr_mutex.synchronize { @stderr_buffer.dup }
80
+ end
81
+
61
82
  private
62
83
 
63
- def write_message(message)
64
- @stdin.write(JSON.generate(message) + "\n")
65
- @stdin.flush
84
+ def write_message(message, deadline)
85
+ data = (JSON.generate(message) + "\n").b
86
+ raise SerializerValidationError, "MCP stdio message exceeds #{@max_message_bytes} bytes" if data.bytesize > @max_message_bytes
87
+
88
+ offset = 0
89
+ while offset < data.bytesize
90
+ remaining_time(deadline)
91
+ written = @stdin.write_nonblock(data.byteslice(offset, 4096), exception: false)
92
+ if written == :wait_writable
93
+ wait_for_io(deadline, writable: true)
94
+ else
95
+ offset += written
96
+ end
97
+ end
66
98
  rescue Errno::EPIPE, IOError => error
67
99
  raise ToolCallError, "MCP stdio write failed: #{error.message}"
68
100
  end
69
101
 
70
- def read_message
71
- ready = IO.select([@stdout], nil, nil, @timeout)
72
- raise TimeoutError, "MCP stdio response timed out" unless ready
102
+ def read_message(deadline)
103
+ loop do
104
+ remaining_time(deadline)
105
+ if (index = @read_buffer.index("\n"))
106
+ response = JSON.parse(@read_buffer.slice!(0, index + 1))
107
+ raise SerializerValidationError, "MCP stdio response must be an object" unless response.is_a?(Hash)
73
108
 
74
- line = @stdout.gets
75
- raise ToolCallError, "MCP stdio server closed the stream" unless line
109
+ return response
110
+ end
111
+ if @read_buffer.bytesize >= @max_message_bytes
112
+ raise SerializerValidationError, "MCP stdio message exceeds #{@max_message_bytes} bytes"
113
+ end
76
114
 
77
- JSON.parse(line)
115
+ chunk = @stdout.read_nonblock([4096, @max_message_bytes - @read_buffer.bytesize].min, exception: false)
116
+ case chunk
117
+ when :wait_readable then wait_for_io(deadline)
118
+ when nil then raise ToolCallError, "MCP stdio server closed the stream"
119
+ else @read_buffer << chunk
120
+ end
121
+ end
78
122
  rescue JSON::ParserError => error
79
123
  raise SerializerValidationError, "Invalid MCP stdio JSON: #{error.message}"
80
124
  end
125
+
126
+ def monotonic_now
127
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
128
+ end
129
+
130
+ def remaining_time(deadline)
131
+ remaining = deadline - monotonic_now
132
+ raise TimeoutError, "MCP stdio request timed out" unless remaining.positive?
133
+
134
+ remaining
135
+ end
136
+
137
+ def wait_for_io(deadline, writable: false)
138
+ readers, writers = writable ? [nil, [@stdin]] : [[@stdout], nil]
139
+ ready = IO.select(readers, writers, nil, remaining_time(deadline))
140
+ raise TimeoutError, "MCP stdio request timed out" unless ready
141
+ end
142
+
143
+ def drain_stderr
144
+ loop do
145
+ chunk = @stderr.readpartial(4096)
146
+ @stderr_mutex.synchronize do
147
+ @stderr_buffer << chunk
148
+ if @stderr_buffer.bytesize > MAX_STDERR_BYTES
149
+ @stderr_buffer = @stderr_buffer.byteslice(-MAX_STDERR_BYTES, MAX_STDERR_BYTES)
150
+ end
151
+ end
152
+ end
153
+ rescue EOFError, IOError
154
+ nil
155
+ end
81
156
  end
82
157
 
83
158
  class MCPHTTPSession
@@ -156,10 +231,9 @@ module UTCP
156
231
  errors = []
157
232
  template.servers.each do |server_name, config|
158
233
  begin
159
- session = session_for(template, server_name, config)
234
+ session = session_for(client, template, server_name, config)
160
235
  initialize_session(session, template)
161
- result = session.request("tools/list", {}) || {}
162
- Array(result["tools"]).each do |tool|
236
+ each_list_item(session, "tools/list", "tools") do |tool|
163
237
  tools << Tool.new(
164
238
  name: "#{server_name}.#{tool.fetch("name")}",
165
239
  description: tool["description"].to_s,
@@ -168,11 +242,12 @@ module UTCP
168
242
  tool_call_template: template
169
243
  )
170
244
  end
171
- add_resource_tools(template, server_name, session, tools) if template.register_resources_as_tools
245
+ add_resource_tools(client, template, server_name, session, tools) if template.register_resources_as_tools
172
246
  rescue StandardError => error
173
247
  errors << "#{server_name}: #{error.message}"
174
248
  end
175
249
  end
250
+ deregister_manual(client, template) unless errors.empty?
176
251
  manual = Manual.new(utcp_version: VERSION, manual_version: "1.0.0", tools: tools)
177
252
  RegisterManualResult.new(
178
253
  manual_call_template: template,
@@ -185,29 +260,28 @@ module UTCP
185
260
  failure(template, error)
186
261
  end
187
262
 
188
- def deregister_manual(_client, template)
189
- prefix = "#{template.name}\0"
263
+ def deregister_manual(client, template)
190
264
  sessions = @sessions_mutex.synchronize do
191
- keys = @sessions.keys.select { |key| key.start_with?(prefix) }
265
+ keys = @sessions.keys.select { |owner, name, _server| owner.equal?(client) && name == template.name }
266
+ @resources.delete_if { |(owner, name, _server, _resource), _value| owner.equal?(client) && name == template.name }
192
267
  keys.map { |key| @sessions.delete(key) }
193
268
  end
194
269
  sessions.each(&:close)
195
- @resources.delete_if { |key, _value| key.start_with?(prefix) }
196
270
  nil
197
271
  end
198
272
 
199
- def call_tool(_client, tool_name, tool_args, template)
273
+ def call_tool(client, tool_name, tool_args, template)
200
274
  assert_mcp_template!(template)
201
275
  server_name, local_name = parse_tool_name(tool_name, template)
202
276
  config = template.servers.fetch(server_name)
203
- session = session_for(template, server_name, config)
204
- resource_uri = @resources[resource_key(template, server_name, local_name)]
277
+ session = session_for(client, template, server_name, config)
278
+ resource_uri = @sessions_mutex.synchronize { @resources[resource_key(client, template, server_name, local_name)] }
205
279
  result = if resource_uri
206
280
  session.request("resources/read", "uri" => resource_uri)
207
281
  else
208
282
  session.request("tools/call", "name" => local_name, "arguments" => Utils.stringify_keys(tool_args || {}))
209
283
  end
210
- process_mcp_result(result)
284
+ process_mcp_result(result, tool_name: tool_name)
211
285
  rescue Error
212
286
  raise
213
287
  rescue StandardError => error
@@ -251,8 +325,8 @@ module UTCP
251
325
  raise ValidationError, "MCP protocol requires a McpCallTemplate"
252
326
  end
253
327
 
254
- def session_for(template, server_name, config)
255
- key = "#{template.name}\0#{server_name}"
328
+ def session_for(client, template, server_name, config)
329
+ key = [client, template.name, server_name]
256
330
  @sessions_mutex.synchronize do
257
331
  @sessions[key] ||= if @session_factory
258
332
  @session_factory.call(server_name, config, template)
@@ -273,30 +347,42 @@ module UTCP
273
347
  session.notify("notifications/initialized", {})
274
348
  end
275
349
 
276
- def add_resource_tools(template, server_name, session, tools)
350
+ def each_list_item(session, method, collection)
277
351
  cursor = nil
352
+ seen = {}
278
353
  loop do
279
- params = cursor ? { "cursor" => cursor } : {}
280
- result = session.request("resources/list", params) || {}
281
- Array(result["resources"]).each do |resource|
282
- safe_name = resource.fetch("name", resource.fetch("uri")).to_s.gsub(/[^[:alnum:]_]/, "_")
283
- local_name = "resource_#{safe_name}"
284
- @resources[resource_key(template, server_name, local_name)] = resource.fetch("uri")
285
- tools << Tool.new(
286
- name: "#{server_name}.#{local_name}",
287
- description: "Read MCP resource: #{resource["description"] || resource["name"] || resource["uri"]}",
288
- inputs: { "type" => "object", "properties" => {} },
289
- outputs: { "type" => "object" },
290
- tool_call_template: template
291
- )
292
- end
354
+ params = cursor.nil? ? {} : { "cursor" => cursor }
355
+ result = Utils.hash!(session.request(method, params) || {}, "MCP #{method} result")
356
+ Utils.array!(result.fetch(collection, []), "MCP #{collection}").each { |item| yield item }
293
357
  cursor = result["nextCursor"]
294
- break if cursor.nil? || cursor.empty?
358
+ break if cursor.nil?
359
+ unless cursor.is_a?(String) && !seen.key?(cursor)
360
+ raise SerializerValidationError, "MCP #{method} returned an invalid or repeated cursor"
361
+ end
362
+
363
+ seen[cursor] = true
364
+ end
365
+ end
366
+
367
+ def add_resource_tools(client, template, server_name, session, tools)
368
+ each_list_item(session, "resources/list", "resources") do |resource|
369
+ safe_name = resource.fetch("name", resource.fetch("uri")).to_s.gsub(/[^[:alnum:]_]/, "_")
370
+ local_name = "resource_#{safe_name}"
371
+ @sessions_mutex.synchronize do
372
+ @resources[resource_key(client, template, server_name, local_name)] = resource.fetch("uri")
373
+ end
374
+ tools << Tool.new(
375
+ name: "#{server_name}.#{local_name}",
376
+ description: "Read MCP resource: #{resource["description"] || resource["name"] || resource["uri"]}",
377
+ inputs: { "type" => "object", "properties" => {} },
378
+ outputs: { "type" => "object" },
379
+ tool_call_template: template
380
+ )
295
381
  end
296
382
  end
297
383
 
298
- def resource_key(template, server_name, local_name)
299
- "#{template.name}\0#{server_name}\0#{local_name}"
384
+ def resource_key(client, template, server_name, local_name)
385
+ [client, template.name, server_name, local_name]
300
386
  end
301
387
 
302
388
  def parse_tool_name(tool_name, template)
@@ -311,8 +397,15 @@ module UTCP
311
397
  end
312
398
  end
313
399
 
314
- def process_mcp_result(result)
400
+ def process_mcp_result(result, tool_name: nil)
315
401
  return result unless result.is_a?(Hash)
402
+ if result["isError"]
403
+ details = Array(result["content"]).select { |item| item.is_a?(Hash) && item["type"] == "text" }
404
+ .map { |item| item["text"].to_s }.join("\n")
405
+ message = "MCP tool #{tool_name.inspect} failed"
406
+ message += ": #{details}" unless details.empty?
407
+ raise ToolCallError.new(message, tool_name: tool_name, response_body: Utils.deep_copy(result))
408
+ end
316
409
  return result["structuredContent"] if result.key?("structuredContent")
317
410
  return result if result.key?("contents")
318
411
 
data/lib/utcp/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UTCP
4
- VERSION = "1.1.1"
4
+ VERSION = "1.1.2"
5
5
  end
6
6
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-utcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ruby-utcp contributors
@@ -9,6 +9,46 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '1.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '1.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: logger
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.4'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.4'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2.0'
12
52
  - !ruby/object:Gem::Dependency
13
53
  name: minitest
14
54
  requirement: !ruby/object:Gem::Requirement
@@ -49,6 +89,26 @@ dependencies:
49
89
  - - "<"
50
90
  - !ruby/object:Gem::Version
51
91
  version: '14.0'
92
+ - !ruby/object:Gem::Dependency
93
+ name: webrick
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '1.7'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '2.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '1.7'
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '2.0'
52
112
  description: Discover, search, and call UTCP 1.1 tools over pluggable native protocols.
53
113
  email:
54
114
  - maintainers@utcp.io