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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9410e72e90cbc69453db4573c4ea4f611719feafc816c389f0490fe003f5f7ee
4
- data.tar.gz: 3e5776057dd26b4adf2a138d94511a79a88fa55418ca0b2c834aeb62ada9d70f
3
+ metadata.gz: f62b3decd7ee67906392eff6218e29fd8a66bee1f5dc41729e4412193f7ff4d0
4
+ data.tar.gz: 3368912d5ee86e2ba10408aa4795c97820b8f313a6d17d693deca2f0454ad9e8
5
5
  SHA512:
6
- metadata.gz: 889f7e24c2438cba3e7f434e6d1578c734a0bbc6b2ef867da7ee8a74a69d6b6451c4c20da3bc34944f4b2113d679991fb3aa40f761e285266ad6d3cb4a0881ae
7
- data.tar.gz: ddafe4e189697dfcc5980da19bb9339387867f1ceacab79fa4f74aba50ebf84d92754863c066e2db86586d7dd637075ce7fd36ced4ea948ca59c67b09f88aba7
6
+ metadata.gz: da0b274e1ed8e4d4c0d9d4f7867ca546f47bc13c48246e111a71f465719237abf11985384699097f19d61822a33575fdd2a3398c63b2bb12a4612b11e563cec6
7
+ data.tar.gz: 48618afd26f383ff8c38d7527c3aac4f44015c9812677c93a893c7d9ccbddc0ed8cd4f6ebb6309acac3c334effaffec6138fb72715f036a51083839129f44cab
data/.gitignore CHANGED
@@ -7,4 +7,5 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
- /.ruby-env
10
+ /.ruby-env
11
+ /log/*.log
data/README.md CHANGED
@@ -96,12 +96,160 @@ state.messages
96
96
  # ]
97
97
  ```
98
98
 
99
+ ### MCP (Model Context Protocol)
100
+
101
+ Rasti::AI includes support for the Model Context Protocol, allowing you to create MCP servers and clients for tool communication.
102
+
103
+ #### MCP Server
104
+
105
+ The MCP Server acts as a Rack middleware that exposes registered tools through a JSON-RPC 2.0 interface.
106
+
107
+ ##### Configuration
108
+
109
+ ```ruby
110
+ Rasti::AI::MCP::Server.configure do |config|
111
+ config.server_name = 'My MCP Server'
112
+ config.server_version = '1.0.0'
113
+ config.relative_path = '/mcp' # Default endpoint path
114
+ end
115
+ ```
116
+
117
+ ##### Registering Tools
118
+
119
+ Tools must inherit from `Rasti::AI::Tool` and can be registered with the server:
120
+
121
+ ```ruby
122
+ class HelloWorldTool < Rasti::AI::Tool
123
+ def self.description
124
+ 'Returns a hello world message'
125
+ end
126
+
127
+ def execute(form)
128
+ {text: 'Hello world'}
129
+ end
130
+ end
131
+
132
+ class SumTool < Rasti::AI::Tool
133
+ class Form < Rasti::Form
134
+ attribute :number_a, Rasti::Types::Float
135
+ attribute :number_b, Rasti::Types::Float
136
+ end
137
+
138
+ def execute(form)
139
+ {result: form.number_a + form.number_b}
140
+ end
141
+ end
142
+
143
+ # Register tools
144
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
145
+ Rasti::AI::MCP::Server.register_tool SumTool.new
146
+ ```
147
+
148
+ ##### Using as Rack Middleware
149
+
150
+ ```ruby
151
+ # In your config.ru
152
+ require 'rasti/ai'
153
+
154
+ # Register your tools
155
+ Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
156
+ Rasti::AI::MCP::Server.register_tool SumTool.new
157
+
158
+ # Use as middleware
159
+ use Rasti::AI::MCP::Server
160
+
161
+ run YourApp
162
+ ```
163
+
164
+ The server will handle POST requests to the configured path (`/mcp` by default) and pass all other requests to your application.
165
+
166
+ ##### Supported MCP Methods
167
+
168
+ - `initialize` - Returns protocol version and server capabilities
169
+ - `tools/list` - Returns all registered tools with their schemas
170
+ - `tools/call` - Executes a specific tool with provided arguments
171
+
172
+ #### MCP Client
173
+
174
+ The MCP Client allows you to communicate with MCP servers.
175
+
176
+ ##### Basic Usage
177
+
178
+ ```ruby
179
+ # Create a client
180
+ client = Rasti::AI::MCP::Client.new(
181
+ url: 'https://mcp.server.ai/mcp'
182
+ )
183
+
184
+ # List available tools
185
+ tools = client.list_tools
186
+ # => [
187
+ # { "name" => "hello_world_tool", "description" => "Hello World", ... },
188
+ # { "name" => "sum_tool", "description" => "Sum two numbers", ... }
189
+ # ]
190
+
191
+ # Call a tool
192
+ result = client.call_tool 'sum_tool', number_a: 5, number_b: 3
193
+ # => '{"type":"text","text":"{\"result\":8.0}"}'
194
+
195
+ result = client.call_tool 'hello_world_tool'
196
+ # => '{"type":"text","text":"{\"text\":\"Hello world\"}"}'
197
+ ```
198
+
199
+ ##### Restricting Available Tools
200
+
201
+ You can restrict which tools the client can access:
202
+
203
+ ```ruby
204
+ client = Rasti::AI::MCP::Client.new(
205
+ url: 'https://mcp.server.ai/mcp',
206
+ allowed_tools: ['sum_tool', 'multiply_tool']
207
+ )
208
+
209
+ # Only returns allowed tools
210
+ tools = client.list_tools
211
+ # => [{ "name" => "sum_tool", ... }]
212
+
213
+ # Calling a non-allowed tool raises an error
214
+ client.call_tool 'hello_world_tool'
215
+ # => RuntimeError: Invalid tool: hello_world_tool
216
+ ```
217
+
218
+ ##### Custom Logger
219
+
220
+ ```ruby
221
+ client = Rasti::AI::MCP::Client.new(
222
+ url: 'https://mcp.server.ai/mcp',
223
+ logger: Logger.new(STDOUT)
224
+ )
225
+ ```
226
+
227
+ ##### Integration with OpenAI Assistant
228
+
229
+ You can use MCP clients as tools for the OpenAI Assistant:
230
+
231
+ ```ruby
232
+ # Create an MCP client
233
+ mcp_client = Rasti::AI::MCP::Client.new(
234
+ url: 'https://mcp.server.ai/mcp'
235
+ )
236
+
237
+ # Use it with the assistant
238
+ assistant = Rasti::AI::OpenAI::Assistant.new(
239
+ mcp_servers: {
240
+ my_mcp: mcp_client
241
+ }
242
+ )
243
+
244
+ # The assistant can now call tools from the MCP server
245
+ assistant.call 'What is 5 plus 3?'
246
+ # The assistant will use the sum_tool from the MCP server
247
+ ```
248
+
99
249
  ## Contributing
100
250
 
101
251
  Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/rasti-ai.
102
252
 
103
-
104
253
  ## License
105
254
 
106
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
107
-
255
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,68 @@
1
+ module Rasti
2
+ module AI
3
+ module MCP
4
+ class Client
5
+
6
+ def initialize(url:, allowed_tools:nil, logger:nil)
7
+ @url = url
8
+ @allowed_tools = allowed_tools
9
+ @logger = logger || Rasti::AI.logger
10
+ end
11
+
12
+ def list_tools
13
+ result = request_mcp 'tools/list'
14
+ tools = result['tools']
15
+ if allowed_tools
16
+ tools.select { |tool| allowed_tools.include? tool['name'] }
17
+ else
18
+ tools
19
+ end
20
+ end
21
+
22
+ def call_tool(name, arguments={})
23
+ raise "Invalid tool: #{name}" if allowed_tools && !allowed_tools.include?(name)
24
+ result = request_mcp 'tools/call', name: name, arguments: arguments
25
+ JSON.dump result['content'][0]
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :url, :allowed_tools, :logger
31
+
32
+ def request_mcp(method, params={})
33
+ uri = URI.parse url
34
+
35
+ http = Net::HTTP.new uri.host, uri.port
36
+ http.use_ssl = uri.scheme == 'https'
37
+
38
+ request = Net::HTTP::Post.new uri.path
39
+
40
+ request['Content-Type'] = 'application/json'
41
+
42
+ body = {
43
+ jsonrpc: '2.0',
44
+ method: method,
45
+ params: params
46
+ }
47
+ request.body = JSON.dump body
48
+
49
+ logger.info(self.class) { "POST #{url} -> #{method}" }
50
+ logger.debug(self.class) { JSON.pretty_generate(params) }
51
+
52
+ response = http.request request
53
+
54
+ logger.info(self.class) { "Response #{response.code}" }
55
+ logger.debug(self.class) { response.body }
56
+
57
+ json = JSON.parse response.body
58
+
59
+ raise "MCP Error: #{json['error']['message']}" if json['error']
60
+ raise "MCP Error: invalid result" unless json['result']
61
+
62
+ json['result']
63
+ end
64
+
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,29 @@
1
+ module Rasti
2
+ module AI
3
+ module MCP
4
+
5
+ # JSON-RPC oficiales
6
+ JSON_RPC_PARSE_ERROR = -32700
7
+ JSON_RPC_INVALID_REQUEST = -32600
8
+ JSON_RPC_METHOD_NOT_FOUND = -32601
9
+ JSON_RPC_INVALID_PARAMS = -32602
10
+ JSON_RPC_INTERNAL_ERROR = -32603
11
+
12
+ # JSON-RPC rango de servidor (permitido)
13
+ JSON_RPC_SERVER_ERROR = -32000
14
+ JSON_RPC_SERVER_NOT_FOUND = -32001
15
+ JSON_RPC_SERVER_UNAUTHORIZED = -32002
16
+ JSON_RPC_SERVER_FORBIDDEN = -32003
17
+ JSON_RPC_SERVER_RESOURCE_NOT_FOUND = -32004
18
+ JSON_RPC_SERVER_RATE_LIMIT = -32005
19
+
20
+ # MCP (usos comunes)
21
+ MCP_INVALID_ACCESS = -32001
22
+ MCP_TOOL_ERROR = -32002
23
+ MCP_FETCH_ERROR = -32003
24
+ MCP_RESOURCE_NOT_FOUND = -32004
25
+ MCP_TIMEOUT = -32005
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,156 @@
1
+ module Rasti
2
+ module AI
3
+ module MCP
4
+ class Server
5
+
6
+ ToolSpecification = Rasti::Model[:tool, :serialization]
7
+
8
+ PROTOCOL_VERSION = '2024-11-05'.freeze
9
+ JSON_RPC_VERSION = '2.0'.freeze
10
+
11
+ extend ClassConfig
12
+
13
+ attr_config :server_name, 'MCP Server'
14
+ attr_config :server_version, '1.0.0'
15
+ attr_config :relative_path, '/mcp'
16
+
17
+ class << self
18
+
19
+ def register_tool(tool)
20
+ serialization = ToolSerializer.serialize tool.class
21
+ raise "Tool #{serialization[:name]} already exist" if tools.key? serialization[:name]
22
+ tools[serialization[:name]] = ToolSpecification.new tool: tool, serialization: serialization
23
+ end
24
+
25
+ def clear_tools
26
+ tools.clear
27
+ end
28
+
29
+ def tools_serializations
30
+ tools.values.map(&:serialization)
31
+ end
32
+
33
+ def call_tool(name, arguments={})
34
+ raise "Tool #{name} not found" unless tools.key? name
35
+ tools[name].tool.call arguments
36
+ end
37
+
38
+ private
39
+
40
+ def tools
41
+ @tools ||= {}
42
+ end
43
+
44
+ end
45
+
46
+ def initialize(app)
47
+ @app = app
48
+ end
49
+
50
+ def call(env)
51
+ request = Rack::Request.new env
52
+
53
+ if request.post? && request.path == self.class.relative_path
54
+ handle_mcp_request request
55
+ else
56
+ app.call env
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :app
63
+
64
+ def handle_mcp_request(request)
65
+ body = request.body.read
66
+ data = JSON.parse body
67
+
68
+ response = case data['method']
69
+ when 'initialize'
70
+ handle_initialize data
71
+ when 'tools/list'
72
+ handle_tools_list data
73
+ when 'tools/call'
74
+ handle_tool_call data
75
+ else
76
+ error_response data['id'], JSON_RPC_METHOD_NOT_FOUND, 'Method not found'
77
+ end
78
+
79
+ [200, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
80
+
81
+ rescue JSON::ParserError => e
82
+ response = error_response nil, JSON_RPC_PARSE_ERROR, e.message
83
+ [400, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
84
+
85
+ rescue => e
86
+ response = error_response nil, JSON_RPC_INTERNAL_ERROR, e.message
87
+ [500, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
88
+ end
89
+
90
+ def handle_initialize(data)
91
+ {
92
+ jsonrpc: JSON_RPC_VERSION,
93
+ id: data['id'],
94
+ result: {
95
+ protocolVersion: PROTOCOL_VERSION,
96
+ capabilities: {
97
+ tools: {
98
+ list: true,
99
+ call: true
100
+ }
101
+ },
102
+ serverInfo: {
103
+ name: self.class.server_name,
104
+ version: self.class.server_version
105
+ }
106
+ }
107
+ }
108
+ end
109
+
110
+ def handle_tools_list(data)
111
+ {
112
+ jsonrpc: JSON_RPC_VERSION,
113
+ id: data['id'],
114
+ result: {
115
+ tools: self.class.tools_serializations
116
+ }
117
+ }
118
+ end
119
+
120
+ def handle_tool_call(data)
121
+ tool_name = data['params']['name']
122
+ arguments = data['params']['arguments'] || {}
123
+
124
+ result = self.class.call_tool tool_name, arguments
125
+
126
+ {
127
+ jsonrpc: JSON_RPC_VERSION,
128
+ id: data['id'],
129
+ result: {
130
+ content: [
131
+ {
132
+ type: 'text',
133
+ text: result
134
+ }
135
+ ]
136
+ }
137
+ }
138
+ rescue => e
139
+ error_response data['id'], JSON_RPC_INTERNAL_ERROR, e.message
140
+ end
141
+
142
+ def error_response(id, code, message)
143
+ {
144
+ jsonrpc: JSON_RPC_VERSION,
145
+ id: id,
146
+ error: {
147
+ code: code,
148
+ message: message
149
+ }
150
+ }
151
+ end
152
+
153
+ end
154
+ end
155
+ end
156
+ end
@@ -5,8 +5,9 @@ module Rasti
5
5
 
6
6
  attr_reader :state
7
7
 
8
- def initialize(client:nil, state:nil, model:nil, tools:[], logger:nil)
8
+ def initialize(client:nil, json_schema:nil, state:nil, model:nil, tools:[], mcp_servers:{}, logger:nil)
9
9
  @client = client || Client.new
10
+ @json_schema = json_schema
10
11
  @state = state || AssistantState.new
11
12
  @model = model
12
13
  @tools = {}
@@ -14,10 +15,20 @@ module Rasti
14
15
  @logger = logger || Rasti::AI.logger
15
16
 
16
17
  tools.each do |tool|
17
- serialization = ToolSerializer.serialize tool.class
18
+ serialization = serialize_tool tool
18
19
  @tools[serialization[:function][:name]] = tool
19
20
  @serialized_tools << serialization
20
21
  end
22
+
23
+ mcp_servers.each do |name, mcp|
24
+ mcp.list_tools.each do |tool|
25
+ serialization = wrap_tool_serialization tool.merge('name' => "#{name}_#{tool['name']}")
26
+ @tools["#{name}_#{tool['name']}"] = ->(args) do
27
+ mcp.call_tool tool['name'], args
28
+ end
29
+ @serialized_tools << serialization
30
+ end
31
+ end
21
32
  end
22
33
 
23
34
  def call(prompt)
@@ -29,7 +40,8 @@ module Rasti
29
40
  loop do
30
41
  response = client.chat_completions messages: messages,
31
42
  model: model,
32
- tools: serialized_tools
43
+ tools: serialized_tools,
44
+ response_format: response_format
33
45
 
34
46
  choice = response['choices'][0]['message']
35
47
 
@@ -64,12 +76,24 @@ module Rasti
64
76
 
65
77
  private
66
78
 
67
- attr_reader :client, :model, :tools, :serialized_tools, :logger
79
+ attr_reader :client, :json_schema, :model, :tools, :serialized_tools, :logger
68
80
 
69
81
  def messages
70
82
  state.messages
71
83
  end
72
84
 
85
+ def serialize_tool(tool)
86
+ serialization = ToolSerializer.serialize tool.class
87
+ wrap_tool_serialization serialization
88
+ end
89
+
90
+ def wrap_tool_serialization(serialized_tool)
91
+ {
92
+ type: 'function',
93
+ function: serialized_tool
94
+ }
95
+ end
96
+
73
97
  def call_tool(name, args)
74
98
  raise Errors::UndefinedTool.new(name) unless tools.key? name
75
99
 
@@ -90,6 +114,15 @@ module Rasti
90
114
  "Error: #{ex.message}"
91
115
  end
92
116
 
117
+ def response_format
118
+ return nil if json_schema.nil?
119
+
120
+ {
121
+ type: 'json_schema',
122
+ json_schema: json_schema
123
+ }
124
+ end
125
+
93
126
  end
94
127
  end
95
128
  end
@@ -0,0 +1,23 @@
1
+ module Rasti
2
+ module AI
3
+ class Tool
4
+
5
+ def self.form
6
+ constants.include?(:Form) ? const_get(:Form) : Rasti::Form
7
+ end
8
+
9
+ def call(params={})
10
+ form = self.class.form.new params
11
+ result = execute form
12
+ serialize result
13
+ end
14
+
15
+ private
16
+
17
+ def serialize(result)
18
+ JSON.dump result
19
+ end
20
+
21
+ end
22
+ end
23
+ end
@@ -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.0.1'
3
+ VERSION = '1.2.0'
4
4
  end
5
5
  end
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'