rasti-ai 2.0.2 → 3.0.0

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.
@@ -2,42 +2,20 @@ require 'minitest_helper'
2
2
 
3
3
  describe Rasti::AI::MCP::Server do
4
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
5
  class ErrorTool < Rasti::AI::Tool
27
6
  def execute(form)
28
- raise "Unexpected tool error"
7
+ raise 'Unexpected tool error'
29
8
  end
30
9
  end
31
10
 
32
11
  include Rack::Test::Methods
33
12
 
34
13
  let(:app) do
35
- app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['App response']] }
36
- Rasti::AI::MCP::Server.new app
14
+ inner = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['App response']] }
15
+ Rasti::AI::MCP::Server.new inner
37
16
  end
38
17
 
39
18
  before do
40
- Rasti::AI::MCP::Server.clear_tools
41
19
  Rasti::AI::MCP::Server.restore_default_configuration
42
20
  end
43
21
 
@@ -49,7 +27,7 @@ describe Rasti::AI::MCP::Server do
49
27
  }
50
28
  request_data[:params] = params unless params.empty?
51
29
 
52
- post Rasti::AI::MCP::Server.relative_path, JSON.dump(request_data), CONTENT_TYPE: 'application/json'
30
+ post Rasti::AI::MCP::Server.relative_path, JSON.dump(request_data), 'CONTENT_TYPE' => 'application/json'
53
31
  end
54
32
 
55
33
  def assert_jsonrpc_success(expected_result)
@@ -58,7 +36,7 @@ describe Rasti::AI::MCP::Server do
58
36
  id: 1,
59
37
  result: expected_result
60
38
  }
61
-
39
+
62
40
  assert_equal 200, last_response.status
63
41
  assert_equal 'application/json', last_response.content_type
64
42
  assert_equal_json JSON.dump(expected_response), last_response.body
@@ -70,60 +48,12 @@ describe Rasti::AI::MCP::Server do
70
48
  id: 1,
71
49
  error: expected_error
72
50
  }
73
-
51
+
74
52
  assert_equal 200, last_response.status
75
53
  assert_equal 'application/json', last_response.content_type
76
54
  assert_equal_json JSON.dump(expected_response), last_response.body
77
55
  end
78
56
 
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
57
  describe 'Initialize request' do
128
58
 
129
59
  it 'Returns protocol version and capabilities' do
@@ -148,10 +78,10 @@ describe Rasti::AI::MCP::Server do
148
78
 
149
79
  it 'Custom server configuration' do
150
80
  Rasti::AI::MCP::Server.configure do |config|
151
- config.server_name = 'Custom MCP Server'
81
+ config.server_name = 'Custom MCP Server'
152
82
  config.server_version = '2.0.0'
153
83
  end
154
-
84
+
155
85
  post_mcp_request 'initialize'
156
86
 
157
87
  expected_result = {
@@ -175,19 +105,19 @@ describe Rasti::AI::MCP::Server do
175
105
 
176
106
  describe 'Tools list request' do
177
107
 
178
- it 'Returns empty list when no tools' do
108
+ it 'Returns empty list when no builder configured' do
179
109
  post_mcp_request 'tools/list'
180
110
 
181
- expected_result = {
182
- tools: []
183
- }
184
-
185
- assert_jsonrpc_success expected_result
111
+ assert_jsonrpc_success tools: []
186
112
  end
187
113
 
188
- it 'Returns registered tools' do
189
- Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
190
- Rasti::AI::MCP::Server.register_tool SumTool.new
114
+ it 'Returns tools registered in builder' do
115
+ Rasti::AI::MCP::Server.configure do |config|
116
+ config.load_tools do |tools_registry, _request|
117
+ tools_registry.register tool: HelloWorldTool.new
118
+ tools_registry.register tool: SumTool.new
119
+ end
120
+ end
191
121
 
192
122
  post_mcp_request 'tools/list'
193
123
 
@@ -201,13 +131,46 @@ describe Rasti::AI::MCP::Server do
201
131
  assert_jsonrpc_success expected_result
202
132
  end
203
133
 
134
+ it 'Passes request to builder' do
135
+ received_path = nil
136
+
137
+ Rasti::AI::MCP::Server.configure do |config|
138
+ config.load_tools do |tools_registry, request|
139
+ received_path = request.path
140
+ end
141
+ end
142
+
143
+ post_mcp_request 'tools/list'
144
+
145
+ assert_equal '/mcp', received_path
146
+ end
147
+
148
+ it 'Builder runs on every request' do
149
+ call_count = 0
150
+
151
+ Rasti::AI::MCP::Server.configure do |config|
152
+ config.load_tools do |tools_registry, _request|
153
+ call_count += 1
154
+ end
155
+ end
156
+
157
+ post_mcp_request 'tools/list'
158
+ post_mcp_request 'tools/list'
159
+
160
+ assert_equal 2, call_count
161
+ end
162
+
204
163
  end
205
164
 
206
165
  describe 'Tools call request' do
207
166
 
208
167
  before do
209
- Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
210
- Rasti::AI::MCP::Server.register_tool SumTool.new
168
+ Rasti::AI::MCP::Server.configure do |config|
169
+ config.load_tools do |tools_registry, _request|
170
+ tools_registry.register tool: HelloWorldTool.new
171
+ tools_registry.register tool: SumTool.new
172
+ end
173
+ end
211
174
  end
212
175
 
213
176
  it 'Executes tool with arguments' do
@@ -234,11 +197,7 @@ describe Rasti::AI::MCP::Server do
234
197
  end
235
198
 
236
199
  it 'Executes tool without arguments' do
237
- params = {
238
- name: 'hello_world_tool'
239
- }
240
-
241
- post_mcp_request 'tools/call', params
200
+ post_mcp_request 'tools/call', name: 'hello_world_tool'
242
201
 
243
202
  expected_result = {
244
203
  content: [
@@ -252,12 +211,48 @@ describe Rasti::AI::MCP::Server do
252
211
  assert_jsonrpc_success expected_result
253
212
  end
254
213
 
255
- it 'Tool call with nonexistent tool returns error' do
256
- params = {
257
- name: 'nonexistent'
214
+ it 'Executes block-registered tool' do
215
+ Rasti::AI::MCP::Server.configure do |config|
216
+ config.load_tools do |tools_registry, _request|
217
+ schema = {type: 'object', properties: {text: {type: 'string'}}}
218
+ tools_registry.register name: 'echo', description: 'Echo text', input_schema: schema do |args|
219
+ args['text']
220
+ end
221
+ end
222
+ end
223
+
224
+ post_mcp_request 'tools/call', name: 'echo', arguments: {text: 'hello'}
225
+
226
+ expected_result = {
227
+ content: [
228
+ {
229
+ type: 'text',
230
+ text: 'hello'
231
+ }
232
+ ]
258
233
  }
259
234
 
260
- post_mcp_request 'tools/call', params
235
+ assert_jsonrpc_success expected_result
236
+ end
237
+
238
+ it 'Receives request context in builder during tool call' do
239
+ received_header = nil
240
+
241
+ Rasti::AI::MCP::Server.configure do |config|
242
+ config.load_tools do |tools_registry, request|
243
+ received_header = request.env['HTTP_X_USER_ID']
244
+ tools_registry.register tool: HelloWorldTool.new
245
+ end
246
+ end
247
+
248
+ header 'X-User-Id', '42'
249
+ post_mcp_request 'tools/call', name: 'hello_world_tool'
250
+
251
+ assert_equal '42', received_header
252
+ end
253
+
254
+ it 'Tool call with nonexistent tool returns error' do
255
+ post_mcp_request 'tools/call', name: 'nonexistent'
261
256
 
262
257
  expected_error = {
263
258
  code: -32603,
@@ -268,13 +263,13 @@ describe Rasti::AI::MCP::Server do
268
263
  end
269
264
 
270
265
  it 'Tool execution error returns error response' do
271
- Rasti::AI::MCP::Server.register_tool ErrorTool.new
266
+ Rasti::AI::MCP::Server.configure do |config|
267
+ config.load_tools do |tools_registry, _request|
268
+ tools_registry.register tool: ErrorTool.new
269
+ end
270
+ end
272
271
 
273
- params = {
274
- name: 'error_tool'
275
- }
276
-
277
- post_mcp_request 'tools/call', params
272
+ post_mcp_request 'tools/call', name: 'error_tool'
278
273
 
279
274
  expected_error = {
280
275
  code: -32603,
@@ -296,53 +291,117 @@ describe Rasti::AI::MCP::Server do
296
291
  message: 'Method not found'
297
292
  }
298
293
 
299
- assert_jsonrpc_error expected_error
294
+ assert_jsonrpc_error expected_error
300
295
  end
301
296
 
302
- it 'Invalid JSON returns 400' do
303
- post '/mcp', 'invalid json{'
304
-
297
+ it 'Invalid JSON returns parse error' do
298
+ post Rasti::AI::MCP::Server.relative_path, 'not valid json', 'CONTENT_TYPE' => 'application/json'
299
+
305
300
  assert_equal 400, last_response.status
301
+ end
302
+
303
+ end
304
+
305
+ describe 'Authentication' do
306
+
307
+ def assert_unauthorized
308
+ expected_response = {
309
+ jsonrpc: '2.0',
310
+ id: nil,
311
+ error: {
312
+ code: -32002,
313
+ message: 'Unauthorized'
314
+ }
315
+ }
316
+
317
+ assert_equal 401, last_response.status
306
318
  assert_equal 'application/json', last_response.content_type
319
+ assert_equal_json JSON.dump(expected_response), last_response.body
320
+ end
321
+
322
+ it 'Allows request when no authenticator configured' do
323
+ post_mcp_request 'initialize'
307
324
 
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']
325
+ assert_equal 200, last_response.status
311
326
  end
312
327
 
313
- it 'Unhandled exception returns 500' do
314
- app.stub :handle_initialize, ->(_) { raise 'Unexpected server error' } do
315
- post_mcp_request 'initialize'
328
+ it 'Allows request when authenticator returns true' do
329
+ Rasti::AI::MCP::Server.configure do |config|
330
+ config.authenticate { |_request| true }
331
+ end
316
332
 
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
333
+ post_mcp_request 'initialize'
334
+
335
+ assert_equal 200, last_response.status
336
+ end
337
+
338
+ it 'Rejects initialize when authenticator returns false' do
339
+ Rasti::AI::MCP::Server.configure do |config|
340
+ config.authenticate { |_request| false }
329
341
  end
342
+
343
+ post_mcp_request 'initialize'
344
+
345
+ assert_unauthorized
330
346
  end
331
347
 
332
- end
348
+ it 'Rejects tools/list when authenticator returns false' do
349
+ Rasti::AI::MCP::Server.configure do |config|
350
+ config.authenticate { |_request| false }
351
+ end
352
+
353
+ post_mcp_request 'tools/list'
354
+
355
+ assert_unauthorized
356
+ end
357
+
358
+ it 'Rejects tools/call when authenticator returns false' do
359
+ Rasti::AI::MCP::Server.configure do |config|
360
+ config.authenticate { |_request| false }
361
+ end
362
+
363
+ post_mcp_request 'tools/call', name: 'any_tool'
364
+
365
+ assert_unauthorized
366
+ end
367
+
368
+ it 'Validates bearer token from Authorization header' do
369
+ Rasti::AI::MCP::Server.configure do |config|
370
+ config.authenticate { |request| request.env['HTTP_AUTHORIZATION'] == 'Bearer secret' }
371
+ config.load_tools { |registry, _| registry.register tool: HelloWorldTool.new }
372
+ end
333
373
 
334
- describe 'Middleware behavior' do
374
+ post_mcp_request 'tools/list'
375
+ assert_unauthorized
335
376
 
336
- it 'Non MCP requests passed to app' do
337
- get '/other/path'
338
-
377
+ header 'Authorization', 'Bearer secret'
378
+ post_mcp_request 'tools/list'
339
379
  assert_equal 200, last_response.status
340
- assert_equal 'App response', last_response.body
341
380
  end
342
381
 
382
+ it 'Does not call load_tools when authentication fails' do
383
+ tools_loader_called = false
384
+
385
+ Rasti::AI::MCP::Server.configure do |config|
386
+ config.authenticate { |_request| false }
387
+ config.load_tools do |registry, _|
388
+ tools_loader_called = true
389
+ registry.register tool: HelloWorldTool.new
390
+ end
391
+ end
392
+
393
+ post_mcp_request 'tools/list'
394
+
395
+ assert_equal false, tools_loader_called
396
+ end
397
+
398
+ end
399
+
400
+ describe 'Request routing' do
401
+
343
402
  it 'GET requests to MCP path passed to app' do
344
403
  get '/mcp'
345
-
404
+
346
405
  assert_equal 200, last_response.status
347
406
  assert_equal 'App response', last_response.body
348
407
  end
@@ -361,12 +420,12 @@ describe Rasti::AI::MCP::Server do
361
420
 
362
421
  post '/mcp'
363
422
  assert_equal 200, last_response.status
364
- assert_equal 'App response', last_response.body
365
-
423
+ assert_equal 'App response', last_response.body
424
+
366
425
  post_mcp_request 'tools/list'
367
426
  assert_jsonrpc_success tools: []
368
427
  end
369
428
 
370
429
  end
371
430
 
372
- end
431
+ end
@@ -0,0 +1,226 @@
1
+ require 'minitest_helper'
2
+
3
+ describe Rasti::AI::MCP::ToolsRegistry do
4
+
5
+ class NoDescriptionTool < Rasti::AI::Tool
6
+ def execute(form)
7
+ {ok: true}
8
+ end
9
+ end
10
+
11
+ let(:tools_registry) { Rasti::AI::MCP::ToolsRegistry.new }
12
+
13
+ describe 'Register with tool instance' do
14
+
15
+ it 'Derives name, description and schema from tool class' do
16
+ tools_registry.register tool: HelloWorldTool.new
17
+
18
+ assert_equal [Rasti::AI::ToolSerializer.serialize(HelloWorldTool)], tools_registry.serializations
19
+ end
20
+
21
+ it 'Overrides name when name is provided' do
22
+ tools_registry.register name: 'custom_name', tool: HelloWorldTool.new
23
+
24
+ assert_equal 'custom_name', tools_registry.serializations.first[:name]
25
+ end
26
+
27
+ it 'Overrides description when description is provided' do
28
+ tools_registry.register tool: HelloWorldTool.new, description: 'Custom description'
29
+
30
+ assert_equal 'Custom description', tools_registry.serializations.first[:description]
31
+ end
32
+
33
+ it 'Overrides input schema when input_schema is provided' do
34
+ schema = {type: 'object', properties: {x: {type: 'string'}}}
35
+
36
+ tools_registry.register tool: HelloWorldTool.new, input_schema: schema
37
+
38
+ assert_equal schema, tools_registry.serializations.first[:inputSchema]
39
+ end
40
+
41
+ it 'Omits description when tool class has none' do
42
+ tools_registry.register tool: NoDescriptionTool.new
43
+
44
+ refute tools_registry.serializations.first.key? :description
45
+ end
46
+
47
+ it 'Executes tool when called' do
48
+ tools_registry.register tool: HelloWorldTool.new
49
+
50
+ result = tools_registry.call 'hello_world_tool'
51
+
52
+ assert_equal '{"text":"Hello world"}', result
53
+ end
54
+
55
+ it 'Executes tool with arguments when called' do
56
+ tools_registry.register tool: SumTool.new
57
+
58
+ result = tools_registry.call 'sum_tool', 'number_a' => 3, 'number_b' => 4
59
+
60
+ assert_equal '{"result":7.0}', result
61
+ end
62
+
63
+ end
64
+
65
+ describe 'Register with form and block' do
66
+
67
+ it 'Builds schema from form class' do
68
+ tools_registry.register name: 'sum', description: 'Sum two numbers', form: SumTool::Form do |args|
69
+ (args['number_a'] + args['number_b']).to_s
70
+ end
71
+
72
+ expected_serialization = {
73
+ name: 'sum',
74
+ description: 'Sum two numbers',
75
+ inputSchema: Rasti::AI::ToolSerializer.serialize_form(SumTool::Form)
76
+ }
77
+
78
+ assert_equal [expected_serialization], tools_registry.serializations
79
+ end
80
+
81
+ it 'Executes block when called' do
82
+ tools_registry.register name: 'sum', description: 'Sum', form: SumTool::Form do |args|
83
+ (args['number_a'] + args['number_b']).to_s
84
+ end
85
+
86
+ result = tools_registry.call 'sum', 'number_a' => 1.0, 'number_b' => 2.0
87
+
88
+ assert_equal '3.0', result
89
+ end
90
+
91
+ end
92
+
93
+ describe 'Register with input_schema and block' do
94
+
95
+ let(:schema) do
96
+ {
97
+ type: 'object',
98
+ properties: {
99
+ query: {type: 'string', description: 'Search query'},
100
+ limit: {type: 'integer'}
101
+ },
102
+ required: ['query']
103
+ }
104
+ end
105
+
106
+ it 'Uses provided input_schema as-is' do
107
+ tools_registry.register name: 'search', description: 'Search content', input_schema: schema do |args|
108
+ "results for #{args['query']}"
109
+ end
110
+
111
+ expected_serialization = {
112
+ name: 'search',
113
+ description: 'Search content',
114
+ inputSchema: schema
115
+ }
116
+
117
+ assert_equal [expected_serialization], tools_registry.serializations
118
+ end
119
+
120
+ it 'Supports nested schemas' do
121
+ nested_schema = {
122
+ type: 'object',
123
+ properties: {
124
+ title: {type: 'string'},
125
+ filters: {
126
+ type: 'object',
127
+ properties: {
128
+ category: {type: 'string', enum: ['sales', 'ops']},
129
+ date_range: {
130
+ type: 'object',
131
+ properties: {
132
+ from: {type: 'string', format: 'date'},
133
+ to: {type: 'string', format: 'date'}
134
+ },
135
+ required: ['from', 'to']
136
+ }
137
+ }
138
+ }
139
+ },
140
+ required: ['title']
141
+ }
142
+
143
+ tools_registry.register name: 'report', description: 'Generate report', input_schema: nested_schema do |args|
144
+ "report: #{args['title']}"
145
+ end
146
+
147
+ assert_equal nested_schema, tools_registry.serializations.first[:inputSchema]
148
+ end
149
+
150
+ it 'Executes block when called' do
151
+ tools_registry.register name: 'search', description: 'Search', input_schema: schema do |args|
152
+ "results for #{args['query']}"
153
+ end
154
+
155
+ result = tools_registry.call 'search', 'query' => 'ruby'
156
+
157
+ assert_equal 'results for ruby', result
158
+ end
159
+
160
+ end
161
+
162
+ describe 'Register combinations' do
163
+
164
+ it 'Registers multiple tools' do
165
+ tools_registry.register tool: HelloWorldTool.new
166
+ tools_registry.register tool: SumTool.new
167
+
168
+ assert_equal 2, tools_registry.serializations.length
169
+ assert_equal 'hello_world_tool', tools_registry.serializations[0][:name]
170
+ assert_equal 'sum_tool', tools_registry.serializations[1][:name]
171
+ end
172
+
173
+ it 'input_schema overrides form when both provided' do
174
+ custom_schema = {type: 'object', properties: {x: {type: 'string'}}}
175
+
176
+ tools_registry.register(
177
+ name: 'mixed',
178
+ description: 'Mixed',
179
+ form: SumTool::Form,
180
+ input_schema: custom_schema
181
+ ) { |args| args.to_s }
182
+
183
+ assert_equal custom_schema, tools_registry.serializations.first[:inputSchema]
184
+ end
185
+
186
+ it 'Block overrides tool executor when both provided' do
187
+ tools_registry.register tool: HelloWorldTool.new do |_args|
188
+ 'overridden'
189
+ end
190
+
191
+ result = tools_registry.call 'hello_world_tool'
192
+
193
+ assert_equal 'overridden', result
194
+ end
195
+
196
+ end
197
+
198
+ describe 'Validation' do
199
+
200
+ it 'Raises when name is missing and no tool provided' do
201
+ error = assert_raises ArgumentError do
202
+ tools_registry.register(description: 'No name') { |_| 'ok' }
203
+ end
204
+
205
+ assert_equal 'name is required', error.message
206
+ end
207
+
208
+ it 'Raises when no executor provided' do
209
+ error = assert_raises ArgumentError do
210
+ tools_registry.register name: 'no_exec', description: 'No executor'
211
+ end
212
+
213
+ assert_match 'no_exec', error.message
214
+ end
215
+
216
+ it 'Raises when calling unknown tool' do
217
+ error = assert_raises RuntimeError do
218
+ tools_registry.call 'nonexistent'
219
+ end
220
+
221
+ assert_equal 'Tool nonexistent not found', error.message
222
+ end
223
+
224
+ end
225
+
226
+ end