ruby-utcp 1.1.0
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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +22 -0
- data/Makefile +226 -0
- data/README.md +331 -0
- data/examples/basic.rb +33 -0
- data/examples/cli.rb +32 -0
- data/examples/generated/__init__.py +1 -0
- data/examples/generated/utcp_pb2.py +46 -0
- data/examples/generated/utcp_pb2_grpc.py +183 -0
- data/examples/graphql.rb +15 -0
- data/examples/grpc.rb +42 -0
- data/examples/grpc_python.py +52 -0
- data/examples/http.rb +17 -0
- data/examples/mcp.rb +28 -0
- data/examples/servers/graphql_server.rb +39 -0
- data/examples/servers/grpc_server.py +97 -0
- data/examples/servers/grpc_server.rb +62 -0
- data/examples/servers/http_helpers.rb +34 -0
- data/examples/servers/http_server.rb +28 -0
- data/examples/servers/mcp_stdio_server.rb +43 -0
- data/examples/servers/requirements-grpc.txt +2 -0
- data/examples/servers/sse_server.rb +36 -0
- data/examples/servers/streamable_http_server.rb +39 -0
- data/examples/servers/tcp_server.rb +58 -0
- data/examples/servers/udp_server.rb +33 -0
- data/examples/servers/webrtc_server.rb +78 -0
- data/examples/servers/websocket_server.rb +92 -0
- data/examples/sse.rb +16 -0
- data/examples/streamable_http.rb +17 -0
- data/examples/tcp.rb +20 -0
- data/examples/text.rb +23 -0
- data/examples/udp.rb +18 -0
- data/examples/webrtc.rb +19 -0
- data/examples/websocket.rb +17 -0
- data/lib/ruby-utcp.rb +4 -0
- data/lib/utcp/client.rb +217 -0
- data/lib/utcp/config.rb +79 -0
- data/lib/utcp/errors.rb +48 -0
- data/lib/utcp/migration.rb +88 -0
- data/lib/utcp/models.rb +794 -0
- data/lib/utcp/openapi_converter.rb +179 -0
- data/lib/utcp/protocols/base.rb +97 -0
- data/lib/utcp/protocols/cli.rb +186 -0
- data/lib/utcp/protocols/file.rb +52 -0
- data/lib/utcp/protocols/graphql.rb +277 -0
- data/lib/utcp/protocols/grpc.rb +207 -0
- data/lib/utcp/protocols/http.rb +340 -0
- data/lib/utcp/protocols/http_stream_support.rb +122 -0
- data/lib/utcp/protocols/mcp.rb +339 -0
- data/lib/utcp/protocols/socket_support.rb +51 -0
- data/lib/utcp/protocols/sse.rb +107 -0
- data/lib/utcp/protocols/streamable_http.rb +78 -0
- data/lib/utcp/protocols/tcp.rb +143 -0
- data/lib/utcp/protocols/text.rb +44 -0
- data/lib/utcp/protocols/udp.rb +61 -0
- data/lib/utcp/protocols/webrtc.rb +217 -0
- data/lib/utcp/protocols/websocket.rb +350 -0
- data/lib/utcp/registry.rb +67 -0
- data/lib/utcp/repository.rb +137 -0
- data/lib/utcp/serializer.rb +71 -0
- data/lib/utcp/utils.rb +118 -0
- data/lib/utcp/variables.rb +170 -0
- data/lib/utcp/version.rb +6 -0
- data/lib/utcp.rb +70 -0
- data/proto/utcp.proto +31 -0
- metadata +148 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module UTCP
|
|
6
|
+
class MCPStdioSession
|
|
7
|
+
def initialize(config, timeout)
|
|
8
|
+
command = config["command"]
|
|
9
|
+
command = command.first if command.is_a?(Array)
|
|
10
|
+
raise ValidationError, "MCP stdio server requires command" if command.to_s.empty?
|
|
11
|
+
|
|
12
|
+
args = Array(config["args"]).map(&:to_s)
|
|
13
|
+
environment = Utils.stringify_keys(config["env"] || {}).transform_values(&:to_s)
|
|
14
|
+
options = { pgroup: true }
|
|
15
|
+
options[:chdir] = config["cwd"] || config["workingDir"] if config["cwd"] || config["workingDir"]
|
|
16
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(environment, command.to_s, *args, options)
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@next_id = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@stderr_reader = Thread.new { @stderr.read }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def request(method, params = nil)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
identifier = (@next_id += 1)
|
|
26
|
+
message = { "jsonrpc" => "2.0", "id" => identifier, "method" => method }
|
|
27
|
+
message["params"] = params unless params.nil?
|
|
28
|
+
write_message(message)
|
|
29
|
+
loop do
|
|
30
|
+
response = read_message
|
|
31
|
+
next unless response["id"] == identifier
|
|
32
|
+
raise ToolCallError, "MCP error #{response["error"].inspect}" if response["error"]
|
|
33
|
+
|
|
34
|
+
return response["result"]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def notify(method, params = nil)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
message = { "jsonrpc" => "2.0", "method" => method }
|
|
42
|
+
message["params"] = params unless params.nil?
|
|
43
|
+
write_message(message)
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
@stdin.close unless @stdin.closed?
|
|
50
|
+
Process.kill("TERM", -@wait_thread.pid) if @wait_thread&.alive?
|
|
51
|
+
@wait_thread.join(1) if @wait_thread
|
|
52
|
+
Process.kill("KILL", -@wait_thread.pid) if @wait_thread&.alive?
|
|
53
|
+
rescue Errno::ESRCH, Errno::EPERM, IOError
|
|
54
|
+
nil
|
|
55
|
+
ensure
|
|
56
|
+
@stdout.close unless @stdout.closed?
|
|
57
|
+
@stderr.close unless @stderr.closed?
|
|
58
|
+
@stderr_reader.kill if @stderr_reader&.alive?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def write_message(message)
|
|
64
|
+
@stdin.write(JSON.generate(message) + "\n")
|
|
65
|
+
@stdin.flush
|
|
66
|
+
rescue Errno::EPIPE, IOError => error
|
|
67
|
+
raise ToolCallError, "MCP stdio write failed: #{error.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_message
|
|
71
|
+
ready = IO.select([@stdout], nil, nil, @timeout)
|
|
72
|
+
raise TimeoutError, "MCP stdio response timed out" unless ready
|
|
73
|
+
|
|
74
|
+
line = @stdout.gets
|
|
75
|
+
raise ToolCallError, "MCP stdio server closed the stream" unless line
|
|
76
|
+
|
|
77
|
+
JSON.parse(line)
|
|
78
|
+
rescue JSON::ParserError => error
|
|
79
|
+
raise SerializerValidationError, "Invalid MCP stdio JSON: #{error.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class MCPHTTPSession
|
|
84
|
+
attr_reader :session_id
|
|
85
|
+
|
|
86
|
+
def initialize(config, template, protocol)
|
|
87
|
+
@url = Utils.required_string!(config["url"], "MCP server url")
|
|
88
|
+
@headers = Utils.stringify_keys(config["headers"] || {})
|
|
89
|
+
@template = template
|
|
90
|
+
@protocol = protocol
|
|
91
|
+
@next_id = 0
|
|
92
|
+
@session_id = nil
|
|
93
|
+
@mutex = Mutex.new
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def request(method, params = nil)
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
identifier = (@next_id += 1)
|
|
99
|
+
message = { "jsonrpc" => "2.0", "id" => identifier, "method" => method }
|
|
100
|
+
message["params"] = params unless params.nil?
|
|
101
|
+
response = transmit(message)
|
|
102
|
+
raise ToolCallError, "MCP error #{response["error"].inspect}" if response["error"]
|
|
103
|
+
|
|
104
|
+
response["result"]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def notify(method, params = nil)
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
message = { "jsonrpc" => "2.0", "method" => method }
|
|
111
|
+
message["params"] = params unless params.nil?
|
|
112
|
+
transmit(message, notification: true)
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def transmit(message, notification: false)
|
|
124
|
+
response = @protocol.send(:mcp_http_request, @url, @headers, @template, message, @session_id)
|
|
125
|
+
@session_id ||= response[:session_id]
|
|
126
|
+
return {} if notification && response[:body].to_s.empty?
|
|
127
|
+
|
|
128
|
+
values = response[:content_type].include?("text/event-stream") ? sse_values(response[:body]) : [JSON.parse(response[:body])]
|
|
129
|
+
identifier = message["id"]
|
|
130
|
+
values.find { |value| value.is_a?(Hash) && value["id"] == identifier } || values.last || {}
|
|
131
|
+
rescue JSON::ParserError => error
|
|
132
|
+
raise SerializerValidationError, "Invalid MCP HTTP JSON: #{error.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sse_values(body)
|
|
136
|
+
values = []
|
|
137
|
+
parser = SSEParser.new
|
|
138
|
+
parser.feed(body) { |value| values << value }
|
|
139
|
+
parser.finish { |value| values << value }
|
|
140
|
+
values
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class MCPProtocol < HTTPProtocol
|
|
145
|
+
def initialize(session_factory: nil, **options)
|
|
146
|
+
super(**options)
|
|
147
|
+
@session_factory = session_factory
|
|
148
|
+
@sessions = {}
|
|
149
|
+
@resources = {}
|
|
150
|
+
@sessions_mutex = Mutex.new
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def register_manual(client, template)
|
|
154
|
+
assert_mcp_template!(template)
|
|
155
|
+
tools = []
|
|
156
|
+
errors = []
|
|
157
|
+
template.servers.each do |server_name, config|
|
|
158
|
+
begin
|
|
159
|
+
session = session_for(template, server_name, config)
|
|
160
|
+
initialize_session(session, template)
|
|
161
|
+
result = session.request("tools/list", {}) || {}
|
|
162
|
+
Array(result["tools"]).each do |tool|
|
|
163
|
+
tools << Tool.new(
|
|
164
|
+
name: "#{server_name}.#{tool.fetch("name")}",
|
|
165
|
+
description: tool["description"].to_s,
|
|
166
|
+
inputs: tool["inputSchema"] || {},
|
|
167
|
+
outputs: tool["outputSchema"] || {},
|
|
168
|
+
tool_call_template: template
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
add_resource_tools(template, server_name, session, tools) if template.register_resources_as_tools
|
|
172
|
+
rescue StandardError => error
|
|
173
|
+
errors << "#{server_name}: #{error.message}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
manual = Manual.new(utcp_version: VERSION, manual_version: "1.0.0", tools: tools)
|
|
177
|
+
RegisterManualResult.new(
|
|
178
|
+
manual_call_template: template,
|
|
179
|
+
manual: manual,
|
|
180
|
+
success: errors.empty?,
|
|
181
|
+
errors: errors
|
|
182
|
+
)
|
|
183
|
+
rescue StandardError => error
|
|
184
|
+
client.logger.warn("Unable to register MCP manual #{template.name.inspect}: #{error.message}")
|
|
185
|
+
failure(template, error)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def deregister_manual(_client, template)
|
|
189
|
+
prefix = "#{template.name}\0"
|
|
190
|
+
sessions = @sessions_mutex.synchronize do
|
|
191
|
+
keys = @sessions.keys.select { |key| key.start_with?(prefix) }
|
|
192
|
+
keys.map { |key| @sessions.delete(key) }
|
|
193
|
+
end
|
|
194
|
+
sessions.each(&:close)
|
|
195
|
+
@resources.delete_if { |key, _value| key.start_with?(prefix) }
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
200
|
+
assert_mcp_template!(template)
|
|
201
|
+
server_name, local_name = parse_tool_name(tool_name, template)
|
|
202
|
+
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)]
|
|
205
|
+
result = if resource_uri
|
|
206
|
+
session.request("resources/read", "uri" => resource_uri)
|
|
207
|
+
else
|
|
208
|
+
session.request("tools/call", "name" => local_name, "arguments" => Utils.stringify_keys(tool_args || {}))
|
|
209
|
+
end
|
|
210
|
+
process_mcp_result(result)
|
|
211
|
+
rescue Error
|
|
212
|
+
raise
|
|
213
|
+
rescue StandardError => error
|
|
214
|
+
raise ToolCallError.new("MCP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def mcp_http_request(url, static_headers, template, message, session_id)
|
|
218
|
+
headers = Utils.stringify_keys(static_headers || {})
|
|
219
|
+
headers["Accept"] = "application/json, text/event-stream"
|
|
220
|
+
headers["MCP-Protocol-Version"] = template.protocol_version
|
|
221
|
+
headers["MCP-Session-Id"] = session_id if session_id
|
|
222
|
+
query = {}
|
|
223
|
+
cookies = {}
|
|
224
|
+
sensitive = apply_auth(template.auth, headers, query, cookies)
|
|
225
|
+
if template.auth.is_a?(OAuth2Auth)
|
|
226
|
+
headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
|
|
227
|
+
sensitive << "Authorization"
|
|
228
|
+
end
|
|
229
|
+
uri = URLSecurity.validate!(append_query(url, query), context: "MCP HTTP")
|
|
230
|
+
response = perform_request(
|
|
231
|
+
"POST", uri,
|
|
232
|
+
headers: headers,
|
|
233
|
+
cookies: cookies,
|
|
234
|
+
body: message,
|
|
235
|
+
content_type: "application/json",
|
|
236
|
+
timeout: template.timeout,
|
|
237
|
+
sensitive_headers: sensitive.uniq
|
|
238
|
+
)
|
|
239
|
+
{
|
|
240
|
+
body: response.body.to_s,
|
|
241
|
+
content_type: response["content-type"].to_s.downcase,
|
|
242
|
+
session_id: response["mcp-session-id"]
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def assert_mcp_template!(template)
|
|
249
|
+
return if template.is_a?(McpCallTemplate)
|
|
250
|
+
|
|
251
|
+
raise ValidationError, "MCP protocol requires a McpCallTemplate"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def session_for(template, server_name, config)
|
|
255
|
+
key = "#{template.name}\0#{server_name}"
|
|
256
|
+
@sessions_mutex.synchronize do
|
|
257
|
+
@sessions[key] ||= if @session_factory
|
|
258
|
+
@session_factory.call(server_name, config, template)
|
|
259
|
+
elsif %w[http streamable_http sse].include?(config["transport"].to_s) || config["url"]
|
|
260
|
+
MCPHTTPSession.new(config, template, self)
|
|
261
|
+
else
|
|
262
|
+
MCPStdioSession.new(config, template.timeout)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def initialize_session(session, template)
|
|
268
|
+
session.request("initialize", {
|
|
269
|
+
"protocolVersion" => template.protocol_version,
|
|
270
|
+
"capabilities" => {},
|
|
271
|
+
"clientInfo" => { "name" => "ruby-utcp", "version" => VERSION }
|
|
272
|
+
})
|
|
273
|
+
session.notify("notifications/initialized", {})
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def add_resource_tools(template, server_name, session, tools)
|
|
277
|
+
cursor = nil
|
|
278
|
+
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
|
|
293
|
+
cursor = result["nextCursor"]
|
|
294
|
+
break if cursor.nil? || cursor.empty?
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def resource_key(template, server_name, local_name)
|
|
299
|
+
"#{template.name}\0#{server_name}\0#{local_name}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def parse_tool_name(tool_name, template)
|
|
303
|
+
parts = tool_name.to_s.split(".")
|
|
304
|
+
parts.shift if parts.first == template.name
|
|
305
|
+
if parts.length >= 2 && template.servers.key?(parts.first)
|
|
306
|
+
[parts.shift, parts.join(".")]
|
|
307
|
+
elsif template.servers.length == 1
|
|
308
|
+
[template.servers.keys.first, parts.join(".")]
|
|
309
|
+
else
|
|
310
|
+
raise ToolCallError, "MCP tool name must include one of the server names: #{template.servers.keys.join(', ')}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def process_mcp_result(result)
|
|
315
|
+
return result unless result.is_a?(Hash)
|
|
316
|
+
return result["structuredContent"] if result.key?("structuredContent")
|
|
317
|
+
return result if result.key?("contents")
|
|
318
|
+
|
|
319
|
+
content = fetch_content(result["content"])
|
|
320
|
+
return result unless content
|
|
321
|
+
return process_mcp_content(content.first) if content.length == 1
|
|
322
|
+
|
|
323
|
+
content.map { |item| process_mcp_content(item) }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def process_mcp_content(item)
|
|
327
|
+
return item unless item.is_a?(Hash) && item["type"] == "text"
|
|
328
|
+
|
|
329
|
+
decode_json_or_text(item["text"].to_s)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Keeps the nil-vs-empty distinction explicit without relying on ActiveSupport.
|
|
333
|
+
def fetch_content(value)
|
|
334
|
+
value.is_a?(Array) && !value.empty? ? value : nil
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
McpCommunicationProtocol = MCPProtocol
|
|
338
|
+
MCPCommunicationProtocol = MCPProtocol
|
|
339
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module UTCP
|
|
7
|
+
module SocketSupport
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def socket_timeout_seconds(template)
|
|
11
|
+
Float(template.timeout) / 1000.0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format_socket_message(template, arguments)
|
|
15
|
+
args = Utils.stringify_keys(arguments || {})
|
|
16
|
+
return JSON.generate(args) if template.request_data_format == "json"
|
|
17
|
+
|
|
18
|
+
if template.request_data_template && !template.request_data_template.empty?
|
|
19
|
+
substitute_message_template(template.request_data_template, args)
|
|
20
|
+
else
|
|
21
|
+
args.values.map { |value| value.is_a?(String) ? value : JSON.generate(value) }.join(" ")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def decode_socket_payload(payload, encoding)
|
|
26
|
+
return payload.b if encoding.nil?
|
|
27
|
+
|
|
28
|
+
payload.dup.force_encoding(encoding).encode(Encoding::UTF_8)
|
|
29
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError,
|
|
30
|
+
ArgumentError => error
|
|
31
|
+
raise ToolCallError, "Unable to decode socket response as #{encoding}: #{error.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def escaped_delimiter(value, interpret)
|
|
35
|
+
return value.to_s.b unless interpret
|
|
36
|
+
|
|
37
|
+
value.to_s.gsub(/\\(?:x([0-9A-Fa-f]{2})|([nrt0\\]))/) do
|
|
38
|
+
if Regexp.last_match(1)
|
|
39
|
+
Regexp.last_match(1).to_i(16).chr
|
|
40
|
+
else
|
|
41
|
+
{ "n" => "\n", "r" => "\r", "t" => "\t", "0" => "\0", "\\" => "\\" }.fetch(Regexp.last_match(2))
|
|
42
|
+
end
|
|
43
|
+
end.b
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def wait_readable!(io, deadline, label)
|
|
47
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
raise TimeoutError, "#{label} timed out" unless remaining.positive? && IO.select([io], nil, nil, remaining)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class SSEProtocol < HTTPProtocol
|
|
5
|
+
include HTTPStreamSupport
|
|
6
|
+
|
|
7
|
+
def register_manual(client, template)
|
|
8
|
+
assert_sse_template!(template)
|
|
9
|
+
response = buffered_discovery(template)
|
|
10
|
+
manual = manual_from_payload(template, response.body, source: "SSE discovery response")
|
|
11
|
+
success(template, manual)
|
|
12
|
+
rescue StandardError => error
|
|
13
|
+
client.logger.warn("Unable to register SSE manual #{template.name.inspect}: #{error.message}")
|
|
14
|
+
failure(template, error)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call_tool(client, tool_name, tool_args, template)
|
|
18
|
+
values = []
|
|
19
|
+
call_tool_streaming(client, tool_name, tool_args, template) { |value| values << value }
|
|
20
|
+
values
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call_tool_streaming(_client, tool_name, tool_args, template)
|
|
24
|
+
return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
|
|
25
|
+
|
|
26
|
+
assert_sse_template!(template)
|
|
27
|
+
with_stream_response(template, tool_args || {}, accept: "text/event-stream") do |response|
|
|
28
|
+
parser = SSEParser.new(event_type: template.event_type)
|
|
29
|
+
response.read_body do |chunk|
|
|
30
|
+
parser.feed(chunk) { |event| yield event }
|
|
31
|
+
end
|
|
32
|
+
parser.finish { |event| yield event }
|
|
33
|
+
end
|
|
34
|
+
rescue Error
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError => error
|
|
37
|
+
raise ToolCallError.new("SSE tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def assert_sse_template!(template)
|
|
43
|
+
return if template.is_a?(SseCallTemplate)
|
|
44
|
+
|
|
45
|
+
raise ValidationError, "SSE protocol requires an SseCallTemplate"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class SSEParser
|
|
50
|
+
def initialize(event_type: nil)
|
|
51
|
+
@event_type = event_type
|
|
52
|
+
@buffer = +""
|
|
53
|
+
@fields = reset_fields
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
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 }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def finish
|
|
65
|
+
process_line(@buffer) { |event| yield event } unless @buffer.empty?
|
|
66
|
+
dispatch { |event| yield event } unless @fields[:data].empty?
|
|
67
|
+
@buffer.clear
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def process_line(line)
|
|
73
|
+
if line.empty?
|
|
74
|
+
dispatch { |event| yield event }
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
return if line.start_with?(":")
|
|
78
|
+
|
|
79
|
+
field, value = line.split(":", 2)
|
|
80
|
+
value = value.to_s.sub(/\A /, "")
|
|
81
|
+
case field
|
|
82
|
+
when "data" then @fields[:data] << value
|
|
83
|
+
when "event" then @fields[:event] = value
|
|
84
|
+
when "id" then @fields[:id] = value unless value.include?("\0")
|
|
85
|
+
when "retry" then @fields[:retry] = Integer(value) rescue nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dispatch
|
|
90
|
+
fields = @fields
|
|
91
|
+
@fields = reset_fields
|
|
92
|
+
return if fields[:data].empty?
|
|
93
|
+
return if @event_type && fields[:event] != @event_type
|
|
94
|
+
|
|
95
|
+
payload = fields[:data].join("\n")
|
|
96
|
+
yield JSON.parse(payload)
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
yield payload
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reset_fields
|
|
102
|
+
{ data: [], event: nil, id: nil, retry: nil }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
SseCommunicationProtocol = SSEProtocol
|
|
106
|
+
SSECommunicationProtocol = SSEProtocol
|
|
107
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class StreamableHTTPProtocol < HTTPProtocol
|
|
5
|
+
include HTTPStreamSupport
|
|
6
|
+
|
|
7
|
+
def register_manual(client, template)
|
|
8
|
+
assert_stream_template!(template)
|
|
9
|
+
response = buffered_discovery(template)
|
|
10
|
+
success(template, manual_from_payload(template, response.body, source: "streamable HTTP discovery response"))
|
|
11
|
+
rescue StandardError => error
|
|
12
|
+
client.logger.warn("Unable to register streamable HTTP manual #{template.name.inspect}: #{error.message}")
|
|
13
|
+
failure(template, error)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call_tool(client, tool_name, tool_args, template)
|
|
17
|
+
chunks = []
|
|
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
|
|
22
|
+
end
|
|
23
|
+
binary && chunks.all? { |chunk| chunk.is_a?(String) } ? chunks.join.b : chunks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call_tool_streaming(_client, tool_name, tool_args, template)
|
|
27
|
+
return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
|
|
28
|
+
|
|
29
|
+
assert_stream_template!(template)
|
|
30
|
+
with_stream_response(template, tool_args || {}) do |response|
|
|
31
|
+
content_type = response["content-type"].to_s.downcase
|
|
32
|
+
if content_type.include?("application/x-ndjson") || content_type.include?("application/json-seq")
|
|
33
|
+
stream_json_lines(response) { |item| yield item }
|
|
34
|
+
elsif content_type.include?("application/json")
|
|
35
|
+
body = +""
|
|
36
|
+
response.read_body { |chunk| body << chunk }
|
|
37
|
+
yield decode_json_or_text(body) unless body.empty?
|
|
38
|
+
else
|
|
39
|
+
response.read_body do |chunk|
|
|
40
|
+
bytes = chunk.to_s.b
|
|
41
|
+
offset = 0
|
|
42
|
+
while offset < bytes.bytesize
|
|
43
|
+
yield bytes.byteslice(offset, template.chunk_size)
|
|
44
|
+
offset += template.chunk_size
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue Error
|
|
50
|
+
raise
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
raise ToolCallError.new("Streamable HTTP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def assert_stream_template!(template)
|
|
58
|
+
return if template.is_a?(StreamableHttpCallTemplate)
|
|
59
|
+
|
|
60
|
+
raise ValidationError, "streamable HTTP protocol requires a StreamableHttpCallTemplate"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stream_json_lines(response)
|
|
64
|
+
buffer = +""
|
|
65
|
+
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?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
tail = buffer.strip.sub(/\A\x1E/, "")
|
|
73
|
+
yield decode_json_or_text(tail) unless tail.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
StreamableHttpCommunicationProtocol = StreamableHTTPProtocol
|
|
77
|
+
StreamableHTTPCommunicationProtocol = StreamableHTTPProtocol
|
|
78
|
+
end
|