raix-openai-eight 1.0.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/.rspec +3 -0
- data/.rubocop.yml +53 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +168 -0
- data/CLAUDE.md +13 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +240 -0
- data/Guardfile +72 -0
- data/LICENSE.txt +21 -0
- data/README.llm +106 -0
- data/README.md +775 -0
- data/Rakefile +18 -0
- data/lib/mcp/sse_client.rb +297 -0
- data/lib/mcp/stdio_client.rb +80 -0
- data/lib/mcp/tool.rb +67 -0
- data/lib/raix/chat_completion.rb +346 -0
- data/lib/raix/configuration.rb +71 -0
- data/lib/raix/function_dispatch.rb +132 -0
- data/lib/raix/mcp.rb +255 -0
- data/lib/raix/message_adapters/base.rb +50 -0
- data/lib/raix/predicate.rb +68 -0
- data/lib/raix/prompt_declarations.rb +166 -0
- data/lib/raix/response_format.rb +81 -0
- data/lib/raix/version.rb +5 -0
- data/lib/raix.rb +27 -0
- data/raix-openai-eight.gemspec +36 -0
- data/sig/raix.rbs +4 -0
- metadata +140 -0
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
require "rubocop/rake_task"
|
9
|
+
|
10
|
+
RuboCop::RakeTask.new(:rubocop_ci)
|
11
|
+
|
12
|
+
task ci: %i[spec rubocop_ci]
|
13
|
+
|
14
|
+
RuboCop::RakeTask.new(:rubocop) do |task|
|
15
|
+
task.options = ["--autocorrect"]
|
16
|
+
end
|
17
|
+
|
18
|
+
task default: %i[spec rubocop]
|
@@ -0,0 +1,297 @@
|
|
1
|
+
require_relative "tool"
|
2
|
+
require "json"
|
3
|
+
require "securerandom"
|
4
|
+
require "faraday"
|
5
|
+
require "uri"
|
6
|
+
require "digest"
|
7
|
+
|
8
|
+
module Raix
|
9
|
+
module MCP
|
10
|
+
# Client for communicating with MCP servers via Server-Sent Events (SSE).
|
11
|
+
class SseClient
|
12
|
+
PROTOCOL_VERSION = "2024-11-05".freeze
|
13
|
+
CONNECTION_TIMEOUT = 10
|
14
|
+
OPEN_TIMEOUT = 30
|
15
|
+
|
16
|
+
# Creates a new client and establishes SSE connection to discover the JSON-RPC endpoint.
|
17
|
+
#
|
18
|
+
# @param url [String] the SSE endpoint URL
|
19
|
+
def initialize(url, headers: {})
|
20
|
+
@url = url
|
21
|
+
@endpoint_url = nil
|
22
|
+
@sse_thread = nil
|
23
|
+
@event_queue = Thread::Queue.new
|
24
|
+
@buffer = ""
|
25
|
+
@closed = false
|
26
|
+
@headers = headers
|
27
|
+
|
28
|
+
# Start the SSE connection and discover endpoint
|
29
|
+
establish_sse_connection
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns available tools from the server.
|
33
|
+
def tools
|
34
|
+
@tools ||= begin
|
35
|
+
request_id = SecureRandom.uuid
|
36
|
+
send_json_rpc(request_id, "tools/list", {})
|
37
|
+
|
38
|
+
# Wait for response through SSE
|
39
|
+
response = wait_for_response(request_id)
|
40
|
+
response[:tools].map do |tool_json|
|
41
|
+
Tool.from_json(tool_json)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Executes a tool with given arguments.
|
47
|
+
# Returns text content directly, or JSON-encoded data for other content types.
|
48
|
+
def call_tool(name, **arguments)
|
49
|
+
request_id = SecureRandom.uuid
|
50
|
+
send_json_rpc(request_id, "tools/call", name:, arguments:)
|
51
|
+
|
52
|
+
# Wait for response through SSE
|
53
|
+
response = wait_for_response(request_id)
|
54
|
+
content = response[:content]
|
55
|
+
return "" if content.nil? || content.empty?
|
56
|
+
|
57
|
+
# Handle different content formats
|
58
|
+
first_item = content.first
|
59
|
+
case first_item
|
60
|
+
when Hash
|
61
|
+
case first_item[:type]
|
62
|
+
when "text"
|
63
|
+
first_item[:text]
|
64
|
+
when "image"
|
65
|
+
# Return a structured response for images
|
66
|
+
{
|
67
|
+
type: "image",
|
68
|
+
data: first_item[:data],
|
69
|
+
mime_type: first_item[:mimeType] || "image/png"
|
70
|
+
}.to_json
|
71
|
+
else
|
72
|
+
# For any other type, return the item as JSON
|
73
|
+
first_item.to_json
|
74
|
+
end
|
75
|
+
else
|
76
|
+
first_item.to_s
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Closes the connection to the server.
|
81
|
+
def close
|
82
|
+
@closed = true
|
83
|
+
@sse_thread&.kill
|
84
|
+
@connection&.close
|
85
|
+
end
|
86
|
+
|
87
|
+
def unique_key
|
88
|
+
parametrized_url = @url.parameterize.underscore.gsub("https_", "")
|
89
|
+
Digest::SHA256.hexdigest(parametrized_url)[0..2]
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
# Establishes and maintains the SSE connection
|
95
|
+
def establish_sse_connection
|
96
|
+
@sse_thread = Thread.new do
|
97
|
+
headers = {
|
98
|
+
"Accept" => "text/event-stream",
|
99
|
+
"Cache-Control" => "no-cache",
|
100
|
+
"Connection" => "keep-alive",
|
101
|
+
"MCP-Version" => PROTOCOL_VERSION
|
102
|
+
}.merge(@headers)
|
103
|
+
|
104
|
+
@connection = Faraday.new(url: @url) do |faraday|
|
105
|
+
faraday.options.timeout = CONNECTION_TIMEOUT
|
106
|
+
faraday.options.open_timeout = OPEN_TIMEOUT
|
107
|
+
end
|
108
|
+
|
109
|
+
@connection.get do |req|
|
110
|
+
req.headers = headers
|
111
|
+
req.options.on_data = proc do |chunk, _size|
|
112
|
+
next if @closed
|
113
|
+
|
114
|
+
@buffer << chunk
|
115
|
+
process_sse_buffer
|
116
|
+
end
|
117
|
+
end
|
118
|
+
rescue StandardError => e
|
119
|
+
# puts "[MCP DEBUG] SSE connection error: #{e.message}"
|
120
|
+
@event_queue << { error: e }
|
121
|
+
end
|
122
|
+
|
123
|
+
# Wait for endpoint discovery
|
124
|
+
loop do
|
125
|
+
event = @event_queue.pop
|
126
|
+
if event[:error]
|
127
|
+
raise ProtocolError, "SSE connection failed: #{event[:error].message}"
|
128
|
+
elsif event[:endpoint_url]
|
129
|
+
@endpoint_url = event[:endpoint_url]
|
130
|
+
break
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Initialize the MCP session
|
135
|
+
initialize_mcp_session
|
136
|
+
end
|
137
|
+
|
138
|
+
# Process SSE buffer for complete events
|
139
|
+
def process_sse_buffer
|
140
|
+
while (idx = @buffer.index("\n\n"))
|
141
|
+
event_text = @buffer.slice!(0..idx + 1)
|
142
|
+
event_type, event_data = parse_sse_fields(event_text)
|
143
|
+
|
144
|
+
case event_type
|
145
|
+
when "endpoint"
|
146
|
+
endpoint_url = build_absolute_url(@url, event_data)
|
147
|
+
@event_queue << { endpoint_url: }
|
148
|
+
when "message"
|
149
|
+
handle_message_event(event_data)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Handle SSE message events
|
155
|
+
def handle_message_event(event_data)
|
156
|
+
parsed = JSON.parse(event_data, symbolize_names: true)
|
157
|
+
|
158
|
+
# Handle different message types
|
159
|
+
case parsed
|
160
|
+
when ->(p) { p[:method] == "initialize" && p.dig(:params, :endpoint_url) }
|
161
|
+
# Legacy endpoint discovery
|
162
|
+
endpoint_url = parsed.dig(:params, :endpoint_url)
|
163
|
+
@event_queue << { endpoint_url: }
|
164
|
+
when ->(p) { p[:id] && p[:result] }
|
165
|
+
@event_queue << { id: parsed[:id], result: parsed[:result] }
|
166
|
+
when ->(p) { p[:result] }
|
167
|
+
@event_queue << { result: parsed[:result] }
|
168
|
+
end
|
169
|
+
rescue JSON::ParserError => e
|
170
|
+
puts "[MCP DEBUG] Error parsing message: #{e.message}"
|
171
|
+
puts "[MCP DEBUG] Message data: #{event_data}"
|
172
|
+
end
|
173
|
+
|
174
|
+
# Initialize the MCP session
|
175
|
+
def initialize_mcp_session
|
176
|
+
request_id = SecureRandom.uuid
|
177
|
+
send_json_rpc(request_id, "initialize", {
|
178
|
+
protocolVersion: PROTOCOL_VERSION,
|
179
|
+
capabilities: {
|
180
|
+
roots: { listChanged: true },
|
181
|
+
sampling: {}
|
182
|
+
},
|
183
|
+
clientInfo: {
|
184
|
+
name: "Raix",
|
185
|
+
version: Raix::VERSION
|
186
|
+
}
|
187
|
+
})
|
188
|
+
|
189
|
+
# Wait for initialization response
|
190
|
+
response = wait_for_response(request_id)
|
191
|
+
|
192
|
+
# Send acknowledgment if needed
|
193
|
+
return unless response.dig(:capabilities, :tools, :listChanged)
|
194
|
+
|
195
|
+
send_notification("notifications/initialized", {})
|
196
|
+
end
|
197
|
+
|
198
|
+
# Send a JSON-RPC request
|
199
|
+
def send_json_rpc(id, method, params)
|
200
|
+
body = {
|
201
|
+
jsonrpc: JSONRPC_VERSION,
|
202
|
+
id:,
|
203
|
+
method:,
|
204
|
+
params:
|
205
|
+
}
|
206
|
+
|
207
|
+
# Use a new connection for the POST request
|
208
|
+
conn = Faraday.new(url: @endpoint_url) do |faraday|
|
209
|
+
faraday.options.timeout = CONNECTION_TIMEOUT
|
210
|
+
end
|
211
|
+
|
212
|
+
conn.post do |req|
|
213
|
+
req.headers["Content-Type"] = "application/json"
|
214
|
+
req.body = body.to_json
|
215
|
+
end
|
216
|
+
rescue StandardError => e
|
217
|
+
raise ProtocolError, "Failed to send request: #{e.message}"
|
218
|
+
end
|
219
|
+
|
220
|
+
# Send a notification (no response expected)
|
221
|
+
def send_notification(method, params)
|
222
|
+
body = {
|
223
|
+
jsonrpc: JSONRPC_VERSION,
|
224
|
+
method:,
|
225
|
+
params:
|
226
|
+
}
|
227
|
+
|
228
|
+
conn = Faraday.new(url: @endpoint_url) do |faraday|
|
229
|
+
faraday.options.timeout = CONNECTION_TIMEOUT
|
230
|
+
end
|
231
|
+
|
232
|
+
conn.post do |req|
|
233
|
+
req.headers["Content-Type"] = "application/json"
|
234
|
+
req.body = body.to_json
|
235
|
+
end
|
236
|
+
rescue StandardError => e
|
237
|
+
puts "[MCP DEBUG] Error sending notification: #{e.message}"
|
238
|
+
end
|
239
|
+
|
240
|
+
# Wait for a response with a specific ID
|
241
|
+
def wait_for_response(request_id)
|
242
|
+
timeout = Time.now + CONNECTION_TIMEOUT
|
243
|
+
|
244
|
+
loop do
|
245
|
+
if Time.now > timeout
|
246
|
+
raise ProtocolError, "Timeout waiting for response"
|
247
|
+
end
|
248
|
+
|
249
|
+
# Use non-blocking pop with timeout
|
250
|
+
begin
|
251
|
+
event = @event_queue.pop(true) # non_block = true
|
252
|
+
rescue ThreadError
|
253
|
+
# Queue is empty, wait a bit
|
254
|
+
sleep 0.1
|
255
|
+
next
|
256
|
+
end
|
257
|
+
|
258
|
+
if event[:error]
|
259
|
+
raise ProtocolError, "SSE error: #{event[:error].message}"
|
260
|
+
elsif event[:result] && (event[:id] == request_id || !event[:id])
|
261
|
+
return event[:result]
|
262
|
+
else
|
263
|
+
@event_queue << event
|
264
|
+
sleep 0.01
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# Parses SSE event fields from raw text.
|
270
|
+
def parse_sse_fields(event_text)
|
271
|
+
event_type = "message"
|
272
|
+
data_lines = []
|
273
|
+
|
274
|
+
event_text.each_line do |line|
|
275
|
+
case line
|
276
|
+
when /^event:\s*(.+)$/
|
277
|
+
event_type = Regexp.last_match(1).strip
|
278
|
+
when /^data:\s*(.*)$/
|
279
|
+
data_lines << Regexp.last_match(1)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
[event_type, data_lines.join("\n").strip]
|
284
|
+
end
|
285
|
+
|
286
|
+
# Builds an absolute URL for candidate relative to base.
|
287
|
+
def build_absolute_url(base, candidate)
|
288
|
+
uri = URI.parse(candidate)
|
289
|
+
return candidate if uri.absolute?
|
290
|
+
|
291
|
+
URI.join(base, candidate).to_s
|
292
|
+
rescue URI::InvalidURIError
|
293
|
+
candidate
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require_relative "tool"
|
2
|
+
require "json"
|
3
|
+
require "securerandom"
|
4
|
+
require "digest"
|
5
|
+
|
6
|
+
module Raix
|
7
|
+
module MCP
|
8
|
+
# Client for communicating with MCP servers via stdio using JSON-RPC.
|
9
|
+
class StdioClient
|
10
|
+
# Creates a new client with a bidirectional pipe to the MCP server.
|
11
|
+
def initialize(*args, env)
|
12
|
+
@args = args
|
13
|
+
@io = IO.popen(env, args, "w+")
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns available tools from the server.
|
17
|
+
def tools
|
18
|
+
result = call("tools/list")
|
19
|
+
|
20
|
+
result["tools"].map do |tool_json|
|
21
|
+
Tool.from_json(tool_json)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Executes a tool with given arguments.
|
26
|
+
# Returns text content directly, or JSON-encoded data for other content types.
|
27
|
+
def call_tool(name, **arguments)
|
28
|
+
result = call("tools/call", name:, arguments:)
|
29
|
+
content = result["content"]
|
30
|
+
return "" if content.nil? || content.empty?
|
31
|
+
|
32
|
+
# Handle different content formats
|
33
|
+
first_item = content.first
|
34
|
+
case first_item
|
35
|
+
when Hash
|
36
|
+
case first_item["type"]
|
37
|
+
when "text"
|
38
|
+
first_item["text"]
|
39
|
+
when "image"
|
40
|
+
# Return a structured response for images
|
41
|
+
{
|
42
|
+
type: "image",
|
43
|
+
data: first_item["data"],
|
44
|
+
mime_type: first_item["mimeType"] || "image/png"
|
45
|
+
}.to_json
|
46
|
+
else
|
47
|
+
# For any other type, return the item as JSON
|
48
|
+
first_item.to_json
|
49
|
+
end
|
50
|
+
else
|
51
|
+
first_item.to_s
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Closes the connection to the server.
|
56
|
+
def close
|
57
|
+
@io.close
|
58
|
+
end
|
59
|
+
|
60
|
+
def unique_key
|
61
|
+
parametrized_args = @args.join(" ").parameterize.underscore
|
62
|
+
Digest::SHA256.hexdigest(parametrized_args)[0..2]
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Sends JSON-RPC request and returns the result.
|
68
|
+
def call(method, **params)
|
69
|
+
@io.puts({ id: SecureRandom.uuid, method:, params:, jsonrpc: JSONRPC_VERSION }.to_json)
|
70
|
+
@io.flush # Ensure data is immediately sent
|
71
|
+
message = JSON.parse(@io.gets)
|
72
|
+
if (error = message["error"])
|
73
|
+
raise ProtocolError, error["message"]
|
74
|
+
end
|
75
|
+
|
76
|
+
message["result"]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/lib/mcp/tool.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
module Raix
|
2
|
+
module MCP
|
3
|
+
# Represents an MCP (Model Context Protocol) tool with metadata and schema
|
4
|
+
#
|
5
|
+
# @example
|
6
|
+
# tool = Tool.new(
|
7
|
+
# name: "weather",
|
8
|
+
# description: "Get weather info",
|
9
|
+
# input_schema: { "type" => "object", "properties" => { "city" => { "type" => "string" } } }
|
10
|
+
# )
|
11
|
+
class Tool
|
12
|
+
attr_reader :name, :description, :input_schema
|
13
|
+
|
14
|
+
# Initialize a new Tool
|
15
|
+
#
|
16
|
+
# @param name [String] the tool name
|
17
|
+
# @param description [String] human-readable description of what the tool does
|
18
|
+
# @param input_schema [Hash] JSON schema defining the tool's input parameters
|
19
|
+
def initialize(name:, description:, input_schema: {})
|
20
|
+
@name = name
|
21
|
+
@description = description
|
22
|
+
@input_schema = input_schema
|
23
|
+
end
|
24
|
+
|
25
|
+
# Initialize from raw MCP JSON response
|
26
|
+
#
|
27
|
+
# @param json [Hash] parsed JSON data from MCP response
|
28
|
+
# @return [Tool] new Tool instance
|
29
|
+
def self.from_json(json)
|
30
|
+
new(
|
31
|
+
name: json[:name] || json["name"],
|
32
|
+
description: json[:description] || json["description"],
|
33
|
+
input_schema: json[:inputSchema] || json["inputSchema"] || {}
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Get the input schema type
|
38
|
+
#
|
39
|
+
# @return [String, nil] the schema type (e.g., "object")
|
40
|
+
def input_type
|
41
|
+
input_schema["type"]
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get the properties hash
|
45
|
+
#
|
46
|
+
# @return [Hash] schema properties definition
|
47
|
+
def properties
|
48
|
+
input_schema["properties"] || {}
|
49
|
+
end
|
50
|
+
|
51
|
+
# Get required properties array
|
52
|
+
#
|
53
|
+
# @return [Array<String>] list of required property names
|
54
|
+
def required_properties
|
55
|
+
input_schema["required"] || []
|
56
|
+
end
|
57
|
+
|
58
|
+
# Check if a property is required
|
59
|
+
#
|
60
|
+
# @param property_name [String] name of the property to check
|
61
|
+
# @return [Boolean] true if the property is required
|
62
|
+
def required?(property_name)
|
63
|
+
required_properties.include?(property_name)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|