rasti-ai 1.0.1 → 1.2.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.
@@ -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
@@ -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'
@@ -8,14 +8,17 @@ describe Rasti::AI::OpenAI::Assistant do
8
8
 
9
9
  let(:answer) { 'Lionel Messi scored 672 goals in 778 official matches for FC Barcelona.' }
10
10
 
11
- def stub_open_ai_chat_completions(model:nil, question:, answer:)
11
+ def stub_open_ai_chat_completions(question:, answer:, model:nil, json_schema:nil)
12
12
  model ||= Rasti::AI.openai_default_model
13
13
 
14
+ body = read_json_resource('open_ai/basic_request.json', model: model, prompt: question)
15
+ body['response_format'] = {type: 'json_schema', json_schema: json_schema} if json_schema
16
+
14
17
  stub_request(:post, api_url)
15
- .with(body: read_resource('open_ai/basic_request.json', model: model, prompt: question))
18
+ .with(body: JSON.dump(body))
16
19
  .to_return(body: read_resource('open_ai/basic_response.json', content: answer))
17
20
  end
18
-
21
+
19
22
 
20
23
  it 'Default' do
21
24
  stub_open_ai_chat_completions question: question, answer: answer
@@ -39,7 +42,8 @@ describe Rasti::AI::OpenAI::Assistant do
39
42
  role: Rasti::AI::OpenAI::Roles::USER,
40
43
  content: question
41
44
  }
42
- ]
45
+ ],
46
+ response_format: nil
43
47
  }
44
48
  ]
45
49
 
@@ -107,6 +111,19 @@ describe Rasti::AI::OpenAI::Assistant do
107
111
  assert_equal answer, response
108
112
  end
109
113
 
114
+ it 'JSON Schema' do
115
+ json_schema = {answer: 'Response answer'}
116
+ json_answer = "{\\\"answer\\\": \\\"#{answer}\\\"}"
117
+
118
+ stub_open_ai_chat_completions question: question, answer: json_answer, json_schema: json_schema
119
+
120
+ assistant = Rasti::AI::OpenAI::Assistant.new json_schema: json_schema
121
+
122
+ response = assistant.call question
123
+
124
+ assert_equal answer, JSON.parse(response)['answer']
125
+ end
126
+
110
127
  end
111
128
 
112
129
  describe 'Tools' do
@@ -146,11 +163,18 @@ describe Rasti::AI::OpenAI::Assistant do
146
163
  end
147
164
 
148
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
+
149
173
  client.expect :chat_completions, response do |params|
150
174
  last_message = params[:messages].last
151
175
  last_message[:role] == role &&
152
176
  last_message[:content] == content &&
153
- params[:tools] == tools.map { |t| Rasti::AI::OpenAI::ToolSerializer.serialize t.class }
177
+ params[:tools] == serialized_tools
154
178
  end
155
179
  end
156
180