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.
- 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 +37 -4
- 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/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 +2 -0
- data/spec/open_ai/assistant_spec.rb +29 -5
- 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,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
|
data/spec/minitest_helper.rb
CHANGED
|
@@ -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,
|
|
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:
|
|
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] ==
|
|
177
|
+
params[:tools] == serialized_tools
|
|
154
178
|
end
|
|
155
179
|
end
|
|
156
180
|
|