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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +14 -3
- data/lib/utcp/code_mode.rb +42 -3
- data/lib/utcp/protocols/mcp.rb +141 -48
- data/lib/utcp/version.rb +1 -1
- metadata +61 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c041cd63497e9162042ccdd75ef1adad0bd1b3e8c7fe13ca67d84519bcced0c8
|
|
4
|
+
data.tar.gz: 2ff6634e287d5b313f58de19de4c70c64c626d894e939cad2246339f2683c12a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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.
|
|
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
|
-
|
|
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.
|
data/lib/utcp/code_mode.rb
CHANGED
|
@@ -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 ==
|
|
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
|
|
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) }
|
data/lib/utcp/protocols/mcp.rb
CHANGED
|
@@ -4,7 +4,16 @@ require "open3"
|
|
|
4
4
|
|
|
5
5
|
module UTCP
|
|
6
6
|
class MCPStdioSession
|
|
7
|
-
|
|
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
|
-
@
|
|
26
|
+
@stdin.binmode
|
|
27
|
+
@stdout.binmode
|
|
28
|
+
@stderr.binmode
|
|
18
29
|
@next_id = 0
|
|
19
30
|
@mutex = Mutex.new
|
|
20
|
-
@
|
|
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
|
-
|
|
65
|
-
@
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
189
|
-
prefix = "#{template.name}\0"
|
|
263
|
+
def deregister_manual(client, template)
|
|
190
264
|
sessions = @sessions_mutex.synchronize do
|
|
191
|
-
keys = @sessions.keys.select { |
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
281
|
-
|
|
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?
|
|
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
|
-
|
|
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
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.
|
|
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
|