raix 0.8.6 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f28d49373248dcc8209a5c4c3678c632953aab2282f3f71a887ee1e2b3e7d249
4
- data.tar.gz: bacd7d9ef9b7aff8ab6b998d1ddb2efe3fd3dfa5855d8d5851161a271b056963
3
+ metadata.gz: ed80bdf079ec738beb42df145184b5b23b5ad6c2f507233afd9aed47a1ac61f9
4
+ data.tar.gz: b38006ad88398d9584d29fad763b7361cd22f7d6d6fb3d51e70b6ac80a5c39aa
5
5
  SHA512:
6
- metadata.gz: a7a75b0c7c8f9ecfcbae002e6b5dfea7cee17be8d3d8825dacf2276f200f9bb542e0ccc4f7bb402b03b57d0240d84431bba3d749657a51ef4a90e937fd096633
7
- data.tar.gz: b367bdd61f7c7fb269232387c0d31af1cb2315d9bed8abc16758931851dab4b849139cb23e1c2c4236f8e8ced55368726db8c2a308efa3b6db503290d07165c2
6
+ metadata.gz: c896ee3e447b7d45e009cc0c18b6d5527f4fc2620773a27a94743c591e9f535be0f1692e4e4d2b1a60b2059b813e66c5c5c32464f7ea194a95d6b31e553fad59
7
+ data.tar.gz: 822e63a7375693fec475f6056db092c5e1a75398cd513e7650b8bfeec6535fe42c85681b98d4867869bca7e83a156cae5ac8101573c6ebd25f899d86c9b1fe46
data/.rubocop.yml CHANGED
@@ -36,6 +36,9 @@ Metrics/PerceivedComplexity:
36
36
  Metrics/ParameterLists:
37
37
  Enabled: false
38
38
 
39
+ Metrics/ClassLength:
40
+ Enabled: false
41
+
39
42
  Style/FrozenStringLiteralComment:
40
43
  Enabled: false
41
44
 
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.9.0] - 2025-05-30
2
+ ### Added
3
+ - **MCP (Model Context Protocol) Support**
4
+ - New `stdio_mcp` method for stdio-based MCP servers
5
+ - Refactored existing MCP code into `SseClient` and `StdioClient`
6
+ - Split top-level `mcp` method into `sse_mcp` and `stdio_mcp`
7
+ - Added authentication support for MCP servers
8
+ - **Class-Level Configuration**
9
+ - Moved configuration to separate `Configuration` class
10
+ - Added fallback mechanism for configuration options
11
+ - Cleaner metaprogramming implementation
12
+
13
+ ### Fixed
14
+ - Fixed method signature of functions added via MCP
15
+
1
16
  ## [0.8.6] - 2025-05-19
2
17
  - add `required` and `optional` flags for parameters in `function` declarations
3
18
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- raix (0.8.6)
4
+ raix (0.9.0)
5
5
  activesupport (>= 6.0)
6
6
  faraday-retry (~> 2.0)
7
7
  open_router (~> 0.2)
data/README.md CHANGED
@@ -703,6 +703,21 @@ You can add an initializer to your application's `config/initializers` directory
703
703
 
704
704
  You will also need to configure the OpenRouter API access token as per the instructions here: https://github.com/OlympiaAI/open_router?tab=readme-ov-file#quickstart
705
705
 
706
+ ### Global vs class level configuration
707
+
708
+ You can either configure Raix globally or at the class level. The global configuration is set in the initializer as shown above. You can however also override all configuration options of the `Configuration` class on the class level with the
709
+ same syntax:
710
+
711
+ ```ruby
712
+ class MyClass
713
+ include Raix::ChatCompletion
714
+
715
+ configure do |config|
716
+ config.openrouter_client = OpenRouter::Client.new # with my special options
717
+ end
718
+ end
719
+ ```
720
+
706
721
  ## Development
707
722
 
708
723
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,287 @@
1
+ require_relative "tool"
2
+ require "json"
3
+ require "securerandom"
4
+ require "faraday"
5
+ require "uri"
6
+
7
+ module Raix
8
+ module MCP
9
+ # Client for communicating with MCP servers via Server-Sent Events (SSE).
10
+ class SseClient
11
+ PROTOCOL_VERSION = "2024-11-05".freeze
12
+ CONNECTION_TIMEOUT = 10
13
+ OPEN_TIMEOUT = 30
14
+
15
+ # Creates a new client and establishes SSE connection to discover the JSON-RPC endpoint.
16
+ #
17
+ # @param url [String] the SSE endpoint URL
18
+ def initialize(url, headers: {})
19
+ @url = url
20
+ @endpoint_url = nil
21
+ @sse_thread = nil
22
+ @event_queue = Thread::Queue.new
23
+ @buffer = ""
24
+ @closed = false
25
+ @headers = headers
26
+
27
+ # Start the SSE connection and discover endpoint
28
+ establish_sse_connection
29
+ end
30
+
31
+ # Returns available tools from the server.
32
+ def tools
33
+ @tools ||= begin
34
+ request_id = SecureRandom.uuid
35
+ send_json_rpc(request_id, "tools/list", {})
36
+
37
+ # Wait for response through SSE
38
+ response = wait_for_response(request_id)
39
+ response[:tools].map do |tool_json|
40
+ Tool.from_json(tool_json)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Executes a tool with given arguments, returns text content.
46
+ def call_tool(name, **arguments)
47
+ request_id = SecureRandom.uuid
48
+ send_json_rpc(request_id, "tools/call", name:, arguments:)
49
+
50
+ # Wait for response through SSE
51
+ response = wait_for_response(request_id)
52
+ content = response[:content]
53
+ return "" if content.nil? || content.empty?
54
+
55
+ # Handle different content formats
56
+ first_item = content.first
57
+ case first_item
58
+ when Hash
59
+ if first_item[:type] == "text"
60
+ first_item[:text]
61
+ else
62
+ first_item.to_json
63
+ end
64
+ else
65
+ first_item.to_s
66
+ end
67
+ end
68
+
69
+ # Closes the connection to the server.
70
+ def close
71
+ @closed = true
72
+ @sse_thread&.kill
73
+ @connection&.close
74
+ end
75
+
76
+ def unique_key
77
+ @url.parameterize.underscore.gsub("https_", "")
78
+ end
79
+
80
+ private
81
+
82
+ # Establishes and maintains the SSE connection
83
+ def establish_sse_connection
84
+ @sse_thread = Thread.new do
85
+ headers = {
86
+ "Accept" => "text/event-stream",
87
+ "Cache-Control" => "no-cache",
88
+ "Connection" => "keep-alive",
89
+ "MCP-Version" => PROTOCOL_VERSION
90
+ }.merge(@headers)
91
+
92
+ @connection = Faraday.new(url: @url) do |faraday|
93
+ faraday.options.timeout = CONNECTION_TIMEOUT
94
+ faraday.options.open_timeout = OPEN_TIMEOUT
95
+ end
96
+
97
+ @connection.get do |req|
98
+ req.headers = headers
99
+ req.options.on_data = proc do |chunk, _size|
100
+ next if @closed
101
+
102
+ @buffer << chunk
103
+ process_sse_buffer
104
+ end
105
+ end
106
+ rescue StandardError => e
107
+ # puts "[MCP DEBUG] SSE connection error: #{e.message}"
108
+ @event_queue << { error: e }
109
+ end
110
+
111
+ # Wait for endpoint discovery
112
+ loop do
113
+ event = @event_queue.pop
114
+ if event[:error]
115
+ raise ProtocolError, "SSE connection failed: #{event[:error].message}"
116
+ elsif event[:endpoint_url]
117
+ @endpoint_url = event[:endpoint_url]
118
+ break
119
+ end
120
+ end
121
+
122
+ # Initialize the MCP session
123
+ initialize_mcp_session
124
+ end
125
+
126
+ # Process SSE buffer for complete events
127
+ def process_sse_buffer
128
+ while (idx = @buffer.index("\n\n"))
129
+ event_text = @buffer.slice!(0..idx + 1)
130
+ event_type, event_data = parse_sse_fields(event_text)
131
+
132
+ case event_type
133
+ when "endpoint"
134
+ endpoint_url = build_absolute_url(@url, event_data)
135
+ @event_queue << { endpoint_url: }
136
+ when "message"
137
+ handle_message_event(event_data)
138
+ end
139
+ end
140
+ end
141
+
142
+ # Handle SSE message events
143
+ def handle_message_event(event_data)
144
+ parsed = JSON.parse(event_data, symbolize_names: true)
145
+
146
+ # Handle different message types
147
+ case parsed
148
+ when ->(p) { p[:method] == "initialize" && p.dig(:params, :endpoint_url) }
149
+ # Legacy endpoint discovery
150
+ endpoint_url = parsed.dig(:params, :endpoint_url)
151
+ @event_queue << { endpoint_url: }
152
+ when ->(p) { p[:id] && p[:result] }
153
+ @event_queue << { id: parsed[:id], result: parsed[:result] }
154
+ when ->(p) { p[:result] }
155
+ @event_queue << { result: parsed[:result] }
156
+ end
157
+ rescue JSON::ParserError => e
158
+ puts "[MCP DEBUG] Error parsing message: #{e.message}"
159
+ puts "[MCP DEBUG] Message data: #{event_data}"
160
+ end
161
+
162
+ # Initialize the MCP session
163
+ def initialize_mcp_session
164
+ request_id = SecureRandom.uuid
165
+ send_json_rpc(request_id, "initialize", {
166
+ protocolVersion: PROTOCOL_VERSION,
167
+ capabilities: {
168
+ roots: { listChanged: true },
169
+ sampling: {}
170
+ },
171
+ clientInfo: {
172
+ name: "Raix",
173
+ version: Raix::VERSION
174
+ }
175
+ })
176
+
177
+ # Wait for initialization response
178
+ response = wait_for_response(request_id)
179
+
180
+ # Send acknowledgment if needed
181
+ return unless response.dig(:capabilities, :tools, :listChanged)
182
+
183
+ send_notification("notifications/initialized", {})
184
+ end
185
+
186
+ # Send a JSON-RPC request
187
+ def send_json_rpc(id, method, params)
188
+ body = {
189
+ jsonrpc: JSONRPC_VERSION,
190
+ id:,
191
+ method:,
192
+ params:
193
+ }
194
+
195
+ # Use a new connection for the POST request
196
+ conn = Faraday.new(url: @endpoint_url) do |faraday|
197
+ faraday.options.timeout = CONNECTION_TIMEOUT
198
+ end
199
+
200
+ conn.post do |req|
201
+ req.headers["Content-Type"] = "application/json"
202
+ req.body = body.to_json
203
+ end
204
+ rescue StandardError => e
205
+ raise ProtocolError, "Failed to send request: #{e.message}"
206
+ end
207
+
208
+ # Send a notification (no response expected)
209
+ def send_notification(method, params)
210
+ body = {
211
+ jsonrpc: JSONRPC_VERSION,
212
+ method:,
213
+ params:
214
+ }
215
+
216
+ conn = Faraday.new(url: @endpoint_url) do |faraday|
217
+ faraday.options.timeout = CONNECTION_TIMEOUT
218
+ end
219
+
220
+ conn.post do |req|
221
+ req.headers["Content-Type"] = "application/json"
222
+ req.body = body.to_json
223
+ end
224
+ rescue StandardError => e
225
+ puts "[MCP DEBUG] Error sending notification: #{e.message}"
226
+ end
227
+
228
+ # Wait for a response with a specific ID
229
+ def wait_for_response(request_id)
230
+ timeout = Time.now + CONNECTION_TIMEOUT
231
+
232
+ loop do
233
+ if Time.now > timeout
234
+ raise ProtocolError, "Timeout waiting for response"
235
+ end
236
+
237
+ # Use non-blocking pop with timeout
238
+ begin
239
+ event = @event_queue.pop(true) # non_block = true
240
+ rescue ThreadError
241
+ # Queue is empty, wait a bit
242
+ sleep 0.1
243
+ next
244
+ end
245
+
246
+ if event[:error]
247
+ raise ProtocolError, "SSE error: #{event[:error].message}"
248
+ elsif event[:id] == request_id && event[:result]
249
+ return event[:result]
250
+ elsif event[:result] && !event[:id]
251
+ return event[:result]
252
+ else
253
+ @event_queue << event
254
+ sleep 0.01
255
+ end
256
+ end
257
+ end
258
+
259
+ # Parses SSE event fields from raw text.
260
+ def parse_sse_fields(event_text)
261
+ event_type = "message"
262
+ data_lines = []
263
+
264
+ event_text.each_line do |line|
265
+ case line
266
+ when /^event:\s*(.+)$/
267
+ event_type = Regexp.last_match(1).strip
268
+ when /^data:\s*(.*)$/
269
+ data_lines << Regexp.last_match(1)
270
+ end
271
+ end
272
+
273
+ [event_type, data_lines.join("\n").strip]
274
+ end
275
+
276
+ # Builds an absolute URL for candidate relative to base.
277
+ def build_absolute_url(base, candidate)
278
+ uri = URI.parse(candidate)
279
+ return candidate if uri.absolute?
280
+
281
+ URI.join(base, candidate).to_s
282
+ rescue URI::InvalidURIError
283
+ candidate
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,58 @@
1
+ require_relative "tool"
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Raix
6
+ module MCP
7
+ # Client for communicating with MCP servers via stdio using JSON-RPC.
8
+ class StdioClient
9
+ # Creates a new client with a bidirectional pipe to the MCP server.
10
+ def initialize(*args, env)
11
+ @args = args
12
+ @io = IO.popen(env, args, "w+")
13
+ end
14
+
15
+ # Returns available tools from the server.
16
+ def tools
17
+ result = call("tools/list")
18
+
19
+ result["tools"].map do |tool_json|
20
+ Tool.from_json(tool_json)
21
+ end
22
+ end
23
+
24
+ # Executes a tool with given arguments, returns text content.
25
+ def call_tool(name, **arguments)
26
+ result = call("tools/call", name:, arguments:)
27
+ unless result.dig("content", 0, "type") == "text"
28
+ raise NotImplementedError, "Only text is supported"
29
+ end
30
+
31
+ result.dig("content", 0, "text")
32
+ end
33
+
34
+ # Closes the connection to the server.
35
+ def close
36
+ @io.close
37
+ end
38
+
39
+ def unique_key
40
+ @args.join(" ").parameterize.underscore
41
+ end
42
+
43
+ private
44
+
45
+ # Sends JSON-RPC request and returns the result.
46
+ def call(method, **params)
47
+ @io.puts({ id: SecureRandom.uuid, method:, params:, jsonrpc: JSONRPC_VERSION }.to_json)
48
+ @io.flush # Ensure data is immediately sent
49
+ message = JSON.parse(@io.gets)
50
+ if (error = message["error"])
51
+ raise ProtocolError, error["message"]
52
+ end
53
+
54
+ message["result"]
55
+ end
56
+ end
57
+ end
58
+ 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
@@ -38,6 +38,23 @@ module Raix
38
38
  :prediction, :repetition_penalty, :response_format, :stream, :temperature, :max_completion_tokens,
39
39
  :max_tokens, :seed, :stop, :top_a, :top_k, :top_logprobs, :top_p, :tools, :available_tools, :tool_choice, :provider
40
40
 
41
+ class_methods do
42
+ # Returns the current configuration of this class. Falls back to global configuration for unset values.
43
+ def configuration
44
+ @configuration ||= Configuration.new(fallback: Raix.configuration)
45
+ end
46
+
47
+ # Let's you configure the class-level configuration using a block.
48
+ def configure
49
+ yield(configuration)
50
+ end
51
+ end
52
+
53
+ # Instance level access to the class-level configuration.
54
+ def configuration
55
+ self.class.configuration
56
+ end
57
+
41
58
  # This method performs chat completion based on the provided transcript and parameters.
42
59
  #
43
60
  # @param params [Hash] The parameters for chat completion.
@@ -54,8 +71,8 @@ module Raix
54
71
  params[:frequency_penalty] ||= frequency_penalty.presence
55
72
  params[:logit_bias] ||= logit_bias.presence
56
73
  params[:logprobs] ||= logprobs.presence
57
- params[:max_completion_tokens] ||= max_completion_tokens.presence || Raix.configuration.max_completion_tokens
58
- params[:max_tokens] ||= max_tokens.presence || Raix.configuration.max_tokens
74
+ params[:max_completion_tokens] ||= max_completion_tokens.presence || configuration.max_completion_tokens
75
+ params[:max_tokens] ||= max_tokens.presence || configuration.max_tokens
59
76
  params[:min_p] ||= min_p.presence
60
77
  params[:prediction] = { type: "content", content: params[:prediction] || prediction } if params[:prediction] || prediction.present?
61
78
  params[:presence_penalty] ||= presence_penalty.presence
@@ -64,7 +81,7 @@ module Raix
64
81
  params[:response_format] ||= response_format.presence
65
82
  params[:seed] ||= seed.presence
66
83
  params[:stop] ||= stop.presence
67
- params[:temperature] ||= temperature.presence || Raix.configuration.temperature
84
+ params[:temperature] ||= temperature.presence || configuration.temperature
68
85
  params[:tool_choice] ||= tool_choice.presence
69
86
  params[:tools] = if available_tools == false
70
87
  nil
@@ -95,7 +112,7 @@ module Raix
95
112
  self.loop = loop
96
113
 
97
114
  # set the model to the default if not provided
98
- self.model ||= Raix.configuration.model
115
+ self.model ||= configuration.model
99
116
 
100
117
  adapter = MessageAdapters::Base.new(self)
101
118
 
@@ -227,7 +244,7 @@ module Raix
227
244
 
228
245
  params.delete(:temperature) if model.start_with?("o")
229
246
 
230
- Raix.configuration.openai_client.chat(parameters: params.compact.merge(model:, messages:))
247
+ configuration.openai_client.chat(parameters: params.compact.merge(model:, messages:))
231
248
  end
232
249
 
233
250
  def openrouter_request(params:, model:, messages:)
@@ -237,7 +254,7 @@ module Raix
237
254
  retry_count = 0
238
255
 
239
256
  begin
240
- Raix.configuration.openrouter_client.complete(messages, model:, extras: params.compact, stream:)
257
+ configuration.openrouter_client.complete(messages, model:, extras: params.compact, stream:)
241
258
  rescue OpenRouter::ServerError => e
242
259
  if e.message.include?("retry")
243
260
  puts "Retrying OpenRouter request... (#{retry_count} attempts) #{e.message}"
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raix
4
+ # The Configuration class holds the configuration options for the Raix gem.
5
+ class Configuration
6
+ def self.attr_accessor_with_fallback(method_name)
7
+ define_method(method_name) do
8
+ value = instance_variable_get("@#{method_name}")
9
+ return value if value
10
+ return unless fallback
11
+
12
+ fallback.public_send(method_name)
13
+ end
14
+ define_method("#{method_name}=") do |value|
15
+ instance_variable_set("@#{method_name}", value)
16
+ end
17
+ end
18
+
19
+ # The temperature option determines the randomness of the generated text.
20
+ # Higher values result in more random output.
21
+ attr_accessor_with_fallback :temperature
22
+
23
+ # The max_tokens option determines the maximum number of tokens to generate.
24
+ attr_accessor_with_fallback :max_tokens
25
+
26
+ # The max_completion_tokens option determines the maximum number of tokens to generate.
27
+ attr_accessor_with_fallback :max_completion_tokens
28
+
29
+ # The model option determines the model to use for text generation. This option
30
+ # is normally set in each class that includes the ChatCompletion module.
31
+ attr_accessor_with_fallback :model
32
+
33
+ # The openrouter_client option determines the default client to use for communication.
34
+ attr_accessor_with_fallback :openrouter_client
35
+
36
+ # The openai_client option determines the OpenAI client to use for communication.
37
+ attr_accessor_with_fallback :openai_client
38
+
39
+ DEFAULT_MAX_TOKENS = 1000
40
+ DEFAULT_MAX_COMPLETION_TOKENS = 16_384
41
+ DEFAULT_MODEL = "meta-llama/llama-3-8b-instruct:free"
42
+ DEFAULT_TEMPERATURE = 0.0
43
+
44
+ # Initializes a new instance of the Configuration class with default values.
45
+ def initialize(fallback: nil)
46
+ self.temperature = DEFAULT_TEMPERATURE
47
+ self.max_completion_tokens = DEFAULT_MAX_COMPLETION_TOKENS
48
+ self.max_tokens = DEFAULT_MAX_TOKENS
49
+ self.model = DEFAULT_MODEL
50
+ self.fallback = fallback
51
+ end
52
+
53
+ private
54
+
55
+ attr_accessor :fallback
56
+
57
+ def get_with_fallback(method)
58
+ value = instance_variable_get("@#{method}")
59
+ return value if value
60
+ return unless fallback
61
+
62
+ fallback.public_send(method)
63
+ end
64
+ end
65
+ end
data/lib/raix/mcp.rb CHANGED
@@ -14,9 +14,10 @@
14
14
  require "active_support/concern"
15
15
  require "active_support/inflector"
16
16
  require "securerandom"
17
- require "faraday"
18
17
  require "uri"
19
- require "json"
18
+
19
+ require_relative "../mcp/sse_client"
20
+ require_relative "../mcp/stdio_client"
20
21
 
21
22
  module Raix
22
23
  # Model Context Protocol integration for Raix
@@ -28,15 +29,38 @@ module Raix
28
29
  module MCP
29
30
  extend ActiveSupport::Concern
30
31
 
32
+ # Error raised when there's a protocol-level error in MCP communication
33
+ class ProtocolError < StandardError; end
34
+
31
35
  JSONRPC_VERSION = "2.0".freeze
32
- PROTOCOL_VERSION = "2024-11-05".freeze # Current supported protocol version
33
- CONNECTION_TIMEOUT = 10
34
- OPEN_TIMEOUT = 30
35
36
 
36
37
  class_methods do
37
- # Declare an MCP server by URL.
38
+ # Declare an MCP server by URL, using the SSE transport.
39
+ #
40
+ # sse_mcp "https://server.example.com/sse",
41
+ # headers: { "Authorization" => "Bearer <token>" },
42
+ # only: [:get_issue]
43
+ #
44
+ def sse_mcp(url, headers: {}, only: nil, except: nil)
45
+ mcp(only:, except:, client: MCP::SseClient.new(url, headers:))
46
+ end
47
+
48
+ # Declare an MCP server by command line arguments, and environment variables ,
49
+ # using the stdio transport.
38
50
  #
39
- # mcp "https://server.example.com/sse"
51
+ # stdio_mcp "docker", "run", "-i", "--rm",
52
+ # "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
53
+ # "ghcr.io/github/github-mcp-server",
54
+ # env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${input:github_token}" },
55
+ # only: [:github_search]
56
+ #
57
+ def stdio_mcp(*args, env: {}, only: nil, except: nil)
58
+ mcp(only:, except:, client: MCP::StdioClient.new(*args, env))
59
+ end
60
+
61
+ # Declare an MCP server, using the given client.
62
+ #
63
+ # mcp client: MCP::SseClient.new("https://server.example.com/sse")
40
64
  #
41
65
  # This will automatically:
42
66
  # • query `tools/list` on the server
@@ -46,72 +70,57 @@ module Raix
46
70
  # call to the server and appends the proper messages to the
47
71
  # transcript.
48
72
  # NOTE TO SELF: NEVER MOCK SERVER RESPONSES! THIS MUST WORK WITH REAL SERVERS!
49
- def mcp(url, only: nil, except: nil)
73
+ def mcp(client:, only: nil, except: nil)
50
74
  @mcp_servers ||= {}
51
75
 
52
- return if @mcp_servers.key?(url) # avoid duplicate definitions
76
+ return if @mcp_servers.key?(client.unique_key) # avoid duplicate definitions
53
77
 
54
- # Connect and initialize the SSE endpoint
55
-
56
- result = Thread::Queue.new
57
- Thread.new do
58
- establish_sse_connection(url, result:)
59
- end
60
- tools = result.pop
78
+ # Fetch tools
79
+ tools = client.tools
61
80
 
62
81
  if tools.empty?
63
- puts "[MCP DEBUG] No tools found from MCP server at #{url}"
82
+ # puts "[MCP DEBUG] No tools found from MCP server at #{url}"
83
+ client.close
64
84
  return nil
65
85
  end
66
86
 
67
- # 3. Register each tool so ChatCompletion#tools picks them up
68
87
  # Apply filters
69
88
  filtered_tools = if only.present?
70
89
  only_symbols = Array(only).map(&:to_sym)
71
- tools.select { |tool| only_symbols.include?(tool["name"].to_sym) }
90
+ tools.select { |tool| only_symbols.include?(tool.name.to_sym) }
72
91
  elsif except.present?
73
92
  except_symbols = Array(except).map(&:to_sym)
74
- tools.reject { |tool| except_symbols.include?(tool["name"].to_sym) }
93
+ tools.reject { |tool| except_symbols.include?(tool.name.to_sym) }
75
94
  else
76
95
  tools
77
96
  end
78
97
 
79
98
  # Ensure FunctionDispatch is included in the class
80
- # Explicit include in the class context
81
99
  include FunctionDispatch unless included_modules.include?(FunctionDispatch)
82
- puts "[MCP DEBUG] FunctionDispatch included in #{name}"
100
+ # puts "[MCP DEBUG] FunctionDispatch included in #{name}"
83
101
 
84
102
  filtered_tools.each do |tool|
85
- remote_name = tool[:name]
103
+ remote_name = tool.name
86
104
  # TODO: Revisit later whether this much context is needed in the function name
87
- local_name = "#{url.parameterize.underscore}_#{remote_name}".gsub("https_", "").to_sym
105
+ local_name = "#{client.unique_key}_#{remote_name}".to_sym
88
106
 
89
- description = tool["description"]
90
- input_schema = tool["inputSchema"] || {}
107
+ description = tool.description
108
+ input_schema = tool.input_schema || {}
91
109
 
92
110
  # --- register with FunctionDispatch (adds to .functions)
93
111
  function(local_name, description, **{}) # placeholder parameters replaced next
94
112
  latest_definition = functions.last
95
- latest_definition[:parameters] = input_schema.deep_symbolize_keys if input_schema.present?
113
+ latest_definition[:parameters] = input_schema.deep_symbolize_keys || {}
114
+
115
+ # Required by OpenAI
116
+ latest_definition[:parameters][:properties] ||= {}
96
117
 
97
118
  # --- define an instance method that proxies to the server
98
- define_method(local_name) do |**arguments|
119
+ define_method(local_name) do |arguments, _cache|
99
120
  arguments ||= {}
100
121
 
122
+ content_text = client.call_tool(remote_name, **arguments)
101
123
  call_id = SecureRandom.uuid
102
- result = Thread::Queue.new
103
- Thread.new do
104
- self.class.establish_sse_connection(url, name: remote_name, arguments:, result:)
105
- end
106
-
107
- content_item = result.pop
108
-
109
- # Decide what to add to the transcript
110
- content_text = if content_item.is_a?(Hash) && content_item["type"] == "text"
111
- content_item["text"]
112
- else
113
- content_item.to_json
114
- end
115
124
 
116
125
  # Mirror FunctionDispatch transcript behaviour
117
126
  transcript << [
@@ -144,193 +153,8 @@ module Raix
144
153
  end
145
154
  end
146
155
 
147
- # Store the URL and tools for future use
148
- @mcp_servers[url] = { tools: }
149
- end
150
-
151
- # Establishes an SSE connection to +url+ and returns the JSON‑RPC POST endpoint
152
- # advertised by the server. The MCP specification allows two different event
153
- # formats during initialization:
154
- #
155
- # 1. A generic JSON‑RPC *initialize* event (the behaviour previously
156
- # implemented):
157
- #
158
- # event: message (implicit when no explicit event type is given)
159
- # data: {"jsonrpc":"2.0","method":"initialize","params":{"endpoint_url":"https://…/rpc"}}
160
- #
161
- # 2. A dedicated *endpoint* event, as implemented by the reference
162
- # TypeScript SDK and the public GitMCP server used in our test-suite:
163
- #
164
- # event: endpoint\n
165
- # data: /rpc\n
166
- #
167
- # This method now supports **both** formats.
168
- #
169
- # It uses Net::HTTP directly rather than Faraday streaming because the latter
170
- # does not consistently surface partial body reads across adapters. The
171
- # implementation reads the response body incrementally, splitting on the
172
- # SSE record delimiter (double newline) and processing each event until an
173
- # endpoint is discovered (or a timeout / connection error occurs).
174
- def establish_sse_connection(url, name: nil, arguments: {}, result: nil)
175
- puts "[MCP DEBUG] Establishing MCP connection with URL: #{url}"
176
-
177
- headers = {
178
- "Accept" => "text/event-stream",
179
- "Cache-Control" => "no-cache",
180
- "Connection" => "keep-alive",
181
- "MCP-Version" => PROTOCOL_VERSION
182
- }
183
-
184
- endpoint_url = nil
185
- buffer = ""
186
-
187
- connection = Faraday.new(url:) do |faraday|
188
- faraday.options.timeout = CONNECTION_TIMEOUT
189
- faraday.options.open_timeout = OPEN_TIMEOUT
190
- end
191
-
192
- connection.get do |req|
193
- req.headers = headers
194
- req.options.on_data = proc do |chunk, _size|
195
- buffer << chunk
196
-
197
- # Process complete SSE events (separated by a blank line)
198
- while (idx = buffer.index("\n\n"))
199
- event_text = buffer.slice!(0..idx + 1) # include delimiter
200
- event_type, event_data = parse_sse_fields(event_text)
201
-
202
- case event_type
203
- when "endpoint"
204
- # event data is expected to be a plain string with the endpoint
205
- puts "[MCP DEBUG] Found endpoint event: #{event_data}"
206
- endpoint_url = build_absolute_url(url, event_data)
207
- initialize_mcp_connection(connection, endpoint_url)
208
- when "message"
209
- puts "[MCP DEBUG] Received message: #{event_data}"
210
- dispatch_event(event_data, connection, endpoint_url, name, arguments, result)
211
- else
212
- puts "[MCP DEBUG] Unexpected event type: #{event_type} with data: #{event_data}"
213
- end
214
- end
215
- end
216
- end
217
- end
218
-
219
- # Parses an SSE *event block* (text up to the blank line delimiter) and
220
- # returns `[event_type, data]` where *event_type* defaults to "message" when
221
- # no explicit `event:` field is present. The *data* combines all `data:`
222
- # lines separated by newlines, as per the SSE specification.
223
- def parse_sse_fields(event_text)
224
- event_type = "message"
225
- data_lines = []
226
-
227
- event_text.each_line do |line|
228
- case line
229
- when /^event:\s*(.+)$/
230
- event_type = Regexp.last_match(1).strip
231
- when /^data:\s*(.*)$/
232
- data_lines << Regexp.last_match(1)
233
- end
234
- end
235
-
236
- [event_type, data_lines.join("\n").strip]
237
- end
238
-
239
- # Builds an absolute URL for +candidate+ relative to +base+.
240
- # If +candidate+ is already absolute, it is returned unchanged.
241
- def build_absolute_url(base, candidate)
242
- uri = URI.parse(candidate)
243
- return candidate if uri.absolute?
244
-
245
- URI.join(base, candidate).to_s
246
- rescue URI::InvalidURIError
247
- candidate # fall back to original string
248
- end
249
-
250
- def initialize_mcp_connection(connection, endpoint_url)
251
- puts "[MCP DEBUG] Initializing MCP connection with URL: #{endpoint_url}"
252
- connection.post(endpoint_url) do |req|
253
- req.headers["Content-Type"] = "application/json"
254
- req.body = {
255
- jsonrpc: JSONRPC_VERSION,
256
- id: SecureRandom.uuid,
257
- method: "initialize",
258
- params: {
259
- protocolVersion: PROTOCOL_VERSION,
260
- capabilities: {
261
- roots: {
262
- listChanged: true
263
- },
264
- sampling: {}
265
- },
266
- clientInfo: {
267
- name: "Raix",
268
- version: Raix::VERSION
269
- }
270
- }
271
- }.to_json
272
- end
273
- end
274
-
275
- def dispatch_event(event_data, connection, endpoint_url, name, arguments, result)
276
- event_data = JSON.parse(event_data, symbolize_names: true)
277
- case event_data
278
- in { result: { capabilities: { tools: { listChanged: true } } } }
279
- puts "[MCP DEBUG] Received listChanged event"
280
- acknowledge_event(connection, endpoint_url)
281
- fetch_mcp_tools(connection, endpoint_url)
282
- in { result: { tools: } }
283
- puts "[MCP DEBUG] Received tools event: #{tools}"
284
- if name.present?
285
- puts "[MCP DEBUG] Calling function: #{name} with params: #{arguments.inspect}"
286
- remote_dispatch(connection, endpoint_url, name, arguments)
287
- else
288
- result << tools # will unblock the pop on the main thread
289
- connection.close
290
- end
291
- in { result: { content: } }
292
- puts "[MCP DEBUG] Received content event: #{content}"
293
- result << content # will unblock the pop on the main thread
294
- connection.close
295
- else
296
- puts "[MCP DEBUG] Received unexpected event: #{event_data}"
297
- end
298
- end
299
-
300
- def remote_dispatch(connection, endpoint_url, name, arguments)
301
- connection.post(endpoint_url) do |req|
302
- req.headers["Content-Type"] = "application/json"
303
- req.body = {
304
- jsonrpc: JSONRPC_VERSION,
305
- id: SecureRandom.uuid,
306
- method: "tools/call",
307
- params: { name:, arguments: }
308
- }.to_json
309
- end
310
- end
311
-
312
- def acknowledge_event(connection, endpoint_url)
313
- puts "[MCP DEBUG] Acknowledging event"
314
- connection.post(endpoint_url) do |req|
315
- req.headers["Content-Type"] = "application/json"
316
- req.body = {
317
- jsonrpc: JSONRPC_VERSION,
318
- method: "notifications/initialized"
319
- }.to_json
320
- end
321
- end
322
-
323
- def fetch_mcp_tools(connection, endpoint_url)
324
- puts "[MCP DEBUG] Fetching tools"
325
- connection.post(endpoint_url) do |req|
326
- req.headers["Content-Type"] = "application/json"
327
- req.body = {
328
- jsonrpc: JSONRPC_VERSION,
329
- id: SecureRandom.uuid,
330
- method: "tools/list",
331
- params: {}
332
- }.to_json
333
- end
156
+ # Store the URL, tools, and client for future use
157
+ @mcp_servers[client.unique_key] = { tools: filtered_tools, client: }
334
158
  end
335
159
  end
336
160
  end
@@ -26,12 +26,9 @@ module Raix
26
26
  # question = Question.new
27
27
  # question.ask("Is Ruby a programming language?")
28
28
  module Predicate
29
+ extend ActiveSupport::Concern
29
30
  include ChatCompletion
30
31
 
31
- def self.included(base)
32
- base.extend(ClassMethods)
33
- end
34
-
35
32
  def ask(question, openai: false)
36
33
  raise "Please define a yes and/or no block" if self.class.yes_block.nil? && self.class.no_block.nil?
37
34
 
data/lib/raix/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- VERSION = "0.8.6"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/raix.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "raix/version"
4
+ require_relative "raix/configuration"
4
5
  require_relative "raix/chat_completion"
5
6
  require_relative "raix/function_dispatch"
6
7
  require_relative "raix/prompt_declarations"
@@ -10,42 +11,6 @@ require_relative "raix/mcp"
10
11
 
11
12
  # The Raix module provides configuration options for the Raix gem.
12
13
  module Raix
13
- # The Configuration class holds the configuration options for the Raix gem.
14
- class Configuration
15
- # The temperature option determines the randomness of the generated text.
16
- # Higher values result in more random output.
17
- attr_accessor :temperature
18
-
19
- # The max_tokens option determines the maximum number of tokens to generate.
20
- attr_accessor :max_tokens
21
-
22
- # The max_completion_tokens option determines the maximum number of tokens to generate.
23
- attr_accessor :max_completion_tokens
24
-
25
- # The model option determines the model to use for text generation. This option
26
- # is normally set in each class that includes the ChatCompletion module.
27
- attr_accessor :model
28
-
29
- # The openrouter_client option determines the default client to use for communicatio.
30
- attr_accessor :openrouter_client
31
-
32
- # The openai_client option determines the OpenAI client to use for communication.
33
- attr_accessor :openai_client
34
-
35
- DEFAULT_MAX_TOKENS = 1000
36
- DEFAULT_MAX_COMPLETION_TOKENS = 16_384
37
- DEFAULT_MODEL = "meta-llama/llama-3-8b-instruct:free"
38
- DEFAULT_TEMPERATURE = 0.0
39
-
40
- # Initializes a new instance of the Configuration class with default values.
41
- def initialize
42
- self.temperature = DEFAULT_TEMPERATURE
43
- self.max_completion_tokens = DEFAULT_MAX_COMPLETION_TOKENS
44
- self.max_tokens = DEFAULT_MAX_TOKENS
45
- self.model = DEFAULT_MODEL
46
- end
47
- end
48
-
49
14
  class << self
50
15
  attr_writer :configuration
51
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
@@ -83,8 +83,12 @@ files:
83
83
  - README.llm
84
84
  - README.md
85
85
  - Rakefile
86
+ - lib/mcp/sse_client.rb
87
+ - lib/mcp/stdio_client.rb
88
+ - lib/mcp/tool.rb
86
89
  - lib/raix.rb
87
90
  - lib/raix/chat_completion.rb
91
+ - lib/raix/configuration.rb
88
92
  - lib/raix/function_dispatch.rb
89
93
  - lib/raix/mcp.rb
90
94
  - lib/raix/message_adapters/base.rb