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,372 @@
1
+ require 'minitest_helper'
2
+
3
+ describe Rasti::AI::MCP::Server do
4
+
5
+ class HelloWorldTool < Rasti::AI::Tool
6
+ def self.description
7
+ 'Hello World'
8
+ end
9
+
10
+ def execute(form)
11
+ {text: 'Hello world'}
12
+ end
13
+ end
14
+
15
+ class SumTool < Rasti::AI::Tool
16
+ class Form < Rasti::Form
17
+ attribute :number_a, Rasti::Types::Float
18
+ attribute :number_b, Rasti::Types::Float
19
+ end
20
+
21
+ def execute(form)
22
+ {result: form.number_a + form.number_b}
23
+ end
24
+ end
25
+
26
+ class ErrorTool < Rasti::AI::Tool
27
+ def execute(form)
28
+ raise "Unexpected tool error"
29
+ end
30
+ end
31
+
32
+ include Rack::Test::Methods
33
+
34
+ let(:app) do
35
+ app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['App response']] }
36
+ Rasti::AI::MCP::Server.new app
37
+ end
38
+
39
+ before do
40
+ Rasti::AI::MCP::Server.clear_tools
41
+ Rasti::AI::MCP::Server.restore_default_configuration
42
+ end
43
+
44
+ def post_mcp_request(method, params={})
45
+ request_data = {
46
+ jsonrpc: '2.0',
47
+ id: 1,
48
+ method: method
49
+ }
50
+ request_data[:params] = params unless params.empty?
51
+
52
+ post Rasti::AI::MCP::Server.relative_path, JSON.dump(request_data), CONTENT_TYPE: 'application/json'
53
+ end
54
+
55
+ def assert_jsonrpc_success(expected_result)
56
+ expected_response = {
57
+ jsonrpc: '2.0',
58
+ id: 1,
59
+ result: expected_result
60
+ }
61
+
62
+ assert_equal 200, last_response.status
63
+ assert_equal 'application/json', last_response.content_type
64
+ assert_equal_json JSON.dump(expected_response), last_response.body
65
+ end
66
+
67
+ def assert_jsonrpc_error(expected_error)
68
+ expected_response = {
69
+ jsonrpc: '2.0',
70
+ id: 1,
71
+ error: expected_error
72
+ }
73
+
74
+ assert_equal 200, last_response.status
75
+ assert_equal 'application/json', last_response.content_type
76
+ assert_equal_json JSON.dump(expected_response), last_response.body
77
+ end
78
+
79
+ describe 'Tool registration' do
80
+
81
+ it 'Register tool' do
82
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
83
+ Rasti::AI::MCP::Server.register_tool SumTool.new
84
+
85
+ serializations = Rasti::AI::MCP::Server.tools_serializations
86
+
87
+ expeted_serializations = [
88
+ Rasti::AI::ToolSerializer.serialize(HelloWorldTool),
89
+ Rasti::AI::ToolSerializer.serialize(SumTool)
90
+ ]
91
+
92
+ assert_equal expeted_serializations, serializations
93
+ end
94
+
95
+ it 'Register duplicate tool raises error' do
96
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
97
+
98
+ error = assert_raises RuntimeError do
99
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
100
+ end
101
+
102
+ assert_equal 'Tool hello_world_tool already exist', error.message
103
+ end
104
+
105
+ it 'Tools serializations empty when no tools' do
106
+ assert_empty Rasti::AI::MCP::Server.tools_serializations
107
+ end
108
+
109
+ it 'Call tool executes registered tool' do
110
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
111
+
112
+ result = Rasti::AI::MCP::Server.call_tool 'hello_world_tool'
113
+
114
+ assert_equal '{"text":"Hello world"}', result
115
+ end
116
+
117
+ it 'Call tool not found raises error' do
118
+ error = assert_raises RuntimeError do
119
+ Rasti::AI::MCP::Server.call_tool 'non_existent', {}
120
+ end
121
+
122
+ assert_equal 'Tool non_existent not found', error.message
123
+ end
124
+
125
+ end
126
+
127
+ describe 'Initialize request' do
128
+
129
+ it 'Returns protocol version and capabilities' do
130
+ post_mcp_request 'initialize'
131
+
132
+ expected_result = {
133
+ protocolVersion: '2024-11-05',
134
+ capabilities: {
135
+ tools: {
136
+ list: true,
137
+ call: true
138
+ }
139
+ },
140
+ serverInfo: {
141
+ name: 'MCP Server',
142
+ version: '1.0.0'
143
+ }
144
+ }
145
+
146
+ assert_jsonrpc_success expected_result
147
+ end
148
+
149
+ it 'Custom server configuration' do
150
+ Rasti::AI::MCP::Server.configure do |config|
151
+ config.server_name = 'Custom MCP Server'
152
+ config.server_version = '2.0.0'
153
+ end
154
+
155
+ post_mcp_request 'initialize'
156
+
157
+ expected_result = {
158
+ protocolVersion: '2024-11-05',
159
+ capabilities: {
160
+ tools: {
161
+ list: true,
162
+ call: true
163
+ }
164
+ },
165
+ serverInfo: {
166
+ name: 'Custom MCP Server',
167
+ version: '2.0.0'
168
+ }
169
+ }
170
+
171
+ assert_jsonrpc_success expected_result
172
+ end
173
+
174
+ end
175
+
176
+ describe 'Tools list request' do
177
+
178
+ it 'Returns empty list when no tools' do
179
+ post_mcp_request 'tools/list'
180
+
181
+ expected_result = {
182
+ tools: []
183
+ }
184
+
185
+ assert_jsonrpc_success expected_result
186
+ end
187
+
188
+ it 'Returns registered tools' do
189
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
190
+ Rasti::AI::MCP::Server.register_tool SumTool.new
191
+
192
+ post_mcp_request 'tools/list'
193
+
194
+ expected_result = {
195
+ tools: [
196
+ Rasti::AI::ToolSerializer.serialize(HelloWorldTool),
197
+ Rasti::AI::ToolSerializer.serialize(SumTool)
198
+ ]
199
+ }
200
+
201
+ assert_jsonrpc_success expected_result
202
+ end
203
+
204
+ end
205
+
206
+ describe 'Tools call request' do
207
+
208
+ before do
209
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
210
+ Rasti::AI::MCP::Server.register_tool SumTool.new
211
+ end
212
+
213
+ it 'Executes tool with arguments' do
214
+ params = {
215
+ name: 'sum_tool',
216
+ arguments: {
217
+ number_a: 1,
218
+ number_b: 2
219
+ }
220
+ }
221
+
222
+ post_mcp_request 'tools/call', params
223
+
224
+ expected_result = {
225
+ content: [
226
+ {
227
+ type: 'text',
228
+ text: '{"result":3.0}'
229
+ }
230
+ ]
231
+ }
232
+
233
+ assert_jsonrpc_success expected_result
234
+ end
235
+
236
+ it 'Executes tool without arguments' do
237
+ params = {
238
+ name: 'hello_world_tool'
239
+ }
240
+
241
+ post_mcp_request 'tools/call', params
242
+
243
+ expected_result = {
244
+ content: [
245
+ {
246
+ type: 'text',
247
+ text: '{"text":"Hello world"}'
248
+ }
249
+ ]
250
+ }
251
+
252
+ assert_jsonrpc_success expected_result
253
+ end
254
+
255
+ it 'Tool call with nonexistent tool returns error' do
256
+ params = {
257
+ name: 'nonexistent'
258
+ }
259
+
260
+ post_mcp_request 'tools/call', params
261
+
262
+ expected_error = {
263
+ code: -32603,
264
+ message: 'Tool nonexistent not found'
265
+ }
266
+
267
+ assert_jsonrpc_error expected_error
268
+ end
269
+
270
+ it 'Tool execution error returns error response' do
271
+ Rasti::AI::MCP::Server.register_tool ErrorTool.new
272
+
273
+ params = {
274
+ name: 'error_tool'
275
+ }
276
+
277
+ post_mcp_request 'tools/call', params
278
+
279
+ expected_error = {
280
+ code: -32603,
281
+ message: 'Unexpected tool error'
282
+ }
283
+
284
+ assert_jsonrpc_error expected_error
285
+ end
286
+
287
+ end
288
+
289
+ describe 'Error handling' do
290
+
291
+ it 'Method not found' do
292
+ post_mcp_request 'invalid/method'
293
+
294
+ expected_error = {
295
+ code: -32601,
296
+ message: 'Method not found'
297
+ }
298
+
299
+ assert_jsonrpc_error expected_error
300
+ end
301
+
302
+ it 'Invalid JSON returns 400' do
303
+ post '/mcp', 'invalid json{'
304
+
305
+ assert_equal 400, last_response.status
306
+ assert_equal 'application/json', last_response.content_type
307
+
308
+ json_response = JSON.parse last_response.body
309
+ assert_equal -32700, json_response['error']['code']
310
+ assert_match /unexpected token/, json_response['error']['message']
311
+ end
312
+
313
+ it 'Unhandled exception returns 500' do
314
+ app.stub :handle_initialize, ->(_) { raise 'Unexpected server error' } do
315
+ post_mcp_request 'initialize'
316
+
317
+ expected_response = {
318
+ jsonrpc: '2.0',
319
+ id: nil,
320
+ error: {
321
+ code: -32603,
322
+ message: 'Unexpected server error'
323
+ }
324
+ }
325
+
326
+ assert_equal 500, last_response.status
327
+ assert_equal 'application/json', last_response.content_type
328
+ assert_equal_json JSON.dump(expected_response), last_response.body
329
+ end
330
+ end
331
+
332
+ end
333
+
334
+ describe 'Middleware behavior' do
335
+
336
+ it 'Non MCP requests passed to app' do
337
+ get '/other/path'
338
+
339
+ assert_equal 200, last_response.status
340
+ assert_equal 'App response', last_response.body
341
+ end
342
+
343
+ it 'GET requests to MCP path passed to app' do
344
+ get '/mcp'
345
+
346
+ assert_equal 200, last_response.status
347
+ assert_equal 'App response', last_response.body
348
+ end
349
+
350
+ it 'POST to different path passed to app' do
351
+ post '/other/path'
352
+
353
+ assert_equal 200, last_response.status
354
+ assert_equal 'App response', last_response.body
355
+ end
356
+
357
+ it 'Custom relative path' do
358
+ Rasti::AI::MCP::Server.configure do |config|
359
+ config.relative_path = '/path/too/custom_mcp'
360
+ end
361
+
362
+ post '/mcp'
363
+ assert_equal 200, last_response.status
364
+ assert_equal 'App response', last_response.body
365
+
366
+ post_mcp_request 'tools/list'
367
+ assert_jsonrpc_success tools: []
368
+ end
369
+
370
+ end
371
+
372
+ end
@@ -1,7 +1,9 @@
1
1
  require 'coverage_helper'
2
2
  require 'minitest/autorun'
3
3
  require 'minitest/colorin'
4
+ require 'minitest/extended_assertions'
4
5
  require 'webmock/minitest'
6
+ require 'rack/test'
5
7
  require 'pry-nav'
6
8
  require 'rasti-ai'
7
9
  require 'securerandom'
@@ -13,6 +15,8 @@ require_relative 'support/helpers/resources'
13
15
  Rasti::AI.configure do |config|
14
16
  config.logger.level = Logger::FATAL
15
17
 
18
+ config.http_max_retries = 1
19
+
16
20
  config.openai_api_key = 'test_api_key'
17
21
  config.openai_default_model = 'gpt-test'
18
22
  end
@@ -163,11 +163,18 @@ describe Rasti::AI::OpenAI::Assistant do
163
163
  end
164
164
 
165
165
  def stub_client_request(role:, content:, response:, tools:[])
166
+ serialized_tools = tools.map do |tool|
167
+ {
168
+ type: 'function',
169
+ function: Rasti::AI::ToolSerializer.serialize(tool.class)
170
+ }
171
+ end
172
+
166
173
  client.expect :chat_completions, response do |params|
167
174
  last_message = params[:messages].last
168
175
  last_message[:role] == role &&
169
176
  last_message[:content] == content &&
170
- params[:tools] == tools.map { |t| Rasti::AI::OpenAI::ToolSerializer.serialize t.class }
177
+ params[:tools] == serialized_tools
171
178
  end
172
179
  end
173
180
 
@@ -295,4 +302,4 @@ describe Rasti::AI::OpenAI::Assistant do
295
302
 
296
303
  end
297
304
 
298
- end
305
+ end
@@ -1,8 +1,8 @@
1
1
  require 'minitest_helper'
2
2
 
3
- describe Rasti::AI::OpenAI::ToolSerializer do
3
+ describe Rasti::AI::ToolSerializer do
4
4
 
5
- let(:serializer) { Rasti::AI::OpenAI::ToolSerializer }
5
+ let(:serializer) { Rasti::AI::ToolSerializer }
6
6
 
7
7
  def build_tool_class(form_class=nil)
8
8
  tool_class = Minitest::Mock.new
@@ -13,15 +13,12 @@ describe Rasti::AI::OpenAI::ToolSerializer do
13
13
 
14
14
  def build_serializaton(param_name:, param_type:)
15
15
  {
16
- type: 'function',
17
- function: {
18
- name: 'call_custom_function',
19
- parameters: {
20
- type: 'object',
21
- properties: {
22
- param_name.to_sym => {
23
- type: param_type
24
- }
16
+ name: 'call_custom_function',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ param_name.to_sym => {
21
+ type: param_type
25
22
  }
26
23
  }
27
24
  }
@@ -34,10 +31,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
34
31
  serialization = serializer.serialize tool_class
35
32
 
36
33
  expeted_serialization = {
37
- type: 'function',
38
- function: {
39
- name: 'call_custom_function'
40
- }
34
+ name: 'call_custom_function'
41
35
  }
42
36
 
43
37
  assert_equal expeted_serialization, serialization
@@ -52,11 +46,8 @@ describe Rasti::AI::OpenAI::ToolSerializer do
52
46
  serialization = serializer.serialize tool_class
53
47
 
54
48
  expeted_serialization = {
55
- type: 'function',
56
- function: {
57
- name: 'call_custom_function',
58
- description: 'Call custom function without arguments',
59
- }
49
+ name: 'call_custom_function',
50
+ description: 'Call custom function without arguments',
60
51
  }
61
52
 
62
53
  assert_equal expeted_serialization, serialization
@@ -132,7 +123,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
132
123
  serialization = serializer.serialize tool_class
133
124
 
134
125
  expeted_serialization = build_serializaton param_name: 'timestamp', param_type: 'string'
135
- expeted_serialization[:function][:parameters][:properties][:timestamp][:format] = 'date'
126
+ expeted_serialization[:inputSchema][:properties][:timestamp][:format] = 'date'
136
127
 
137
128
  assert_equal expeted_serialization, serialization
138
129
 
@@ -147,7 +138,8 @@ describe Rasti::AI::OpenAI::ToolSerializer do
147
138
  serialization = serializer.serialize tool_class
148
139
 
149
140
  expeted_serialization = build_serializaton param_name: 'option', param_type: 'string'
150
- expeted_serialization[:function][:parameters][:properties][:option][:enum] = ['option_1', 'option_2']
141
+ expeted_serialization[:inputSchema][:properties][:option][:enum] = ['option_1', 'option_2']
142
+ expeted_serialization[:inputSchema][:properties][:option][:description] = 'option_1, option_2'
151
143
 
152
144
  assert_equal expeted_serialization, serialization
153
145
 
@@ -163,7 +155,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
163
155
  serialization = serializer.serialize tool_class
164
156
 
165
157
  expeted_serialization = build_serializaton param_name: 'form', param_type: 'object'
166
- expeted_serialization[:function][:parameters][:properties][:form][:properties] = {
158
+ expeted_serialization[:inputSchema][:properties][:form][:properties] = {
167
159
  text: {type: 'string'},
168
160
  int: {type: 'integer'}
169
161
  }
@@ -183,7 +175,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
183
175
  serialization = serializer.serialize tool_class
184
176
 
185
177
  expeted_serialization = build_serializaton param_name: 'texts', param_type: 'array'
186
- expeted_serialization[:function][:parameters][:properties][:texts][:items] = {type: 'string'}
178
+ expeted_serialization[:inputSchema][:properties][:texts][:items] = {type: 'string'}
187
179
 
188
180
  assert_equal expeted_serialization, serialization
189
181
 
@@ -198,7 +190,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
198
190
  serialization = serializer.serialize tool_class
199
191
 
200
192
  expeted_serialization = build_serializaton param_name: 'numbers', param_type: 'array'
201
- expeted_serialization[:function][:parameters][:properties][:numbers][:items] = {type: 'number'}
193
+ expeted_serialization[:inputSchema][:properties][:numbers][:items] = {type: 'number'}
202
194
 
203
195
  assert_equal expeted_serialization, serialization
204
196
 
@@ -214,7 +206,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
214
206
  serialization = serializer.serialize tool_class
215
207
 
216
208
  expeted_serialization = build_serializaton param_name: 'forms', param_type: 'array'
217
- expeted_serialization[:function][:parameters][:properties][:forms][:items] = {
209
+ expeted_serialization[:inputSchema][:properties][:forms][:items] = {
218
210
  type: 'object',
219
211
  properties: {
220
212
  text: {type: 'string'},
@@ -242,11 +234,11 @@ describe Rasti::AI::OpenAI::ToolSerializer do
242
234
  serialization = serializer.serialize tool_class
243
235
 
244
236
  expeted_serialization = build_serializaton param_name: 'form', param_type: 'object'
245
- expeted_serialization[:function][:parameters][:properties] = {
237
+ expeted_serialization[:inputSchema][:properties] = {
246
238
  text: {type: 'string'},
247
239
  int: {type: 'integer'}
248
240
  }
249
- expeted_serialization[:function][:parameters][:required] = [:text]
241
+ expeted_serialization[:inputSchema][:required] = [:text]
250
242
 
251
243
  assert_equal expeted_serialization, serialization
252
244
 
@@ -264,7 +256,7 @@ describe Rasti::AI::OpenAI::ToolSerializer do
264
256
  serialization = serializer.serialize tool_class
265
257
 
266
258
  expeted_serialization = build_serializaton param_name: 'form', param_type: 'object'
267
- expeted_serialization[:function][:parameters][:properties] = {
259
+ expeted_serialization[:inputSchema][:properties] = {
268
260
  text: {
269
261
  description: 'Text param',
270
262
  type: 'string'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rasti-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Naiman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-27 00:00:00.000000000 Z
11
+ date: 2025-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_require
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '12.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rack-test
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: minitest
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -128,6 +142,20 @@ dependencies:
128
142
  - - "~>"
129
143
  - !ruby/object:Gem::Version
130
144
  version: '0.6'
145
+ - !ruby/object:Gem::Dependency
146
+ name: minitest-extended_assertions
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.0'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '1.0'
131
159
  - !ruby/object:Gem::Dependency
132
160
  name: simplecov
133
161
  requirement: !ruby/object:Gem::Requirement
@@ -188,24 +216,31 @@ files:
188
216
  - lib/rasti-ai.rb
189
217
  - lib/rasti/ai.rb
190
218
  - lib/rasti/ai/errors.rb
219
+ - lib/rasti/ai/mcp/client.rb
220
+ - lib/rasti/ai/mcp/errors.rb
221
+ - lib/rasti/ai/mcp/server.rb
191
222
  - lib/rasti/ai/open_ai/assistant.rb
192
223
  - lib/rasti/ai/open_ai/assistant_state.rb
193
224
  - lib/rasti/ai/open_ai/client.rb
194
225
  - lib/rasti/ai/open_ai/roles.rb
195
- - lib/rasti/ai/open_ai/tool_serializer.rb
226
+ - lib/rasti/ai/tool.rb
227
+ - lib/rasti/ai/tool_serializer.rb
196
228
  - lib/rasti/ai/version.rb
229
+ - log/.gitkeep
197
230
  - rasti-ai.gemspec
198
231
  - spec/coverage_helper.rb
232
+ - spec/mcp/client_spec.rb
233
+ - spec/mcp/server_spec.rb
199
234
  - spec/minitest_helper.rb
200
235
  - spec/open_ai/assistant_spec.rb
201
236
  - spec/open_ai/client_spec.rb
202
- - spec/open_ai/tool_serializer_spec.rb
203
237
  - spec/resources/open_ai/basic_request.json
204
238
  - spec/resources/open_ai/basic_response.json
205
239
  - spec/resources/open_ai/tool_request.json
206
240
  - spec/resources/open_ai/tool_response.json
207
241
  - spec/support/helpers/erb.rb
208
242
  - spec/support/helpers/resources.rb
243
+ - spec/tool_serializer_spec.rb
209
244
  homepage: https://github.com/gabynaiman/rasti-ai
210
245
  licenses:
211
246
  - MIT
@@ -231,13 +266,15 @@ specification_version: 4
231
266
  summary: AI for apps
232
267
  test_files:
233
268
  - spec/coverage_helper.rb
269
+ - spec/mcp/client_spec.rb
270
+ - spec/mcp/server_spec.rb
234
271
  - spec/minitest_helper.rb
235
272
  - spec/open_ai/assistant_spec.rb
236
273
  - spec/open_ai/client_spec.rb
237
- - spec/open_ai/tool_serializer_spec.rb
238
274
  - spec/resources/open_ai/basic_request.json
239
275
  - spec/resources/open_ai/basic_response.json
240
276
  - spec/resources/open_ai/tool_request.json
241
277
  - spec/resources/open_ai/tool_response.json
242
278
  - spec/support/helpers/erb.rb
243
279
  - spec/support/helpers/resources.rb
280
+ - spec/tool_serializer_spec.rb