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.
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