raix 0.7.3 → 0.8.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 +4 -4
- data/.rubocop.yml +6 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +19 -0
- data/Gemfile +0 -5
- data/Gemfile.lock +8 -11
- data/README.md +79 -0
- data/lib/raix/chat_completion.rb +23 -2
- data/lib/raix/function_dispatch.rb +2 -3
- data/lib/raix/mcp.rb +337 -0
- data/lib/raix/version.rb +1 -1
- data/lib/raix.rb +1 -0
- data/raix.gemspec +2 -1
- metadata +20 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2de11b289a7c245c49e865735e1abc67ff671f02a9f23fb89d262d3ff83e6157
|
4
|
+
data.tar.gz: f8cd493f6f018ddd6b981f944786635b6800ca9beac4480e0a4c3c08bb7b8392
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4d26f6ffad99abb7eaf6c8ef072393d75625e4762206d0d1f94c811e9d44747fa2e9048d5a8951484ded2861ecd5ebac69ea39bf6163432a411e2f9e427b0533
|
7
|
+
data.tar.gz: 893d062ab1f23b6c6a47dc715a7c98a3886dbe092645642e74bdb97ad80b2b4f637305b95acc6e6a8984cfe6defcdf020410233f5f818cba16bbbd0c5f49b5ed
|
data/.rubocop.yml
CHANGED
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
3.
|
1
|
+
3.4.2
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,23 @@
|
|
1
|
+
## [0.8.1] - 2025-04-24
|
2
|
+
Added ability to filter tool functions (or disable completely) when calling `chat_completion`. Thanks to @parruda for the contribution.
|
3
|
+
|
4
|
+
## [0.8.0] - 2025-04-23
|
5
|
+
### Added
|
6
|
+
* **MCP integration (Experimental)** — new `Raix::MCP` concern and `mcp` DSL for declaring remote MCP servers.
|
7
|
+
* Automatically fetches `tools/list`, registers remote tools as OpenAI‑compatible function schemas, and defines proxy methods that forward `tools/call`.
|
8
|
+
* `ChatCompletion#tools` now returns remote MCP tools alongside local `function` declarations.
|
9
|
+
|
10
|
+
### Changed
|
11
|
+
* `lib/raix.rb` now requires `raix/mcp` so the concern is auto‑loaded.
|
12
|
+
|
13
|
+
### Fixed
|
14
|
+
* Internal transcript handling spec expectations updated.
|
15
|
+
|
16
|
+
### Specs
|
17
|
+
* Added `spec/raix/mcp_spec.rb` with comprehensive stubs for tools discovery & call flow.
|
18
|
+
|
1
19
|
## [0.7.3] - 2025-04-23
|
20
|
+
- commit function call and result to transcript in one operation for thread safety
|
2
21
|
|
3
22
|
## [0.7.2] - 2025-04-19
|
4
23
|
- adds support for `messages` parameter in `chat_completion` to override the transcript
|
data/Gemfile
CHANGED
@@ -5,11 +5,6 @@ source "https://rubygems.org"
|
|
5
5
|
# Specify your gem's dependencies in raix-rails.gemspec
|
6
6
|
gemspec
|
7
7
|
|
8
|
-
gem "activesupport", ">= 6.0"
|
9
|
-
gem "faraday-retry"
|
10
|
-
gem "open_router", "~> 0.3"
|
11
|
-
gem "ruby-openai", "~> 7.0"
|
12
|
-
|
13
8
|
group :development do
|
14
9
|
gem "dotenv", ">= 2"
|
15
10
|
gem "guard"
|
data/Gemfile.lock
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
raix (0.
|
4
|
+
raix (0.8.1)
|
5
5
|
activesupport (>= 6.0)
|
6
|
+
faraday-retry (~> 2.0)
|
6
7
|
open_router (~> 0.2)
|
7
|
-
ruby-openai (~> 7
|
8
|
+
ruby-openai (~> 7)
|
8
9
|
|
9
10
|
GEM
|
10
11
|
remote: https://rubygems.org/
|
@@ -43,10 +44,10 @@ GEM
|
|
43
44
|
multipart-post (~> 2)
|
44
45
|
faraday-net_http (3.1.0)
|
45
46
|
net-http
|
46
|
-
faraday-retry (2.
|
47
|
+
faraday-retry (2.3.1)
|
47
48
|
faraday (~> 2.0)
|
48
|
-
ffi (1.17.
|
49
|
-
ffi (1.17.
|
49
|
+
ffi (1.17.2-arm64-darwin)
|
50
|
+
ffi (1.17.2-x86_64-linux-gnu)
|
50
51
|
formatador (1.1.0)
|
51
52
|
guard (2.18.1)
|
52
53
|
formatador (>= 0.2.4)
|
@@ -84,9 +85,9 @@ GEM
|
|
84
85
|
net-http (0.4.1)
|
85
86
|
uri
|
86
87
|
netrc (0.11.0)
|
87
|
-
nokogiri (1.
|
88
|
+
nokogiri (1.18.8-arm64-darwin)
|
88
89
|
racc (~> 1.4)
|
89
|
-
nokogiri (1.
|
90
|
+
nokogiri (1.18.8-x86_64-linux-gnu)
|
90
91
|
racc (~> 1.4)
|
91
92
|
notiffany (0.1.3)
|
92
93
|
nenv (~> 0.1)
|
@@ -216,18 +217,14 @@ PLATFORMS
|
|
216
217
|
x86_64-linux
|
217
218
|
|
218
219
|
DEPENDENCIES
|
219
|
-
activesupport (>= 6.0)
|
220
220
|
dotenv (>= 2)
|
221
|
-
faraday-retry
|
222
221
|
guard
|
223
222
|
guard-rspec
|
224
|
-
open_router (~> 0.3)
|
225
223
|
pry (>= 0.14)
|
226
224
|
raix!
|
227
225
|
rake (~> 13.0)
|
228
226
|
rspec (~> 3.0)
|
229
227
|
rubocop (~> 1.21)
|
230
|
-
ruby-openai (~> 7.0)
|
231
228
|
solargraph-rails (~> 0.2.0.pre)
|
232
229
|
sorbet
|
233
230
|
tapioca
|
data/README.md
CHANGED
@@ -134,6 +134,41 @@ end
|
|
134
134
|
|
135
135
|
Note that for security reasons, dispatching functions only works with functions implemented using `Raix::FunctionDispatch#function` or directly on the class.
|
136
136
|
|
137
|
+
#### Tool Filtering
|
138
|
+
|
139
|
+
You can control which tools are available to the AI on a given chat completion request using the `tools` parameter in the `chat_completion` method:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
class WeatherAndTime
|
143
|
+
include Raix::ChatCompletion
|
144
|
+
include Raix::FunctionDispatch
|
145
|
+
|
146
|
+
function :check_weather, "Check the weather for a location", location: { type: "string" } do |arguments|
|
147
|
+
"The weather in #{arguments[:location]} is sunny"
|
148
|
+
end
|
149
|
+
|
150
|
+
function :get_time, "Get the current time" do |_arguments|
|
151
|
+
"The time is 12:00 PM"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
weather = WeatherAndTime.new
|
156
|
+
|
157
|
+
# Don't pass any tools to the LLM
|
158
|
+
weather.chat_completion(tools: false)
|
159
|
+
|
160
|
+
# Only pass specific tools to the LLM
|
161
|
+
weather.chat_completion(tools: [:check_weather])
|
162
|
+
|
163
|
+
# Pass all declared tools (default behavior)
|
164
|
+
weather.chat_completion
|
165
|
+
```
|
166
|
+
|
167
|
+
The `tools` parameter accepts three types of values:
|
168
|
+
- `false`: No tools are passed to the LLM
|
169
|
+
- An array of symbols: Only the specified tools are passed (raises `Raix::UndeclaredToolError` if any tool is not declared)
|
170
|
+
- Not provided: All declared tools are passed (default behavior)
|
171
|
+
|
137
172
|
#### Multiple Tool Calls
|
138
173
|
|
139
174
|
Some AI models (like GPT-4) can make multiple tool calls in a single response. When this happens, Raix will automatically handle all the function calls sequentially.
|
@@ -452,6 +487,50 @@ question.ask("Any question")
|
|
452
487
|
# => RuntimeError: Please define a yes and/or no block
|
453
488
|
```
|
454
489
|
|
490
|
+
## Model Context Protocol (Experimental)
|
491
|
+
|
492
|
+
The `Raix::MCP` module provides integration with the Model Context Protocol, allowing you to connect your Raix-powered application to remote MCP servers. This feature is currently **experimental**.
|
493
|
+
|
494
|
+
### Usage
|
495
|
+
|
496
|
+
Include the `Raix::MCP` module in your class and declare MCP servers using the `mcp` DSL:
|
497
|
+
|
498
|
+
```ruby
|
499
|
+
class McpConsumer
|
500
|
+
include Raix::ChatCompletion
|
501
|
+
include Raix::FunctionDispatch
|
502
|
+
include Raix::MCP
|
503
|
+
|
504
|
+
mcp "https://your-mcp-server.example.com/sse"
|
505
|
+
end
|
506
|
+
```
|
507
|
+
|
508
|
+
### Features
|
509
|
+
|
510
|
+
- Automatically fetches available tools from the remote MCP server using `tools/list`
|
511
|
+
- Registers remote tools as OpenAI-compatible function schemas
|
512
|
+
- Defines proxy methods that forward requests to the remote server via `tools/call`
|
513
|
+
- Seamlessly integrates with the existing `FunctionDispatch` workflow
|
514
|
+
- Handles transcript recording to maintain consistent conversation history
|
515
|
+
|
516
|
+
### Filtering Tools
|
517
|
+
|
518
|
+
You can filter which remote tools to include:
|
519
|
+
|
520
|
+
```ruby
|
521
|
+
class FilteredMcpConsumer
|
522
|
+
include Raix::ChatCompletion
|
523
|
+
include Raix::FunctionDispatch
|
524
|
+
include Raix::MCP
|
525
|
+
|
526
|
+
# Only include specific tools
|
527
|
+
mcp "https://server.example.com/sse", only: [:tool_one, :tool_two]
|
528
|
+
|
529
|
+
# Or exclude specific tools
|
530
|
+
mcp "https://server.example.com/sse", except: [:tool_to_exclude]
|
531
|
+
end
|
532
|
+
```
|
533
|
+
|
455
534
|
## Response Format (Experimental)
|
456
535
|
|
457
536
|
The `ResponseFormat` class provides a way to declare a JSON schema for the response format of an AI chat completion. It's particularly useful when you need structured responses from AI models, ensuring the output conforms to your application's requirements.
|
data/lib/raix/chat_completion.rb
CHANGED
@@ -9,6 +9,8 @@ require "openai"
|
|
9
9
|
require_relative "message_adapters/base"
|
10
10
|
|
11
11
|
module Raix
|
12
|
+
class UndeclaredToolError < StandardError; end
|
13
|
+
|
12
14
|
# The `ChatCompletion`` module is a Rails concern that provides a way to interact
|
13
15
|
# with the OpenRouter Chat Completion API via its client. The module includes a few
|
14
16
|
# methods that allow you to build a transcript of messages and then send them to
|
@@ -44,8 +46,9 @@ module Raix
|
|
44
46
|
# @option params [Boolean] :openai (false) Whether to use OpenAI's API instead of OpenRouter's.
|
45
47
|
# @option params [Boolean] :raw (false) Whether to return the raw response or dig the text content.
|
46
48
|
# @option params [Array] :messages (nil) An array of messages to use instead of the transcript.
|
49
|
+
# @option tools [Array|false] :tools (nil) Tools to pass to the LLM. If false, no tools are passed. If an array, only declared tools in the array are passed.
|
47
50
|
# @return [String|Hash] The completed chat response.
|
48
|
-
def chat_completion(params: {}, loop: false, json: false, raw: false, openai: false, save_response: true, messages: nil)
|
51
|
+
def chat_completion(params: {}, loop: false, json: false, raw: false, openai: false, save_response: true, messages: nil, tools: nil)
|
49
52
|
# set params to default values if not provided
|
50
53
|
params[:cache_at] ||= cache_at.presence
|
51
54
|
params[:frequency_penalty] ||= frequency_penalty.presence
|
@@ -63,7 +66,13 @@ module Raix
|
|
63
66
|
params[:stop] ||= stop.presence
|
64
67
|
params[:temperature] ||= temperature.presence || Raix.configuration.temperature
|
65
68
|
params[:tool_choice] ||= tool_choice.presence
|
66
|
-
params[:tools]
|
69
|
+
params[:tools] = if tools == false
|
70
|
+
nil
|
71
|
+
elsif tools.is_a?(Array)
|
72
|
+
filtered_tools(tools)
|
73
|
+
else
|
74
|
+
self.tools.presence
|
75
|
+
end
|
67
76
|
params[:top_a] ||= top_a.presence
|
68
77
|
params[:top_k] ||= top_k.presence
|
69
78
|
params[:top_logprobs] ||= top_logprobs.presence
|
@@ -182,6 +191,18 @@ module Raix
|
|
182
191
|
|
183
192
|
private
|
184
193
|
|
194
|
+
def filtered_tools(tool_names)
|
195
|
+
return nil if tool_names.blank?
|
196
|
+
|
197
|
+
requested_tools = tool_names.map(&:to_sym)
|
198
|
+
available_tool_names = tools.map { |tool| tool.dig(:function, :name).to_sym }
|
199
|
+
|
200
|
+
undeclared_tools = requested_tools - available_tool_names
|
201
|
+
raise UndeclaredToolError, "Undeclared tools: #{undeclared_tools.join(", ")}" if undeclared_tools.any?
|
202
|
+
|
203
|
+
tools.select { |tool| requested_tools.include?(tool.dig(:function, :name).to_sym) }
|
204
|
+
end
|
205
|
+
|
185
206
|
def openai_request(params:, model:, messages:)
|
186
207
|
if params[:prediction]
|
187
208
|
params.delete(:max_completion_tokens)
|
@@ -94,10 +94,7 @@ module Raix
|
|
94
94
|
end
|
95
95
|
|
96
96
|
def chat_completion(**chat_completion_args)
|
97
|
-
raise "No functions defined" if self.class.functions.blank?
|
98
|
-
|
99
97
|
self.chat_completion_args = chat_completion_args
|
100
|
-
|
101
98
|
super
|
102
99
|
end
|
103
100
|
|
@@ -109,6 +106,8 @@ module Raix
|
|
109
106
|
end
|
110
107
|
|
111
108
|
def tools
|
109
|
+
return [] unless self.class.functions
|
110
|
+
|
112
111
|
self.class.functions.map { |function| { type: "function", function: } }
|
113
112
|
end
|
114
113
|
end
|
data/lib/raix/mcp.rb
ADDED
@@ -0,0 +1,337 @@
|
|
1
|
+
# Simple integration layer that lets Raix classes declare an MCP server
|
2
|
+
# with a single DSL call:
|
3
|
+
#
|
4
|
+
# mcp "https://my-server.example.com/sse"
|
5
|
+
#
|
6
|
+
# The concern fetches the remote server's tool list (via JSON‑RPC 2.0
|
7
|
+
# `tools/list`) and exposes each remote tool as if it were an inline
|
8
|
+
# `function` declared with Raix::FunctionDispatch. When the tool is
|
9
|
+
# invoked by the model, the generated instance method forwards the
|
10
|
+
# request to the remote server using `tools/call`, captures the result,
|
11
|
+
# and appends the appropriate messages to the transcript so that the
|
12
|
+
# conversation history stays consistent.
|
13
|
+
|
14
|
+
require "active_support/concern"
|
15
|
+
require "active_support/inflector"
|
16
|
+
require "securerandom"
|
17
|
+
require "faraday"
|
18
|
+
require "uri"
|
19
|
+
require "json"
|
20
|
+
|
21
|
+
module Raix
|
22
|
+
# Model Context Protocol integration for Raix
|
23
|
+
#
|
24
|
+
# Allows declaring MCP servers with a simple DSL that automatically:
|
25
|
+
# - Queries tools from the remote server
|
26
|
+
# - Exposes each tool as a function callable by LLMs
|
27
|
+
# - Handles transcript recording and response processing
|
28
|
+
module MCP
|
29
|
+
extend ActiveSupport::Concern
|
30
|
+
|
31
|
+
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
|
+
class_methods do
|
37
|
+
# Declare an MCP server by URL.
|
38
|
+
#
|
39
|
+
# mcp "https://server.example.com/sse"
|
40
|
+
#
|
41
|
+
# This will automatically:
|
42
|
+
# • query `tools/list` on the server
|
43
|
+
# • register each remote tool with FunctionDispatch so that the
|
44
|
+
# OpenAI / OpenRouter request body includes its JSON‑Schema
|
45
|
+
# • define an instance method for each tool that forwards the
|
46
|
+
# call to the server and appends the proper messages to the
|
47
|
+
# transcript.
|
48
|
+
# NOTE TO SELF: NEVER MOCK SERVER RESPONSES! THIS MUST WORK WITH REAL SERVERS!
|
49
|
+
def mcp(url, only: nil, except: nil)
|
50
|
+
@mcp_servers ||= {}
|
51
|
+
|
52
|
+
return if @mcp_servers.key?(url) # avoid duplicate definitions
|
53
|
+
|
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
|
61
|
+
|
62
|
+
if tools.empty?
|
63
|
+
puts "[MCP DEBUG] No tools found from MCP server at #{url}"
|
64
|
+
return nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# 3. Register each tool so ChatCompletion#tools picks them up
|
68
|
+
# Apply filters
|
69
|
+
filtered_tools = if only.present?
|
70
|
+
only_symbols = Array(only).map(&:to_sym)
|
71
|
+
tools.select { |tool| only_symbols.include?(tool["name"].to_sym) }
|
72
|
+
elsif except.present?
|
73
|
+
except_symbols = Array(except).map(&:to_sym)
|
74
|
+
tools.reject { |tool| except_symbols.include?(tool["name"].to_sym) }
|
75
|
+
else
|
76
|
+
tools
|
77
|
+
end
|
78
|
+
|
79
|
+
# Ensure FunctionDispatch is included in the class
|
80
|
+
# Explicit include in the class context
|
81
|
+
include FunctionDispatch unless included_modules.include?(FunctionDispatch)
|
82
|
+
puts "[MCP DEBUG] FunctionDispatch included in #{name}"
|
83
|
+
|
84
|
+
filtered_tools.each do |tool|
|
85
|
+
remote_name = tool[:name]
|
86
|
+
# 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
|
88
|
+
|
89
|
+
description = tool["description"]
|
90
|
+
input_schema = tool["inputSchema"] || {}
|
91
|
+
|
92
|
+
# --- register with FunctionDispatch (adds to .functions)
|
93
|
+
function(local_name, description, **{}) # placeholder parameters replaced next
|
94
|
+
latest_definition = functions.last
|
95
|
+
latest_definition[:parameters] = input_schema.deep_symbolize_keys if input_schema.present?
|
96
|
+
|
97
|
+
# --- define an instance method that proxies to the server
|
98
|
+
define_method(local_name) do |**arguments|
|
99
|
+
arguments ||= {}
|
100
|
+
|
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
|
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
|
+
|
116
|
+
# Mirror FunctionDispatch transcript behaviour
|
117
|
+
transcript << [
|
118
|
+
{
|
119
|
+
role: "assistant",
|
120
|
+
content: nil,
|
121
|
+
tool_calls: [
|
122
|
+
{
|
123
|
+
id: call_id,
|
124
|
+
type: "function",
|
125
|
+
function: {
|
126
|
+
name: remote_name,
|
127
|
+
arguments: arguments.to_json
|
128
|
+
}
|
129
|
+
}
|
130
|
+
]
|
131
|
+
},
|
132
|
+
{
|
133
|
+
role: "tool",
|
134
|
+
tool_call_id: call_id,
|
135
|
+
name: remote_name,
|
136
|
+
content: content_text
|
137
|
+
}
|
138
|
+
]
|
139
|
+
|
140
|
+
# Continue the chat loop if requested (same semantics as FunctionDispatch)
|
141
|
+
chat_completion(**chat_completion_args) if loop
|
142
|
+
|
143
|
+
content_text
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
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
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
data/lib/raix/version.rb
CHANGED
data/lib/raix.rb
CHANGED
@@ -6,6 +6,7 @@ require_relative "raix/function_dispatch"
|
|
6
6
|
require_relative "raix/prompt_declarations"
|
7
7
|
require_relative "raix/predicate"
|
8
8
|
require_relative "raix/response_format"
|
9
|
+
require_relative "raix/mcp"
|
9
10
|
|
10
11
|
# The Raix module provides configuration options for the Raix gem.
|
11
12
|
module Raix
|
data/raix.gemspec
CHANGED
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.require_paths = ["lib"]
|
30
30
|
|
31
31
|
spec.add_dependency "activesupport", ">= 6.0"
|
32
|
+
spec.add_dependency "faraday-retry", "~> 2.0"
|
32
33
|
spec.add_dependency "open_router", "~> 0.2"
|
33
|
-
spec.add_dependency "ruby-openai", "~> 7
|
34
|
+
spec.add_dependency "ruby-openai", "~> 7"
|
34
35
|
end
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: raix
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Obie Fernandez
|
8
|
-
autorequire:
|
9
8
|
bindir: exe
|
10
9
|
cert_chain: []
|
11
|
-
date: 2025-04-
|
10
|
+
date: 2025-04-25 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: activesupport
|
@@ -24,6 +23,20 @@ dependencies:
|
|
24
23
|
- - ">="
|
25
24
|
- !ruby/object:Gem::Version
|
26
25
|
version: '6.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: faraday-retry
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '2.0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '2.0'
|
27
40
|
- !ruby/object:Gem::Dependency
|
28
41
|
name: open_router
|
29
42
|
requirement: !ruby/object:Gem::Requirement
|
@@ -44,15 +57,14 @@ dependencies:
|
|
44
57
|
requirements:
|
45
58
|
- - "~>"
|
46
59
|
- !ruby/object:Gem::Version
|
47
|
-
version: '7
|
60
|
+
version: '7'
|
48
61
|
type: :runtime
|
49
62
|
prerelease: false
|
50
63
|
version_requirements: !ruby/object:Gem::Requirement
|
51
64
|
requirements:
|
52
65
|
- - "~>"
|
53
66
|
- !ruby/object:Gem::Version
|
54
|
-
version: '7
|
55
|
-
description:
|
67
|
+
version: '7'
|
56
68
|
email:
|
57
69
|
- obiefernandez@gmail.com
|
58
70
|
executables: []
|
@@ -74,6 +86,7 @@ files:
|
|
74
86
|
- lib/raix.rb
|
75
87
|
- lib/raix/chat_completion.rb
|
76
88
|
- lib/raix/function_dispatch.rb
|
89
|
+
- lib/raix/mcp.rb
|
77
90
|
- lib/raix/message_adapters/base.rb
|
78
91
|
- lib/raix/predicate.rb
|
79
92
|
- lib/raix/prompt_declarations.rb
|
@@ -88,7 +101,6 @@ metadata:
|
|
88
101
|
homepage_uri: https://github.com/OlympiaAI/raix
|
89
102
|
source_code_uri: https://github.com/OlympiaAI/raix
|
90
103
|
changelog_uri: https://github.com/OlympiaAI/raix/blob/main/CHANGELOG.md
|
91
|
-
post_install_message:
|
92
104
|
rdoc_options: []
|
93
105
|
require_paths:
|
94
106
|
- lib
|
@@ -103,8 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
103
115
|
- !ruby/object:Gem::Version
|
104
116
|
version: '0'
|
105
117
|
requirements: []
|
106
|
-
rubygems_version: 3.
|
107
|
-
signing_key:
|
118
|
+
rubygems_version: 3.6.2
|
108
119
|
specification_version: 4
|
109
120
|
summary: Ruby AI eXtensions
|
110
121
|
test_files: []
|