rasti-ai 1.1.0 → 1.2.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/.gitignore +2 -1
- data/README.md +151 -3
- data/lib/rasti/ai/mcp/client.rb +68 -0
- data/lib/rasti/ai/mcp/errors.rb +29 -0
- data/lib/rasti/ai/mcp/server.rb +156 -0
- data/lib/rasti/ai/open_ai/assistant.rb +25 -3
- data/lib/rasti/ai/open_ai/client.rb +43 -19
- data/lib/rasti/ai/tool.rb +23 -0
- data/lib/rasti/ai/tool_serializer.rb +112 -0
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +4 -0
- data/log/.gitkeep +0 -0
- data/rasti-ai.gemspec +2 -0
- data/spec/mcp/client_spec.rb +256 -0
- data/spec/mcp/server_spec.rb +372 -0
- data/spec/minitest_helper.rb +4 -0
- data/spec/open_ai/assistant_spec.rb +9 -2
- data/spec/{open_ai/tool_serializer_spec.rb → tool_serializer_spec.rb} +21 -29
- metadata +42 -5
- data/lib/rasti/ai/open_ai/tool_serializer.rb +0 -111
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
class ToolSerializer
|
|
4
|
+
class << self
|
|
5
|
+
|
|
6
|
+
def serialize(tool_class)
|
|
7
|
+
serialization = {
|
|
8
|
+
name: serialize_name(tool_class)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
serialization[:description] = normalize_description(tool_class.description) if tool_class.respond_to? :description
|
|
12
|
+
|
|
13
|
+
serialization[:inputSchema] = serialize_form(tool_class.form) if tool_class.respond_to? :form
|
|
14
|
+
|
|
15
|
+
serialization
|
|
16
|
+
|
|
17
|
+
rescue => ex
|
|
18
|
+
raise Errors::ToolSerializationError.new(tool_class), cause: ex
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def serialize_name(tool_class)
|
|
24
|
+
Inflecto.underscore Inflecto.demodulize(tool_class.name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def serialize_form(form_class)
|
|
28
|
+
serialized_attributes = form_class.attributes.each_with_object({}) do |attribute, hash|
|
|
29
|
+
hash[attribute.name] = serialize_attribute attribute
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
serialization = {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: serialized_attributes
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
required_attributes = form_class.attributes.select { |a| a.option(:required) }
|
|
38
|
+
|
|
39
|
+
serialization[:required] = required_attributes.map(&:name) unless required_attributes.empty?
|
|
40
|
+
|
|
41
|
+
serialization
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_attribute(attribute)
|
|
45
|
+
serialization = {}
|
|
46
|
+
|
|
47
|
+
if attribute.option(:description)
|
|
48
|
+
serialization[:description] = normalize_description attribute.option(:description)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
type_serialization = serialize_type attribute.type
|
|
52
|
+
serialization.merge! type_serialization
|
|
53
|
+
|
|
54
|
+
if attribute.type.is_a? Types::Enum
|
|
55
|
+
values = "#{type_serialization[:enum].join(', ')}"
|
|
56
|
+
if serialization[:description]
|
|
57
|
+
serialization[:description] += " (#{values})"
|
|
58
|
+
else
|
|
59
|
+
serialization[:description] = values
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
serialization
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serialize_type(type)
|
|
67
|
+
if type == Types::String
|
|
68
|
+
{type: 'string'}
|
|
69
|
+
|
|
70
|
+
elsif type == Types::Integer
|
|
71
|
+
{type: 'integer'}
|
|
72
|
+
|
|
73
|
+
elsif type == Types::Float
|
|
74
|
+
{type: 'number'}
|
|
75
|
+
|
|
76
|
+
elsif type == Types::Boolean
|
|
77
|
+
{type: 'boolean'}
|
|
78
|
+
|
|
79
|
+
elsif type.is_a? Types::Time
|
|
80
|
+
{
|
|
81
|
+
type: 'string',
|
|
82
|
+
format: 'date'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
elsif type.is_a? Types::Enum
|
|
86
|
+
{
|
|
87
|
+
type: 'string',
|
|
88
|
+
enum: type.values
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
elsif type.is_a? Types::Array
|
|
92
|
+
{
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: serialize_type(type.type)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
elsif type.is_a? Types::Model
|
|
98
|
+
serialize_form(type.model)
|
|
99
|
+
|
|
100
|
+
else
|
|
101
|
+
raise "Type not serializable #{type}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_description(description)
|
|
106
|
+
description.split("\n").map(&:strip).join(' ').strip
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/rasti/ai/version.rb
CHANGED
data/lib/rasti/ai.rb
CHANGED
|
@@ -17,6 +17,10 @@ module Rasti
|
|
|
17
17
|
|
|
18
18
|
attr_config :logger, Logger.new(STDOUT)
|
|
19
19
|
|
|
20
|
+
attr_config :http_connect_timeout, 60
|
|
21
|
+
attr_config :http_read_timeout, 60
|
|
22
|
+
attr_config :http_max_retries, 3
|
|
23
|
+
|
|
20
24
|
attr_config :openai_api_key, ENV['OPENAI_API_KEY']
|
|
21
25
|
attr_config :openai_default_model, ENV['OPENAI_DEFAULT_MODEL']
|
|
22
26
|
|
data/log/.gitkeep
ADDED
|
File without changes
|
data/rasti-ai.gemspec
CHANGED
|
@@ -24,9 +24,11 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
spec.add_runtime_dependency 'class_config', '~> 0.0'
|
|
25
25
|
|
|
26
26
|
spec.add_development_dependency 'rake', '~> 12.0'
|
|
27
|
+
spec.add_development_dependency 'rack-test', '~> 2.0'
|
|
27
28
|
spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
|
|
28
29
|
spec.add_development_dependency 'minitest-colorin', '~> 0.1'
|
|
29
30
|
spec.add_development_dependency 'minitest-line', '~> 0.6'
|
|
31
|
+
spec.add_development_dependency 'minitest-extended_assertions', '~> 1.0'
|
|
30
32
|
spec.add_development_dependency 'simplecov', '~> 0.12'
|
|
31
33
|
spec.add_development_dependency 'pry-nav', '~> 0.2'
|
|
32
34
|
spec.add_development_dependency 'webmock', '~> 3.0'
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::MCP::Client do
|
|
4
|
+
|
|
5
|
+
let(:mcp_url) { 'https://mcp.server.ai/mcp' }
|
|
6
|
+
|
|
7
|
+
let(:client) { Rasti::AI::MCP::Client.new(url: mcp_url) }
|
|
8
|
+
|
|
9
|
+
let(:client_with_allowed_tools) { Rasti::AI::MCP::Client.new(url: mcp_url, allowed_tools: ['sum_tool']) }
|
|
10
|
+
|
|
11
|
+
def stub_mcp_request(method, params:{}, result:{}, error:nil)
|
|
12
|
+
request_body = {
|
|
13
|
+
jsonrpc: '2.0',
|
|
14
|
+
method: method,
|
|
15
|
+
params: params
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
response_body = {
|
|
19
|
+
jsonrpc: '2.0'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if error
|
|
23
|
+
response_body[:error] = error
|
|
24
|
+
else
|
|
25
|
+
response_body[:result] = result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
stub_request(:post, mcp_url)
|
|
29
|
+
.with(
|
|
30
|
+
body: JSON.dump(request_body),
|
|
31
|
+
headers: {'Content-Type' => 'application/json'}
|
|
32
|
+
)
|
|
33
|
+
.to_return(
|
|
34
|
+
body: JSON.dump(response_body),
|
|
35
|
+
headers: {'Content-Type' => 'application/json'}
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe 'List tools' do
|
|
40
|
+
|
|
41
|
+
it 'Returns all tools when no allowed_tools' do
|
|
42
|
+
tools = [
|
|
43
|
+
{
|
|
44
|
+
'name' => 'hello_world_tool',
|
|
45
|
+
'description' => 'Hello World'
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
'name' => 'sum_tool',
|
|
49
|
+
'description' => 'Sum two numbers'
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
stub_mcp_request 'tools/list', result: {'tools' => tools}
|
|
54
|
+
|
|
55
|
+
result = client.list_tools
|
|
56
|
+
|
|
57
|
+
assert_equal tools, result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'Returns empty list when no tools available' do
|
|
61
|
+
stub_mcp_request 'tools/list', result: {'tools' => []}
|
|
62
|
+
|
|
63
|
+
result = client.list_tools
|
|
64
|
+
|
|
65
|
+
assert_equal [], result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'Filters tools by allowed_tools' do
|
|
69
|
+
tools = [
|
|
70
|
+
{
|
|
71
|
+
'name' => 'hello_world_tool',
|
|
72
|
+
'description' => 'Hello World'
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
'name' => 'sum_tool',
|
|
76
|
+
'description' => 'Sum two numbers'
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
'name' => 'multiply_tool',
|
|
80
|
+
'description' => 'Multiply'
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
stub_mcp_request 'tools/list', result: {'tools' => tools}
|
|
85
|
+
|
|
86
|
+
result = client_with_allowed_tools.list_tools
|
|
87
|
+
|
|
88
|
+
expected = [
|
|
89
|
+
{
|
|
90
|
+
'name' => 'sum_tool',
|
|
91
|
+
'description' => 'Sum two numbers'
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
assert_equal expected, result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'Returns empty when no tools match allowed_tools' do
|
|
99
|
+
tools = [
|
|
100
|
+
{
|
|
101
|
+
'name' => 'hello_world_tool',
|
|
102
|
+
'description' => 'Hello World'
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
'name' => 'multiply_tool',
|
|
106
|
+
'description' => 'Multiply'
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
stub_mcp_request 'tools/list', result: {'tools' => tools}
|
|
111
|
+
|
|
112
|
+
result = client_with_allowed_tools.list_tools
|
|
113
|
+
|
|
114
|
+
assert_equal [], result
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe 'Call tool' do
|
|
120
|
+
|
|
121
|
+
it 'Executes tool with arguments' do
|
|
122
|
+
params = {
|
|
123
|
+
name: 'sum_tool',
|
|
124
|
+
arguments: {
|
|
125
|
+
number_a: 1,
|
|
126
|
+
number_b: 2
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
result_content = {
|
|
131
|
+
'content' => [
|
|
132
|
+
{
|
|
133
|
+
'type' => 'text',
|
|
134
|
+
'text' => '{"result":3.0}'
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
stub_mcp_request 'tools/call', params: params, result: result_content
|
|
140
|
+
|
|
141
|
+
result = client.call_tool 'sum_tool', number_a: 1, number_b: 2
|
|
142
|
+
|
|
143
|
+
assert_equal '{"type":"text","text":"{\"result\":3.0}"}', result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'Executes tool without arguments' do
|
|
147
|
+
params = {
|
|
148
|
+
name: 'hello_world_tool',
|
|
149
|
+
arguments: {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
result_content = {
|
|
153
|
+
'content' => [
|
|
154
|
+
{
|
|
155
|
+
'type' => 'text',
|
|
156
|
+
'text' => '{"text":"Hello world"}'
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stub_mcp_request 'tools/call', params: params, result: result_content
|
|
162
|
+
|
|
163
|
+
result = client.call_tool 'hello_world_tool'
|
|
164
|
+
|
|
165
|
+
assert_equal '{"type":"text","text":"{\"text\":\"Hello world\"}"}', result
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'Raises error when tool not in allowed_tools' do
|
|
169
|
+
error = assert_raises RuntimeError do
|
|
170
|
+
client_with_allowed_tools.call_tool 'hello_world_tool'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
assert_equal 'Invalid tool: hello_world_tool', error.message
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'Allows tool call when in allowed_tools' do
|
|
177
|
+
params = {
|
|
178
|
+
name: 'sum_tool',
|
|
179
|
+
arguments: {
|
|
180
|
+
number_a: 5,
|
|
181
|
+
number_b: 3
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
result_content = {
|
|
186
|
+
'content' => [
|
|
187
|
+
{
|
|
188
|
+
'type' => 'text',
|
|
189
|
+
'text' => '{"result":8.0}'
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
stub_mcp_request 'tools/call', params: params, result: result_content
|
|
195
|
+
|
|
196
|
+
result = client_with_allowed_tools.call_tool 'sum_tool', number_a: 5, number_b: 3
|
|
197
|
+
|
|
198
|
+
assert_equal '{"type":"text","text":"{\"result\":8.0}"}', result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
describe 'Error handling' do
|
|
204
|
+
|
|
205
|
+
it 'Raises error when MCP returns error' do
|
|
206
|
+
error_response = {
|
|
207
|
+
'code' => -32603,
|
|
208
|
+
'message' => 'Tool not found'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
params = {
|
|
212
|
+
name: 'nonexistent',
|
|
213
|
+
arguments: {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
stub_mcp_request 'tools/call', params: params, error: error_response
|
|
217
|
+
|
|
218
|
+
error = assert_raises RuntimeError do
|
|
219
|
+
client.call_tool 'nonexistent'
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
assert_equal 'MCP Error: Tool not found', error.message
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'Raises error when result is missing' do
|
|
226
|
+
stub_request(:post, mcp_url)
|
|
227
|
+
.to_return(
|
|
228
|
+
status: 200,
|
|
229
|
+
body: JSON.dump({ jsonrpc: '2.0' }),
|
|
230
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
error = assert_raises RuntimeError do
|
|
234
|
+
client.list_tools
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
assert_equal 'MCP Error: invalid result', error.message
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'Raises error when response is invalid JSON' do
|
|
241
|
+
stub_request(:post, mcp_url)
|
|
242
|
+
.to_return(
|
|
243
|
+
status: 200,
|
|
244
|
+
body: 'invalid json{',
|
|
245
|
+
headers: {'Content-Type' => 'application/json'}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
error = assert_raises JSON::ParserError do
|
|
249
|
+
client.list_tools
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
assert_match /unexpected token/, error.message
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
end
|
|
256
|
+
end
|