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.
@@ -4,13 +4,14 @@ module Rasti
4
4
  class Client
5
5
 
6
6
  def initialize(url:, allowed_tools:nil, logger:nil)
7
- @url = url
7
+ @url = url
8
8
  @allowed_tools = allowed_tools
9
- @logger = logger || Rasti::AI.logger
9
+ @logger = logger || Rasti::AI.logger
10
+ @session_id = nil
10
11
  end
11
12
 
12
13
  def list_tools
13
- result = request_mcp 'tools/list'
14
+ result = request_with_session 'tools/list'
14
15
  tools = result['tools']
15
16
  if allowed_tools
16
17
  tools.select { |tool| allowed_tools.include? tool['name'] }
@@ -21,15 +22,55 @@ module Rasti
21
22
 
22
23
  def call_tool(name, arguments={})
23
24
  raise "Invalid tool: #{name}" if allowed_tools && !allowed_tools.include?(name)
24
- result = request_mcp 'tools/call', name: name, arguments: arguments
25
+ result = request_with_session 'tools/call', name: name, arguments: arguments
25
26
  JSON.dump result['content'][0]
26
27
  end
27
28
 
28
29
  private
29
30
 
30
- attr_reader :url, :allowed_tools, :logger
31
+ attr_reader :url, :allowed_tools, :logger, :session_id
32
+
33
+ def request_with_session(method, params={})
34
+ request_mcp method, params
35
+ rescue RuntimeError => e
36
+ raise unless e.message =~ /session/i && session_id.nil?
37
+ initialize_session
38
+ request_mcp method, params
39
+ end
40
+
41
+ def initialize_session
42
+ _result, response = request_mcp_raw 'initialize',
43
+ protocolVersion: PROTOCOL_VERSION,
44
+ capabilities: {},
45
+ clientInfo: {name: 'rasti-ai', version: Rasti::AI::VERSION}
46
+
47
+ @session_id = response['mcp-session-id']
48
+
49
+ send_notification 'notifications/initialized' if session_id
50
+ end
51
+
52
+ def send_notification(method)
53
+ uri = URI.parse url
54
+ http = Net::HTTP.new uri.host, uri.port
55
+ http.use_ssl = uri.scheme == 'https'
56
+
57
+ req = Net::HTTP::Post.new uri.path
58
+ req['Content-Type'] = 'application/json'
59
+ req['Accept'] = 'application/json, text/event-stream'
60
+ req['Mcp-Session-Id'] = session_id if session_id
61
+ req.body = JSON.dump(jsonrpc: JSON_RPC_VERSION, method: method)
62
+
63
+ http.request req
64
+ rescue => e
65
+ logger.warn(self.class) { "Notification failed: #{e.message}" }
66
+ end
31
67
 
32
68
  def request_mcp(method, params={})
69
+ result, _response = request_mcp_raw method, params
70
+ result
71
+ end
72
+
73
+ def request_mcp_raw(method, params={})
33
74
  uri = URI.parse url
34
75
 
35
76
  http = Net::HTTP.new uri.host, uri.port
@@ -38,9 +79,12 @@ module Rasti
38
79
  request = Net::HTTP::Post.new uri.path
39
80
 
40
81
  request['Content-Type'] = 'application/json'
82
+ request['Accept'] = 'application/json, text/event-stream'
83
+ request['Mcp-Session-Id'] = session_id if session_id
41
84
 
42
85
  body = {
43
- jsonrpc: '2.0',
86
+ jsonrpc: JSON_RPC_VERSION,
87
+ id: 1,
44
88
  method: method,
45
89
  params: params
46
90
  }
@@ -48,18 +92,25 @@ module Rasti
48
92
 
49
93
  logger.info(self.class) { "POST #{url} -> #{method}" }
50
94
  logger.debug(self.class) { JSON.pretty_generate(params) }
51
-
95
+
52
96
  response = http.request request
53
97
 
54
98
  logger.info(self.class) { "Response #{response.code}" }
55
99
  logger.debug(self.class) { response.body }
56
100
 
57
- json = JSON.parse response.body
101
+ body_str = response.body
102
+
103
+ # Handle SSE format (text/event-stream)
104
+ if response['Content-Type']&.include?('text/event-stream')
105
+ body_str = body_str.scan(/^data:\s*(.+)$/).flatten.first || body_str
106
+ end
107
+
108
+ json = JSON.parse body_str
58
109
 
59
110
  raise "MCP Error: #{json['error']['message']}" if json['error']
60
111
  raise "MCP Error: invalid result" unless json['result']
61
112
 
62
- json['result']
113
+ [json['result'], response]
63
114
  end
64
115
 
65
116
  end
@@ -2,6 +2,9 @@ module Rasti
2
2
  module AI
3
3
  module MCP
4
4
 
5
+ JSON_RPC_VERSION = '2.0'.freeze
6
+ PROTOCOL_VERSION = '2024-11-05'.freeze
7
+
5
8
  # JSON-RPC oficiales
6
9
  JSON_RPC_PARSE_ERROR = -32700
7
10
  JSON_RPC_INVALID_REQUEST = -32600
@@ -26,4 +29,4 @@ module Rasti
26
29
 
27
30
  end
28
31
  end
29
- end
32
+ end
@@ -3,44 +3,22 @@ module Rasti
3
3
  module MCP
4
4
  class Server
5
5
 
6
- ToolSpecification = Rasti::Model[:tool, :serialization]
7
-
8
- PROTOCOL_VERSION = '2024-11-05'.freeze
9
- JSON_RPC_VERSION = '2.0'.freeze
10
-
11
6
  extend ClassConfig
12
7
 
13
- attr_config :server_name, 'MCP Server'
8
+ attr_config :server_name, 'MCP Server'
14
9
  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
10
+ attr_config :relative_path, '/mcp'
11
+ attr_config :tools_loader
12
+ attr_config :authenticator
28
13
 
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
14
+ class << self
15
+ def load_tools(&block)
16
+ self.tools_loader = block
36
17
  end
37
18
 
38
- private
39
-
40
- def tools
41
- @tools ||= {}
19
+ def authenticate(&block)
20
+ self.authenticator = block
42
21
  end
43
-
44
22
  end
45
23
 
46
24
  def initialize(app)
@@ -49,7 +27,7 @@ module Rasti
49
27
 
50
28
  def call(env)
51
29
  request = Rack::Request.new env
52
-
30
+
53
31
  if request.post? && request.path == self.class.relative_path
54
32
  handle_mcp_request request
55
33
  else
@@ -62,31 +40,48 @@ module Rasti
62
40
  attr_reader :app
63
41
 
64
42
  def handle_mcp_request(request)
43
+ if !authorized? request
44
+ response = error_response nil, JSON_RPC_SERVER_UNAUTHORIZED, 'Unauthorized'
45
+ return [401, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
46
+ end
47
+
65
48
  body = request.body.read
66
49
  data = JSON.parse body
67
-
50
+
51
+ tools_registry = build_tools_registry request
52
+
68
53
  response = case data['method']
69
54
  when 'initialize'
70
55
  handle_initialize data
71
56
  when 'tools/list'
72
- handle_tools_list data
57
+ handle_tools_list data, tools_registry
73
58
  when 'tools/call'
74
- handle_tool_call data
59
+ handle_tool_call data, tools_registry
75
60
  else
76
61
  error_response data['id'], JSON_RPC_METHOD_NOT_FOUND, 'Method not found'
77
62
  end
78
-
63
+
79
64
  [200, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
80
65
 
81
66
  rescue JSON::ParserError => e
82
67
  response = error_response nil, JSON_RPC_PARSE_ERROR, e.message
83
68
  [400, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
84
-
69
+
85
70
  rescue => e
86
71
  response = error_response nil, JSON_RPC_INTERNAL_ERROR, e.message
87
72
  [500, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
88
73
  end
89
-
74
+
75
+ def authorized?(request)
76
+ !self.class.authenticator || self.class.authenticator.call(request)
77
+ end
78
+
79
+ def build_tools_registry(request)
80
+ tools_registry = ToolsRegistry.new
81
+ self.class.tools_loader.call tools_registry, request if self.class.tools_loader
82
+ tools_registry
83
+ end
84
+
90
85
  def handle_initialize(data)
91
86
  {
92
87
  jsonrpc: JSON_RPC_VERSION,
@@ -106,23 +101,23 @@ module Rasti
106
101
  }
107
102
  }
108
103
  end
109
-
110
- def handle_tools_list(data)
104
+
105
+ def handle_tools_list(data, tools_registry)
111
106
  {
112
107
  jsonrpc: JSON_RPC_VERSION,
113
108
  id: data['id'],
114
109
  result: {
115
- tools: self.class.tools_serializations
110
+ tools: tools_registry.serializations
116
111
  }
117
112
  }
118
113
  end
119
-
120
- def handle_tool_call(data)
114
+
115
+ def handle_tool_call(data, tools_registry)
121
116
  tool_name = data['params']['name']
122
117
  arguments = data['params']['arguments'] || {}
123
118
 
124
- result = self.class.call_tool tool_name, arguments
125
-
119
+ result = tools_registry.call tool_name, arguments
120
+
126
121
  {
127
122
  jsonrpc: JSON_RPC_VERSION,
128
123
  id: data['id'],
@@ -138,7 +133,7 @@ module Rasti
138
133
  rescue => e
139
134
  error_response data['id'], JSON_RPC_INTERNAL_ERROR, e.message
140
135
  end
141
-
136
+
142
137
  def error_response(id, code, message)
143
138
  {
144
139
  jsonrpc: JSON_RPC_VERSION,
@@ -153,4 +148,4 @@ module Rasti
153
148
  end
154
149
  end
155
150
  end
156
- end
151
+ end
@@ -0,0 +1,64 @@
1
+ module Rasti
2
+ module AI
3
+ module MCP
4
+ class ToolsRegistry
5
+
6
+ Entry = Rasti::Model[:serialization, :executor]
7
+
8
+ def initialize
9
+ @entries = {}
10
+ end
11
+
12
+ def register(tool: nil, name: nil, description: nil, form: nil, input_schema: nil, &block)
13
+ resolved_name = resolve_name tool, name
14
+ resolved_description = resolve_description tool, description
15
+ resolved_schema = resolve_schema tool, form, input_schema
16
+ resolved_executor = resolve_executor(tool, resolved_name, &block)
17
+
18
+ serialization = {name: resolved_name}
19
+ serialization[:description] = resolved_description if resolved_description
20
+ serialization[:inputSchema] = resolved_schema if resolved_schema
21
+
22
+ entries[resolved_name] = Entry.new serialization: serialization, executor: resolved_executor
23
+ end
24
+
25
+ def serializations
26
+ entries.values.map(&:serialization)
27
+ end
28
+
29
+ def call(name, args={})
30
+ raise "Tool #{name} not found" unless entries.key? name
31
+ entries[name].executor.call args
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :entries
37
+
38
+ def resolve_name(tool, name)
39
+ return name if name
40
+ return ToolSerializer.serialize_name tool.class if tool
41
+ raise ArgumentError, 'name is required'
42
+ end
43
+
44
+ def resolve_description(tool, description)
45
+ return description if description
46
+ tool.class.description if tool && tool.class.respond_to?(:description)
47
+ end
48
+
49
+ def resolve_schema(tool, form, input_schema)
50
+ return input_schema if input_schema
51
+ return ToolSerializer.serialize_form form if form
52
+ ToolSerializer.serialize_form tool.class.form if tool && tool.class.respond_to?(:form)
53
+ end
54
+
55
+ def resolve_executor(tool, name, &block)
56
+ return block if block
57
+ return tool.method :call if tool
58
+ raise ArgumentError, "executor required: provide a tool or a block for '#{name}'"
59
+ end
60
+
61
+ end
62
+ end
63
+ end
64
+ end
@@ -33,10 +33,15 @@ module Rasti
33
33
  messages
34
34
  end
35
35
 
36
- client.chat_completions messages: msgs,
37
- model: model,
38
- tools: serialized_tools,
39
- response_format: response_format
36
+ client.chat_completions messages: msgs,
37
+ model: model,
38
+ tools: serialized_tools,
39
+ response_format: response_format,
40
+ reasoning_effort: thinking_config
41
+ end
42
+
43
+ def thinking_config
44
+ thinking
40
45
  end
41
46
 
42
47
  def parse_tool_calls(response)
@@ -3,7 +3,7 @@ module Rasti
3
3
  module OpenAI
4
4
  class Client < Rasti::AI::Client
5
5
 
6
- def chat_completions(messages:, model:nil, tools:[], response_format:nil)
6
+ def chat_completions(messages:, model:nil, tools:[], response_format:nil, reasoning_effort:nil)
7
7
  body = {
8
8
  model: model || Rasti::AI.openai_default_model,
9
9
  messages: messages,
@@ -11,7 +11,8 @@ module Rasti
11
11
  tool_choice: tools.empty? ? 'none' : 'auto'
12
12
  }
13
13
 
14
- body[:response_format] = response_format unless response_format.nil?
14
+ body[:response_format] = response_format unless response_format.nil?
15
+ body[:reasoning_effort] = reasoning_effort if reasoning_effort
15
16
 
16
17
  post '/chat/completions', body
17
18
  end
@@ -9,50 +9,47 @@ module Rasti
9
9
  }
10
10
 
11
11
  serialization[:description] = normalize_description(tool_class.description) if tool_class.respond_to? :description
12
-
13
12
  serialization[:inputSchema] = serialize_form(tool_class.form) if tool_class.respond_to? :form
14
13
 
15
14
  serialization
16
-
15
+
17
16
  rescue => ex
18
17
  raise Errors::ToolSerializationError.new(tool_class), cause: ex
19
18
  end
20
19
 
21
- private
22
-
23
20
  def serialize_name(tool_class)
24
21
  Inflecto.underscore Inflecto.demodulize(tool_class.name)
25
22
  end
26
23
 
27
24
  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
25
+ json_schema_from_model_schema form_class.to_schema
26
+ end
31
27
 
32
- serialization = {
33
- type: 'object',
34
- properties: serialized_attributes
35
- }
28
+ private
36
29
 
37
- required_attributes = form_class.attributes.select { |a| a.option(:required) }
30
+ def json_schema_from_model_schema(schema)
31
+ properties = schema[:attributes].each_with_object({}) do |attribute, hash|
32
+ hash[attribute[:name]] = json_schema_for_attribute(attribute)
33
+ end
38
34
 
39
- serialization[:required] = required_attributes.map(&:name) unless required_attributes.empty?
35
+ result = {type: 'object', properties: properties}
40
36
 
41
- serialization
37
+ required = schema[:attributes].select { |a| (a[:options] || {})[:required] }.map { |a| a[:name] }
38
+ result[:required] = required unless required.empty?
39
+
40
+ result
42
41
  end
43
42
 
44
- def serialize_attribute(attribute)
43
+ def json_schema_for_attribute(attribute)
45
44
  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
45
 
54
- if attribute.type.is_a? Types::Enum
55
- values = "#{type_serialization[:enum].join(', ')}"
46
+ description = (attribute[:options] || {})[:description]
47
+ serialization[:description] = normalize_description(description) if description
48
+
49
+ serialization.merge! json_schema_for_type(attribute)
50
+
51
+ if attribute[:type] == :enum
52
+ values = attribute[:values].join(', ')
56
53
  if serialization[:description]
57
54
  serialization[:description] += " (#{values})"
58
55
  else
@@ -63,50 +60,26 @@ module Rasti
63
60
  serialization
64
61
  end
65
62
 
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}"
63
+ def json_schema_for_type(type_hash)
64
+ case type_hash[:type]
65
+ when :string, :symbol then {type: 'string'}
66
+ when :integer then {type: 'integer'}
67
+ when :float then {type: 'number'}
68
+ when :boolean then {type: 'boolean'}
69
+ when :time then {type: 'string', format: 'date'}
70
+ when :enum then {type: 'string', enum: type_hash[:values]}
71
+ when :array then {type: 'array', items: json_schema_for_type(type_hash[:items])}
72
+ when :model then json_schema_from_model_schema(type_hash[:schema])
73
+ when :hash then {type: 'object'}
74
+ else {}
102
75
  end
103
76
  end
104
77
 
105
78
  def normalize_description(description)
106
79
  description.split("\n").map(&:strip).join(' ').strip
107
80
  end
108
-
81
+
109
82
  end
110
83
  end
111
84
  end
112
- end
85
+ end
@@ -1,5 +1,5 @@
1
1
  module Rasti
2
2
  module AI
3
- VERSION = '2.0.2'
3
+ VERSION = '3.0.0'
4
4
  end
5
5
  end
data/lib/rasti/ai.rb CHANGED
@@ -14,6 +14,13 @@ module Rasti
14
14
  extend MultiRequire
15
15
  extend ClassConfig
16
16
 
17
+ require_relative 'ai/errors'
18
+ require_relative 'ai/usage'
19
+ require_relative 'ai/assistant_state'
20
+ require_relative 'ai/tool'
21
+ require_relative 'ai/tool_serializer'
22
+ require_relative 'ai/client'
23
+ require_relative 'ai/assistant'
17
24
  require_relative_pattern 'ai/**/*'
18
25
 
19
26
  attr_config :logger, Logger.new(STDOUT)
@@ -28,6 +35,9 @@ module Rasti
28
35
  attr_config :gemini_api_key, ENV['GEMINI_API_KEY']
29
36
  attr_config :gemini_default_model, ENV['GEMINI_DEFAULT_MODEL']
30
37
 
38
+ attr_config :anthropic_api_key, ENV['ANTHROPIC_API_KEY']
39
+ attr_config :anthropic_default_model, ENV['ANTHROPIC_DEFAULT_MODEL']
40
+
31
41
  attr_config :usage_tracker, nil
32
42
 
33
43
  end
data/rasti-ai.gemspec CHANGED
@@ -13,6 +13,8 @@ Gem::Specification.new do |spec|
13
13
  spec.homepage = 'https://github.com/gabynaiman/rasti-ai'
14
14
  spec.license = 'MIT'
15
15
 
16
+ spec.required_ruby_version = '>= 2.3'
17
+
16
18
  spec.files = `git ls-files -z`.split("\x0")
17
19
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
20
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
@@ -25,12 +27,13 @@ Gem::Specification.new do |spec|
25
27
  spec.add_runtime_dependency 'class_config', '~> 0.0'
26
28
 
27
29
  spec.add_development_dependency 'rake', '~> 12.0'
30
+ spec.add_development_dependency 'rack', '>= 1.3', '< 3'
28
31
  spec.add_development_dependency 'rack-test', '~> 2.0'
29
32
  spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
30
33
  spec.add_development_dependency 'minitest-colorin', '~> 0.1'
31
34
  spec.add_development_dependency 'minitest-line', '~> 0.6'
32
35
  spec.add_development_dependency 'minitest-extended_assertions', '~> 1.0'
33
36
  spec.add_development_dependency 'simplecov', '~> 0.12'
34
- spec.add_development_dependency 'pry-nav', '~> 0.2'
37
+ spec.add_development_dependency 'pry-nav', '>= 0.2'
35
38
  spec.add_development_dependency 'webmock', '~> 3.0'
36
39
  end