sc-ruby_llm-mcp 0.3.1
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/LICENSE +21 -0
- data/README.md +446 -0
- data/lib/ruby_llm/chat.rb +33 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/capabilities.rb +29 -0
- data/lib/ruby_llm/mcp/client.rb +104 -0
- data/lib/ruby_llm/mcp/completion.rb +15 -0
- data/lib/ruby_llm/mcp/content.rb +20 -0
- data/lib/ruby_llm/mcp/coordinator.rb +112 -0
- data/lib/ruby_llm/mcp/errors.rb +28 -0
- data/lib/ruby_llm/mcp/parameter.rb +19 -0
- data/lib/ruby_llm/mcp/prompt.rb +106 -0
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +65 -0
- data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +61 -0
- data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +52 -0
- data/lib/ruby_llm/mcp/requests/base.rb +31 -0
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +40 -0
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +40 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +24 -0
- data/lib/ruby_llm/mcp/requests/initialize_notification.rb +14 -0
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +23 -0
- data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/tool_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -0
- data/lib/ruby_llm/mcp/resource.rb +77 -0
- data/lib/ruby_llm/mcp/resource_template.rb +79 -0
- data/lib/ruby_llm/mcp/tool.rb +115 -0
- data/lib/ruby_llm/mcp/transport/sse.rb +244 -0
- data/lib/ruby_llm/mcp/transport/stdio.rb +210 -0
- data/lib/ruby_llm/mcp/transport/streamable.rb +299 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +27 -0
- metadata +175 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceRead
|
7
|
+
attr_reader :client, :uri
|
8
|
+
|
9
|
+
def initialize(client, uri:)
|
10
|
+
@client = client
|
11
|
+
@uri = uri
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
client.request(reading_resource_body(uri))
|
16
|
+
end
|
17
|
+
|
18
|
+
def reading_resource_body(uri)
|
19
|
+
{
|
20
|
+
jsonrpc: "2.0",
|
21
|
+
method: "resources/read",
|
22
|
+
params: {
|
23
|
+
uri: uri
|
24
|
+
}
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceTemplateList < Base
|
7
|
+
def call
|
8
|
+
client.request(resource_template_list_body)
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_template_list_body
|
12
|
+
{
|
13
|
+
jsonrpc: "2.0",
|
14
|
+
method: "resources/templates/list",
|
15
|
+
params: {}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ToolCall
|
7
|
+
def initialize(client, name:, parameters: {})
|
8
|
+
@client = client
|
9
|
+
@name = name
|
10
|
+
@parameters = parameters
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
@client.request(request_body)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def request_body
|
20
|
+
{
|
21
|
+
jsonrpc: "2.0",
|
22
|
+
method: "tools/call",
|
23
|
+
params: {
|
24
|
+
name: @name,
|
25
|
+
arguments: @parameters
|
26
|
+
}
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class RubyLLM::MCP::Requests::ToolList < RubyLLM::MCP::Requests::Base
|
4
|
+
def call
|
5
|
+
client.request(tool_list_body)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def tool_list_body
|
11
|
+
{
|
12
|
+
jsonrpc: "2.0",
|
13
|
+
method: "tools/list",
|
14
|
+
params: {}
|
15
|
+
}
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Resource
|
6
|
+
attr_reader :uri, :name, :description, :mime_type, :coordinator
|
7
|
+
|
8
|
+
def initialize(coordinator, resource)
|
9
|
+
@coordinator = coordinator
|
10
|
+
@uri = resource["uri"]
|
11
|
+
@name = resource["name"]
|
12
|
+
@description = resource["description"]
|
13
|
+
@mime_type = resource["mimeType"]
|
14
|
+
if resource.key?("content_response")
|
15
|
+
@content_response = resource["content_response"]
|
16
|
+
@content = @content_response["text"] || @content_response["blob"]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def content
|
21
|
+
return @content unless @content.nil?
|
22
|
+
|
23
|
+
response = read_response
|
24
|
+
@content_response = response.dig("result", "contents", 0)
|
25
|
+
@content = @content_response["text"] || @content_response["blob"]
|
26
|
+
end
|
27
|
+
|
28
|
+
def include(chat, **args)
|
29
|
+
message = Message.new(
|
30
|
+
role: "user",
|
31
|
+
content: to_content(**args)
|
32
|
+
)
|
33
|
+
|
34
|
+
chat.add_message(message)
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_content
|
38
|
+
content = self.content
|
39
|
+
|
40
|
+
case content_type
|
41
|
+
when "text"
|
42
|
+
MCP::Content.new(text: "#{name}: #{description}\n\n#{content}")
|
43
|
+
when "blob"
|
44
|
+
attachment = MCP::Attachment.new(content, mime_type)
|
45
|
+
MCP::Content.new(text: "#{name}: #{description}", attachments: [attachment])
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def content_type
|
52
|
+
return "text" if @content_response.nil?
|
53
|
+
|
54
|
+
if @content_response.key?("blob")
|
55
|
+
"blob"
|
56
|
+
else
|
57
|
+
"text"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def read_response(uri: @uri)
|
62
|
+
parsed = URI.parse(uri)
|
63
|
+
case parsed.scheme
|
64
|
+
when "http", "https"
|
65
|
+
fetch_uri_content(uri)
|
66
|
+
else # file:// or git://
|
67
|
+
@read_response ||= @coordinator.resource_read(uri: uri)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def fetch_uri_content(uri)
|
72
|
+
response = Faraday.get(uri)
|
73
|
+
{ "result" => { "contents" => [{ "text" => response.body }] } }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class ResourceTemplate
|
6
|
+
attr_reader :uri, :name, :description, :mime_type, :coordinator, :template
|
7
|
+
|
8
|
+
def initialize(coordinator, resource)
|
9
|
+
@coordinator = coordinator
|
10
|
+
@uri = resource["uriTemplate"]
|
11
|
+
@name = resource["name"]
|
12
|
+
@description = resource["description"]
|
13
|
+
@mime_type = resource["mimeType"]
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch_resource(arguments: {})
|
17
|
+
uri = apply_template(@uri, arguments)
|
18
|
+
response = read_response(uri)
|
19
|
+
content_response = response.dig("result", "contents", 0)
|
20
|
+
|
21
|
+
Resource.new(coordinator, {
|
22
|
+
"uri" => uri,
|
23
|
+
"name" => "#{@name} (#{uri})",
|
24
|
+
"description" => @description,
|
25
|
+
"mimeType" => @mime_type,
|
26
|
+
"content_response" => content_response
|
27
|
+
})
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_content(arguments: {})
|
31
|
+
fetch_resource(arguments: arguments).to_content
|
32
|
+
end
|
33
|
+
|
34
|
+
def complete(argument, value)
|
35
|
+
if @coordinator.capabilities.completion?
|
36
|
+
response = @coordinator.completion_resource(uri: @uri, argument: argument, value: value)
|
37
|
+
response = response.dig("result", "completion")
|
38
|
+
|
39
|
+
Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
|
40
|
+
else
|
41
|
+
raise Errors::CompletionNotAvailable.new(message: "Completion is not available for this MCP server")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def content_type
|
48
|
+
if @content.key?("type")
|
49
|
+
@content["type"]
|
50
|
+
else
|
51
|
+
"text"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def read_response(uri)
|
56
|
+
parsed = URI.parse(uri)
|
57
|
+
case parsed.scheme
|
58
|
+
when "http", "https"
|
59
|
+
fetch_uri_content(uri)
|
60
|
+
else # file:// or git://
|
61
|
+
@coordinator.resource_read(uri: uri)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def fetch_uri_content(uri)
|
66
|
+
response = Faraday.get(uri)
|
67
|
+
{ "result" => { "contents" => [{ "text" => response.body }] } }
|
68
|
+
end
|
69
|
+
|
70
|
+
def apply_template(uri, arguments)
|
71
|
+
uri.gsub(/\{(\w+)\}/) do
|
72
|
+
arguments[::Regexp.last_match(1).to_s] ||
|
73
|
+
arguments[::Regexp.last_match(1).to_sym] ||
|
74
|
+
"{#{::Regexp.last_match(1)}}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Tool < RubyLLM::Tool
|
6
|
+
attr_reader :name, :description, :parameters, :coordinator, :tool_response
|
7
|
+
|
8
|
+
def initialize(coordinator, tool_response)
|
9
|
+
super()
|
10
|
+
@coordinator = coordinator
|
11
|
+
|
12
|
+
@name = tool_response["name"]
|
13
|
+
@description = tool_response["description"].to_s
|
14
|
+
@parameters = create_parameters(tool_response["inputSchema"])
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute(**params)
|
18
|
+
response = @coordinator.execute_tool(
|
19
|
+
name: @name,
|
20
|
+
parameters: params
|
21
|
+
)
|
22
|
+
|
23
|
+
text_values = response.dig("result", "content").map { |content| content["text"] }.compact.join("\n")
|
24
|
+
|
25
|
+
if text_values.empty?
|
26
|
+
create_content_for_message(response.dig("result", "content", 0))
|
27
|
+
else
|
28
|
+
create_content_for_message({ "type" => "text", "text" => text_values })
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def create_parameters(input_schema)
|
35
|
+
params = {}
|
36
|
+
return params if input_schema["properties"].nil?
|
37
|
+
|
38
|
+
input_schema["properties"].each_key do |key|
|
39
|
+
param_data = input_schema.dig("properties", key)
|
40
|
+
|
41
|
+
param = if param_data.key?("oneOf") || param_data.key?("anyOf") || param_data.key?("allOf")
|
42
|
+
process_union_parameter(key, param_data)
|
43
|
+
else
|
44
|
+
process_parameter(key, param_data)
|
45
|
+
end
|
46
|
+
|
47
|
+
params[key] = param
|
48
|
+
end
|
49
|
+
|
50
|
+
params
|
51
|
+
end
|
52
|
+
|
53
|
+
def process_union_parameter(key, param_data)
|
54
|
+
union_type = param_data.keys.first
|
55
|
+
param = RubyLLM::MCP::Parameter.new(
|
56
|
+
key,
|
57
|
+
type: :union,
|
58
|
+
union_type: union_type
|
59
|
+
)
|
60
|
+
|
61
|
+
param.properties = param_data[union_type].map do |value|
|
62
|
+
process_parameter(key, value, lifted_type: param_data["type"])
|
63
|
+
end.compact
|
64
|
+
|
65
|
+
param
|
66
|
+
end
|
67
|
+
|
68
|
+
def process_parameter(key, param_data, lifted_type: nil)
|
69
|
+
param = RubyLLM::MCP::Parameter.new(
|
70
|
+
key,
|
71
|
+
type: param_data["type"] || lifted_type,
|
72
|
+
desc: param_data["description"],
|
73
|
+
required: param_data["required"]
|
74
|
+
)
|
75
|
+
|
76
|
+
if param.type == :array
|
77
|
+
items = param_data["items"]
|
78
|
+
param.items = items
|
79
|
+
if items.key?("properties")
|
80
|
+
param.properties = create_parameters(items)
|
81
|
+
end
|
82
|
+
if param_data.key?("enum")
|
83
|
+
param.enum = param_data["enum"]
|
84
|
+
end
|
85
|
+
elsif param.type == :object
|
86
|
+
if param_data.key?("properties")
|
87
|
+
param.properties = create_parameters(param_data)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
param
|
92
|
+
end
|
93
|
+
|
94
|
+
def create_content_for_message(content)
|
95
|
+
case content["type"]
|
96
|
+
when "text"
|
97
|
+
MCP::Content.new(text: content["text"])
|
98
|
+
when "image", "audio"
|
99
|
+
attachment = MCP::Attachment.new(content["data"], content["mimeType"])
|
100
|
+
MCP::Content.new(text: nil, attachments: [attachment])
|
101
|
+
when "resource"
|
102
|
+
resource_data = {
|
103
|
+
"name" => name,
|
104
|
+
"description" => description,
|
105
|
+
"uri" => content.dig("resource", "uri"),
|
106
|
+
"content" => content["resource"]
|
107
|
+
}
|
108
|
+
|
109
|
+
resource = Resource.new(coordinator, resource_data)
|
110
|
+
resource.to_content
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "uri"
|
5
|
+
require "faraday"
|
6
|
+
require "timeout"
|
7
|
+
require "securerandom"
|
8
|
+
|
9
|
+
module RubyLLM
|
10
|
+
module MCP
|
11
|
+
module Transport
|
12
|
+
class SSE
|
13
|
+
attr_reader :headers, :id
|
14
|
+
|
15
|
+
def initialize(url, headers: {}, request_timeout: 8000)
|
16
|
+
@event_url = url
|
17
|
+
@messages_url = nil
|
18
|
+
@request_timeout = request_timeout
|
19
|
+
|
20
|
+
uri = URI.parse(url)
|
21
|
+
@root_url = "#{uri.scheme}://#{uri.host}"
|
22
|
+
@root_url += ":#{uri.port}" if uri.port != uri.default_port
|
23
|
+
|
24
|
+
@client_id = SecureRandom.uuid
|
25
|
+
@headers = headers.merge({
|
26
|
+
"Accept" => "text/event-stream",
|
27
|
+
"Cache-Control" => "no-cache",
|
28
|
+
"Connection" => "keep-alive",
|
29
|
+
"X-CLIENT-ID" => @client_id
|
30
|
+
})
|
31
|
+
|
32
|
+
@id_counter = 0
|
33
|
+
@id_mutex = Mutex.new
|
34
|
+
@pending_requests = {}
|
35
|
+
@pending_mutex = Mutex.new
|
36
|
+
@connection_mutex = Mutex.new
|
37
|
+
@running = true
|
38
|
+
@sse_thread = nil
|
39
|
+
|
40
|
+
# Start the SSE listener thread
|
41
|
+
start_sse_listener
|
42
|
+
end
|
43
|
+
|
44
|
+
def request(body, add_id: true, wait_for_response: true) # rubocop:disable Metrics/MethodLength
|
45
|
+
# Generate a unique request ID
|
46
|
+
if add_id
|
47
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
48
|
+
request_id = @id_counter
|
49
|
+
body["id"] = request_id
|
50
|
+
end
|
51
|
+
|
52
|
+
# Create a queue for this request's response
|
53
|
+
response_queue = Queue.new
|
54
|
+
if wait_for_response
|
55
|
+
@pending_mutex.synchronize do
|
56
|
+
@pending_requests[request_id.to_s] = response_queue
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Send the request using Faraday
|
61
|
+
begin
|
62
|
+
conn = Faraday.new do |f|
|
63
|
+
f.options.timeout = @request_timeout / 1000
|
64
|
+
f.options.open_timeout = 5
|
65
|
+
end
|
66
|
+
|
67
|
+
response = conn.post(@messages_url) do |req|
|
68
|
+
@headers.each do |key, value|
|
69
|
+
req.headers[key] = value
|
70
|
+
end
|
71
|
+
req.headers["Content-Type"] = "application/json"
|
72
|
+
req.body = JSON.generate(body)
|
73
|
+
end
|
74
|
+
|
75
|
+
unless response.status == 200
|
76
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
77
|
+
raise "Failed to request #{@messages_url}: #{response.status} - #{response.body}"
|
78
|
+
end
|
79
|
+
rescue StandardError => e
|
80
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
81
|
+
raise e
|
82
|
+
end
|
83
|
+
return unless wait_for_response
|
84
|
+
|
85
|
+
begin
|
86
|
+
Timeout.timeout(@request_timeout / 1000) do
|
87
|
+
response_queue.pop
|
88
|
+
end
|
89
|
+
rescue Timeout::Error
|
90
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
91
|
+
raise RubyLLM::MCP::Errors::TimeoutError.new(
|
92
|
+
message: "Request timed out after #{@request_timeout / 1000} seconds"
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def alive?
|
98
|
+
@running
|
99
|
+
end
|
100
|
+
|
101
|
+
def close
|
102
|
+
@running = false
|
103
|
+
@sse_thread&.join(1) # Give the thread a second to clean up
|
104
|
+
@sse_thread = nil
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
108
|
+
|
109
|
+
def start_sse_listener
|
110
|
+
@connection_mutex.synchronize do
|
111
|
+
return if sse_thread_running?
|
112
|
+
|
113
|
+
response_queue = Queue.new
|
114
|
+
@pending_mutex.synchronize do
|
115
|
+
@pending_requests["endpoint"] = response_queue
|
116
|
+
end
|
117
|
+
|
118
|
+
@sse_thread = Thread.new do
|
119
|
+
listen_for_events while @running
|
120
|
+
end
|
121
|
+
@sse_thread.abort_on_exception = true
|
122
|
+
|
123
|
+
endpoint = response_queue.pop
|
124
|
+
set_message_endpoint(endpoint)
|
125
|
+
|
126
|
+
@pending_mutex.synchronize { @pending_requests.delete("endpoint") }
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def set_message_endpoint(endpoint)
|
131
|
+
uri = URI.parse(endpoint)
|
132
|
+
|
133
|
+
@messages_url = if uri.host.nil?
|
134
|
+
"#{@root_url}#{endpoint}"
|
135
|
+
else
|
136
|
+
endpoint
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def sse_thread_running?
|
141
|
+
@sse_thread&.alive?
|
142
|
+
end
|
143
|
+
|
144
|
+
def listen_for_events
|
145
|
+
stream_events_from_server
|
146
|
+
rescue Faraday::Error => e
|
147
|
+
handle_connection_error("SSE connection failed", e)
|
148
|
+
rescue StandardError => e
|
149
|
+
handle_connection_error("SSE connection error", e)
|
150
|
+
end
|
151
|
+
|
152
|
+
def stream_events_from_server
|
153
|
+
buffer = +""
|
154
|
+
create_sse_connection.get(@event_url) do |req|
|
155
|
+
setup_request_headers(req)
|
156
|
+
setup_streaming_callback(req, buffer)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def create_sse_connection
|
161
|
+
Faraday.new do |f|
|
162
|
+
f.options.timeout = 300 # 5 minutes
|
163
|
+
f.response :raise_error # raise errors on non-200 responses
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def setup_request_headers(request)
|
168
|
+
@headers.each do |key, value|
|
169
|
+
request.headers[key] = value
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def setup_streaming_callback(request, buffer)
|
174
|
+
request.options.on_data = proc do |chunk, _size, _env|
|
175
|
+
buffer << chunk
|
176
|
+
process_buffer_events(buffer)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def process_buffer_events(buffer)
|
181
|
+
while (event = extract_event(buffer))
|
182
|
+
event_data, buffer = event
|
183
|
+
process_event(event_data) if event_data
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def handle_connection_error(message, error)
|
188
|
+
puts "#{message}: #{error.message}. Reconnecting in 3 seconds..."
|
189
|
+
sleep 3
|
190
|
+
end
|
191
|
+
|
192
|
+
def process_event(raw_event)
|
193
|
+
return if raw_event[:data].nil?
|
194
|
+
|
195
|
+
if raw_event[:event] == "endpoint"
|
196
|
+
request_id = "endpoint"
|
197
|
+
event = raw_event[:data]
|
198
|
+
else
|
199
|
+
event = begin
|
200
|
+
JSON.parse(raw_event[:data])
|
201
|
+
rescue StandardError
|
202
|
+
nil
|
203
|
+
end
|
204
|
+
return if event.nil?
|
205
|
+
|
206
|
+
request_id = event["id"]&.to_s
|
207
|
+
end
|
208
|
+
|
209
|
+
@pending_mutex.synchronize do
|
210
|
+
if request_id && @pending_requests.key?(request_id)
|
211
|
+
response_queue = @pending_requests.delete(request_id)
|
212
|
+
response_queue&.push(event)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
rescue JSON::ParserError => e
|
216
|
+
puts "Error parsing event data: #{e.message}"
|
217
|
+
end
|
218
|
+
|
219
|
+
def extract_event(buffer)
|
220
|
+
return nil unless buffer.include?("\n\n")
|
221
|
+
|
222
|
+
raw, rest = buffer.split("\n\n", 2)
|
223
|
+
[parse_event(raw), rest]
|
224
|
+
end
|
225
|
+
|
226
|
+
def parse_event(raw)
|
227
|
+
event = {}
|
228
|
+
raw.each_line do |line|
|
229
|
+
case line
|
230
|
+
when /^data:\s*(.*)/
|
231
|
+
(event[:data] ||= []) << ::Regexp.last_match(1)
|
232
|
+
when /^event:\s*(.*)/
|
233
|
+
event[:event] = ::Regexp.last_match(1)
|
234
|
+
when /^id:\s*(.*)/
|
235
|
+
event[:id] = ::Regexp.last_match(1)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
event[:data] = event[:data]&.join("\n")
|
239
|
+
event
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|