raix 0.8.6 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f28d49373248dcc8209a5c4c3678c632953aab2282f3f71a887ee1e2b3e7d249
4
- data.tar.gz: bacd7d9ef9b7aff8ab6b998d1ddb2efe3fd3dfa5855d8d5851161a271b056963
3
+ metadata.gz: '0868c4163ea58511e3c0dfc66b2b22a5cb2dbb16a7575746b36cd70af4777137'
4
+ data.tar.gz: 4e396262603a787dc58817e9fbb536264f25cdb9462bcbe035d675e59cb179ad
5
5
  SHA512:
6
- metadata.gz: a7a75b0c7c8f9ecfcbae002e6b5dfea7cee17be8d3d8825dacf2276f200f9bb542e0ccc4f7bb402b03b57d0240d84431bba3d749657a51ef4a90e937fd096633
7
- data.tar.gz: b367bdd61f7c7fb269232387c0d31af1cb2315d9bed8abc16758931851dab4b849139cb23e1c2c4236f8e8ced55368726db8c2a308efa3b6db503290d07165c2
6
+ metadata.gz: 8ae8a80e0e45dfba22290aefe27c39fd914f9046228dac33af1bc631e55a9eff73e38500c12c9eec6ba7bff67a5a16d51438ccf80cbcf423b070125dcd5710c4
7
+ data.tar.gz: ffb45352168291e28041b787fa8c2bf61c032d5bd5c59fb5a5653a602d6b1bb5ef282475ce7d828722113df797369127a1a4cdeec5da1c53ad887f1665b0385a
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,29 @@
1
+ ## [0.9.1] - 2025-05-30
2
+ ### Added
3
+ - **MCP Type Coercion** - Automatic type conversion for MCP tool arguments based on JSON schema
4
+ - Supports integer, number, boolean, array, and object types
5
+ - Handles nested objects and arrays of objects with proper coercion
6
+ - Gracefully handles invalid JSON and type mismatches
7
+ - **MCP Image Support** - MCP tools can now return image content as structured JSON
8
+
9
+ ### Fixed
10
+ - Fixed handling of nil values in MCP argument coercion
11
+
12
+ ## [0.9.0] - 2025-05-30
13
+ ### Added
14
+ - **MCP (Model Context Protocol) Support**
15
+ - New `stdio_mcp` method for stdio-based MCP servers
16
+ - Refactored existing MCP code into `SseClient` and `StdioClient`
17
+ - Split top-level `mcp` method into `sse_mcp` and `stdio_mcp`
18
+ - Added authentication support for MCP servers
19
+ - **Class-Level Configuration**
20
+ - Moved configuration to separate `Configuration` class
21
+ - Added fallback mechanism for configuration options
22
+ - Cleaner metaprogramming implementation
23
+
24
+ ### Fixed
25
+ - Fixed method signature of functions added via MCP
26
+
1
27
  ## [0.8.6] - 2025-05-19
2
28
  - add `required` and `optional` flags for parameters in `function` declarations
3
29
 
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.1)
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,297 @@
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.
46
+ # Returns text content directly, or JSON-encoded data for other content types.
47
+ def call_tool(name, **arguments)
48
+ request_id = SecureRandom.uuid
49
+ send_json_rpc(request_id, "tools/call", name:, arguments:)
50
+
51
+ # Wait for response through SSE
52
+ response = wait_for_response(request_id)
53
+ content = response[:content]
54
+ return "" if content.nil? || content.empty?
55
+
56
+ # Handle different content formats
57
+ first_item = content.first
58
+ case first_item
59
+ when Hash
60
+ case first_item[:type]
61
+ when "text"
62
+ first_item[:text]
63
+ when "image"
64
+ # Return a structured response for images
65
+ {
66
+ type: "image",
67
+ data: first_item[:data],
68
+ mime_type: first_item[:mimeType] || "image/png"
69
+ }.to_json
70
+ else
71
+ # For any other type, return the item as JSON
72
+ first_item.to_json
73
+ end
74
+ else
75
+ first_item.to_s
76
+ end
77
+ end
78
+
79
+ # Closes the connection to the server.
80
+ def close
81
+ @closed = true
82
+ @sse_thread&.kill
83
+ @connection&.close
84
+ end
85
+
86
+ def unique_key
87
+ @url.parameterize.underscore.gsub("https_", "")
88
+ end
89
+
90
+ private
91
+
92
+ # Establishes and maintains the SSE connection
93
+ def establish_sse_connection
94
+ @sse_thread = Thread.new do
95
+ headers = {
96
+ "Accept" => "text/event-stream",
97
+ "Cache-Control" => "no-cache",
98
+ "Connection" => "keep-alive",
99
+ "MCP-Version" => PROTOCOL_VERSION
100
+ }.merge(@headers)
101
+
102
+ @connection = Faraday.new(url: @url) do |faraday|
103
+ faraday.options.timeout = CONNECTION_TIMEOUT
104
+ faraday.options.open_timeout = OPEN_TIMEOUT
105
+ end
106
+
107
+ @connection.get do |req|
108
+ req.headers = headers
109
+ req.options.on_data = proc do |chunk, _size|
110
+ next if @closed
111
+
112
+ @buffer << chunk
113
+ process_sse_buffer
114
+ end
115
+ end
116
+ rescue StandardError => e
117
+ # puts "[MCP DEBUG] SSE connection error: #{e.message}"
118
+ @event_queue << { error: e }
119
+ end
120
+
121
+ # Wait for endpoint discovery
122
+ loop do
123
+ event = @event_queue.pop
124
+ if event[:error]
125
+ raise ProtocolError, "SSE connection failed: #{event[:error].message}"
126
+ elsif event[:endpoint_url]
127
+ @endpoint_url = event[:endpoint_url]
128
+ break
129
+ end
130
+ end
131
+
132
+ # Initialize the MCP session
133
+ initialize_mcp_session
134
+ end
135
+
136
+ # Process SSE buffer for complete events
137
+ def process_sse_buffer
138
+ while (idx = @buffer.index("\n\n"))
139
+ event_text = @buffer.slice!(0..idx + 1)
140
+ event_type, event_data = parse_sse_fields(event_text)
141
+
142
+ case event_type
143
+ when "endpoint"
144
+ endpoint_url = build_absolute_url(@url, event_data)
145
+ @event_queue << { endpoint_url: }
146
+ when "message"
147
+ handle_message_event(event_data)
148
+ end
149
+ end
150
+ end
151
+
152
+ # Handle SSE message events
153
+ def handle_message_event(event_data)
154
+ parsed = JSON.parse(event_data, symbolize_names: true)
155
+
156
+ # Handle different message types
157
+ case parsed
158
+ when ->(p) { p[:method] == "initialize" && p.dig(:params, :endpoint_url) }
159
+ # Legacy endpoint discovery
160
+ endpoint_url = parsed.dig(:params, :endpoint_url)
161
+ @event_queue << { endpoint_url: }
162
+ when ->(p) { p[:id] && p[:result] }
163
+ @event_queue << { id: parsed[:id], result: parsed[:result] }
164
+ when ->(p) { p[:result] }
165
+ @event_queue << { result: parsed[:result] }
166
+ end
167
+ rescue JSON::ParserError => e
168
+ puts "[MCP DEBUG] Error parsing message: #{e.message}"
169
+ puts "[MCP DEBUG] Message data: #{event_data}"
170
+ end
171
+
172
+ # Initialize the MCP session
173
+ def initialize_mcp_session
174
+ request_id = SecureRandom.uuid
175
+ send_json_rpc(request_id, "initialize", {
176
+ protocolVersion: PROTOCOL_VERSION,
177
+ capabilities: {
178
+ roots: { listChanged: true },
179
+ sampling: {}
180
+ },
181
+ clientInfo: {
182
+ name: "Raix",
183
+ version: Raix::VERSION
184
+ }
185
+ })
186
+
187
+ # Wait for initialization response
188
+ response = wait_for_response(request_id)
189
+
190
+ # Send acknowledgment if needed
191
+ return unless response.dig(:capabilities, :tools, :listChanged)
192
+
193
+ send_notification("notifications/initialized", {})
194
+ end
195
+
196
+ # Send a JSON-RPC request
197
+ def send_json_rpc(id, method, params)
198
+ body = {
199
+ jsonrpc: JSONRPC_VERSION,
200
+ id:,
201
+ method:,
202
+ params:
203
+ }
204
+
205
+ # Use a new connection for the POST request
206
+ conn = Faraday.new(url: @endpoint_url) do |faraday|
207
+ faraday.options.timeout = CONNECTION_TIMEOUT
208
+ end
209
+
210
+ conn.post do |req|
211
+ req.headers["Content-Type"] = "application/json"
212
+ req.body = body.to_json
213
+ end
214
+ rescue StandardError => e
215
+ raise ProtocolError, "Failed to send request: #{e.message}"
216
+ end
217
+
218
+ # Send a notification (no response expected)
219
+ def send_notification(method, params)
220
+ body = {
221
+ jsonrpc: JSONRPC_VERSION,
222
+ method:,
223
+ params:
224
+ }
225
+
226
+ conn = Faraday.new(url: @endpoint_url) do |faraday|
227
+ faraday.options.timeout = CONNECTION_TIMEOUT
228
+ end
229
+
230
+ conn.post do |req|
231
+ req.headers["Content-Type"] = "application/json"
232
+ req.body = body.to_json
233
+ end
234
+ rescue StandardError => e
235
+ puts "[MCP DEBUG] Error sending notification: #{e.message}"
236
+ end
237
+
238
+ # Wait for a response with a specific ID
239
+ def wait_for_response(request_id)
240
+ timeout = Time.now + CONNECTION_TIMEOUT
241
+
242
+ loop do
243
+ if Time.now > timeout
244
+ raise ProtocolError, "Timeout waiting for response"
245
+ end
246
+
247
+ # Use non-blocking pop with timeout
248
+ begin
249
+ event = @event_queue.pop(true) # non_block = true
250
+ rescue ThreadError
251
+ # Queue is empty, wait a bit
252
+ sleep 0.1
253
+ next
254
+ end
255
+
256
+ if event[:error]
257
+ raise ProtocolError, "SSE error: #{event[:error].message}"
258
+ elsif event[:id] == request_id && event[:result]
259
+ return event[:result]
260
+ elsif event[:result] && !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,78 @@
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.
25
+ # Returns text content directly, or JSON-encoded data for other content types.
26
+ def call_tool(name, **arguments)
27
+ result = call("tools/call", name:, arguments:)
28
+ content = result["content"]
29
+ return "" if content.nil? || content.empty?
30
+
31
+ # Handle different content formats
32
+ first_item = content.first
33
+ case first_item
34
+ when Hash
35
+ case first_item["type"]
36
+ when "text"
37
+ first_item["text"]
38
+ when "image"
39
+ # Return a structured response for images
40
+ {
41
+ type: "image",
42
+ data: first_item["data"],
43
+ mime_type: first_item["mimeType"] || "image/png"
44
+ }.to_json
45
+ else
46
+ # For any other type, return the item as JSON
47
+ first_item.to_json
48
+ end
49
+ else
50
+ first_item.to_s
51
+ end
52
+ end
53
+
54
+ # Closes the connection to the server.
55
+ def close
56
+ @io.close
57
+ end
58
+
59
+ def unique_key
60
+ @args.join(" ").parameterize.underscore
61
+ end
62
+
63
+ private
64
+
65
+ # Sends JSON-RPC request and returns the result.
66
+ def call(method, **params)
67
+ @io.puts({ id: SecureRandom.uuid, method:, params:, jsonrpc: JSONRPC_VERSION }.to_json)
68
+ @io.flush # Ensure data is immediately sent
69
+ message = JSON.parse(@io.gets)
70
+ if (error = message["error"])
71
+ raise ProtocolError, error["message"]
72
+ end
73
+
74
+ message["result"]
75
+ end
76
+ end
77
+ end
78
+ 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.
38
39
  #
39
- # mcp "https://server.example.com/sse"
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.
50
+ #
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,65 @@ 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] ||= {}
117
+
118
+ # Store the schema for type coercion
119
+ tool_schemas = @tool_schemas ||= {}
120
+ tool_schemas[local_name] = input_schema
96
121
 
97
122
  # --- define an instance method that proxies to the server
98
- define_method(local_name) do |**arguments|
123
+ define_method(local_name) do |arguments, _cache|
99
124
  arguments ||= {}
100
125
 
101
- 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
126
+ # Coerce argument types based on the input schema
127
+ stored_schema = self.class.instance_variable_get(:@tool_schemas)&.dig(local_name)
128
+ coerced_arguments = coerce_arguments(arguments, stored_schema)
108
129
 
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
130
+ content_text = client.call_tool(remote_name, **coerced_arguments)
131
+ call_id = SecureRandom.uuid
115
132
 
116
133
  # Mirror FunctionDispatch transcript behaviour
117
134
  transcript << [
@@ -144,193 +161,95 @@ module Raix
144
161
  end
145
162
  end
146
163
 
147
- # Store the URL and tools for future use
148
- @mcp_servers[url] = { tools: }
164
+ # Store the URL, tools, and client for future use
165
+ @mcp_servers[client.unique_key] = { tools: filtered_tools, client: }
149
166
  end
167
+ end
150
168
 
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
169
+ private
191
170
 
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
171
+ # Coerce argument types based on the JSON schema
172
+ def coerce_arguments(arguments, schema)
173
+ return arguments unless schema.is_a?(Hash) && schema["properties"].is_a?(Hash)
218
174
 
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
175
+ coerced = {}
176
+ schema["properties"].each do |key, prop_schema|
177
+ value = if arguments.key?(key)
178
+ arguments[key]
179
+ elsif arguments.key?(key.to_sym)
180
+ arguments[key.to_sym]
181
+ end
182
+ next if value.nil?
235
183
 
236
- [event_type, data_lines.join("\n").strip]
184
+ coerced[key] = coerce_value(value, prop_schema)
237
185
  end
238
186
 
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
187
+ # Include any additional arguments not in the schema
188
+ arguments.each do |key, value|
189
+ key_str = key.to_s
190
+ coerced[key_str] = value unless coerced.key?(key_str)
248
191
  end
249
192
 
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
193
+ coerced.with_indifferent_access
194
+ end
274
195
 
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
196
+ # Coerce a single value based on its schema
197
+ def coerce_value(value, schema)
198
+ return value unless schema.is_a?(Hash)
199
+
200
+ case schema["type"]
201
+ when "number", "integer"
202
+ if value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
203
+ schema["type"] == "integer" ? value.to_i : value.to_f
295
204
  else
296
- puts "[MCP DEBUG] Received unexpected event: #{event_data}"
205
+ value
297
206
  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
207
+ when "boolean"
208
+ case value
209
+ when "true", true then true
210
+ when "false", false then false
211
+ else value
212
+ end
213
+ when "array"
214
+ array_value = begin
215
+ value.is_a?(String) ? JSON.parse(value) : value
216
+ rescue JSON::ParserError
217
+ value
309
218
  end
310
- end
311
219
 
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
220
+ # If there's an items schema, coerce each element
221
+ if array_value.is_a?(Array) && schema["items"]
222
+ array_value.map { |item| coerce_value(item, schema["items"]) }
223
+ else
224
+ array_value
320
225
  end
321
- end
226
+ when "object"
227
+ object_value = begin
228
+ value.is_a?(String) ? JSON.parse(value) : value
229
+ rescue JSON::ParserError
230
+ value
231
+ end
232
+
233
+ # If there are properties defined, coerce them recursively
234
+ if object_value.is_a?(Hash) && schema["properties"]
235
+ coerced_object = {}
236
+ schema["properties"].each do |prop_key, prop_schema|
237
+ prop_value = object_value[prop_key] || object_value[prop_key.to_sym]
238
+ coerced_object[prop_key] = coerce_value(prop_value, prop_schema) unless prop_value.nil?
239
+ end
322
240
 
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
241
+ # Include any additional properties not in the schema
242
+ object_value.each do |obj_key, obj_value|
243
+ obj_key_str = obj_key.to_s
244
+ coerced_object[obj_key_str] = obj_value unless coerced_object.key?(obj_key_str)
245
+ end
246
+
247
+ coerced_object
248
+ else
249
+ object_value
333
250
  end
251
+ else
252
+ value
334
253
  end
335
254
  end
336
255
  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.1"
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.1
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