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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Rasti
2
2
  module AI
3
- VERSION = '1.1.0'
3
+ VERSION = '1.2.1'
4
4
  end
5
5
  end
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