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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +22 -0
  4. data/Makefile +226 -0
  5. data/README.md +331 -0
  6. data/examples/basic.rb +33 -0
  7. data/examples/cli.rb +32 -0
  8. data/examples/generated/__init__.py +1 -0
  9. data/examples/generated/utcp_pb2.py +46 -0
  10. data/examples/generated/utcp_pb2_grpc.py +183 -0
  11. data/examples/graphql.rb +15 -0
  12. data/examples/grpc.rb +42 -0
  13. data/examples/grpc_python.py +52 -0
  14. data/examples/http.rb +17 -0
  15. data/examples/mcp.rb +28 -0
  16. data/examples/servers/graphql_server.rb +39 -0
  17. data/examples/servers/grpc_server.py +97 -0
  18. data/examples/servers/grpc_server.rb +62 -0
  19. data/examples/servers/http_helpers.rb +34 -0
  20. data/examples/servers/http_server.rb +28 -0
  21. data/examples/servers/mcp_stdio_server.rb +43 -0
  22. data/examples/servers/requirements-grpc.txt +2 -0
  23. data/examples/servers/sse_server.rb +36 -0
  24. data/examples/servers/streamable_http_server.rb +39 -0
  25. data/examples/servers/tcp_server.rb +58 -0
  26. data/examples/servers/udp_server.rb +33 -0
  27. data/examples/servers/webrtc_server.rb +78 -0
  28. data/examples/servers/websocket_server.rb +92 -0
  29. data/examples/sse.rb +16 -0
  30. data/examples/streamable_http.rb +17 -0
  31. data/examples/tcp.rb +20 -0
  32. data/examples/text.rb +23 -0
  33. data/examples/udp.rb +18 -0
  34. data/examples/webrtc.rb +19 -0
  35. data/examples/websocket.rb +17 -0
  36. data/lib/ruby-utcp.rb +4 -0
  37. data/lib/utcp/client.rb +217 -0
  38. data/lib/utcp/config.rb +79 -0
  39. data/lib/utcp/errors.rb +48 -0
  40. data/lib/utcp/migration.rb +88 -0
  41. data/lib/utcp/models.rb +794 -0
  42. data/lib/utcp/openapi_converter.rb +179 -0
  43. data/lib/utcp/protocols/base.rb +97 -0
  44. data/lib/utcp/protocols/cli.rb +186 -0
  45. data/lib/utcp/protocols/file.rb +52 -0
  46. data/lib/utcp/protocols/graphql.rb +277 -0
  47. data/lib/utcp/protocols/grpc.rb +207 -0
  48. data/lib/utcp/protocols/http.rb +340 -0
  49. data/lib/utcp/protocols/http_stream_support.rb +122 -0
  50. data/lib/utcp/protocols/mcp.rb +339 -0
  51. data/lib/utcp/protocols/socket_support.rb +51 -0
  52. data/lib/utcp/protocols/sse.rb +107 -0
  53. data/lib/utcp/protocols/streamable_http.rb +78 -0
  54. data/lib/utcp/protocols/tcp.rb +143 -0
  55. data/lib/utcp/protocols/text.rb +44 -0
  56. data/lib/utcp/protocols/udp.rb +61 -0
  57. data/lib/utcp/protocols/webrtc.rb +217 -0
  58. data/lib/utcp/protocols/websocket.rb +350 -0
  59. data/lib/utcp/registry.rb +67 -0
  60. data/lib/utcp/repository.rb +137 -0
  61. data/lib/utcp/serializer.rb +71 -0
  62. data/lib/utcp/utils.rb +118 -0
  63. data/lib/utcp/variables.rb +170 -0
  64. data/lib/utcp/version.rb +6 -0
  65. data/lib/utcp.rb +70 -0
  66. data/proto/utcp.proto +31 -0
  67. 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