omni_agent 0.1.8 → 0.1.10

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: ea0324a20bc12e064b994b4fcdf7af700cd0280ba7b0609dc75724717e9fc881
4
- data.tar.gz: dfda03e711340e59dfbb1cd803bc785a51bbe39d972992c6a26fb5b2d4d8761a
3
+ metadata.gz: f8a84d4b38e835bc00b73a88bc233932198498112e3837e33fdfdf3dbabab981
4
+ data.tar.gz: 6d5cd7709dc61be01ae892a67732b08ee3e17a6d4af7d81f8640f0fd618010e8
5
5
  SHA512:
6
- metadata.gz: 72739c339838d0f38c172455226ff79244ab7e739920ec9e7b89f16182d220a1526886bff1f85b7b943bc6426676d5cc4dbd727f903c612df401587fc627f3f0
7
- data.tar.gz: b90d265c701efaf90d5d5f5e5f9aac3b337095e998d413bf5e872e05d794a9793a44e0bd669425d30ee13eabd50ded2f0fdc2c19cadbc1ff976ec02d0e8d888f
6
+ metadata.gz: b56c77174212b7a4ba2e45b2b3bb6e0f384631fcdd2bcbf30e2a32c8edb1617b239e8bdd482dbb259baf48746b20c38d29fd325685262337e38ab1e9669a5220
7
+ data.tar.gz: 5fcb5179527eda96d3dc6726ad78c73b1bba4b3ef7c34825cbfec03c2437e7018647b43cf9f9d43791599efdc628530d5c6cfefcb6f431bd7548a97f7c9a8365
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.10](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.9...omni_agent/v0.1.10) (2026-10-09)
6
+
7
+
8
+ ### Features
9
+
10
+ * **mcp:** expose tools and agents as MCP servers ([#40](https://github.com/ACR1209/omni_agent/issues/40)) ([db8dcb8](https://github.com/ACR1209/omni_agent/commit/db8dcb8776cc0baaed45112b61bc329f03d32181))
11
+
12
+ ## [0.1.9](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.8...omni_agent/v0.1.9) (2026-07-12)
13
+
14
+
15
+ ### Features
16
+
17
+ * **agent:** support streaming responses via stream sink ([7cf1fba](https://github.com/ACR1209/omni_agent/commit/7cf1fba4fa378c5cee7f464354f760506a0ff513))
18
+ * **docs:** add enum type for temperature unit in input schema ([d992658](https://github.com/ACR1209/omni_agent/commit/d99265836307ce8f863b060eff73c7acdbc8d2ff))
19
+ * **providers:** add Ollama provider via OpenAI-compat endpoint ([4ae92ad](https://github.com/ACR1209/omni_agent/commit/4ae92ad0f549119b43c2e8a7381f4f7245aa2f68))
20
+ * **tool:** add optional constraint validations and custom validate procs ([db8371a](https://github.com/ACR1209/omni_agent/commit/db8371a189f710e812c3c3ce15f200df1ba94902))
21
+ * **tool:** add polymorphic reference datatype ([1686bbb](https://github.com/ACR1209/omni_agent/commit/1686bbbf41932622a5537c335da8c41c68a73f73))
22
+ * **tool:** infer and validate enum value types in schema builder ([ceb3e78](https://github.com/ACR1209/omni_agent/commit/ceb3e780498caa1158e9fc8af3eb6e9b95c33ddb))
23
+ * **tool:** validate required and enum arguments on invoke ([543c6e0](https://github.com/ACR1209/omni_agent/commit/543c6e08f9c394b84c73bc5957aff7058e0fab19))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **lint:** add space inside empty block braces ([9116b3f](https://github.com/ACR1209/omni_agent/commit/9116b3ff82e9fab9824347515b4c1cd40a40785e))
29
+ * **spec:** redact OpenAI API key from recorded VCR cassettes ([53cecd5](https://github.com/ACR1209/omni_agent/commit/53cecd540b6dd52ddc5bf3da0550836fbfcfab88))
30
+
5
31
  ## [0.1.8](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.7...omni_agent/v0.1.8) (2026-07-07)
6
32
 
7
33
 
data/README.md CHANGED
@@ -11,7 +11,8 @@ tool schemas, and generation lifecycle callbacks.
11
11
  - Prompt composition from ERB files in `app/agents/<agent_name>/`
12
12
  - Agent callbacks (`before_generation`, `after_generation`)
13
13
  - Agent and tool tags to support filtering strategies
14
- - OpenAI provider integration out of the box
14
+ - OpenAI provider integration out of the box, plus an Ollama provider for local models
15
+ - MCP server support: expose tools and whole agents to Claude Code, Claude Desktop, Cursor and other MCP clients
15
16
  - Rake tasks and Rails generators for scaffolding
16
17
 
17
18
  ## Installation
@@ -27,6 +28,8 @@ Add the provider you're using to the Gemfile as well:
27
28
  gem "openai"
28
29
  ```
29
30
 
31
+ The `ollama` provider also depends on the `openai` gem — it talks to Ollama's OpenAI-compatible endpoint, so no separate gem is needed.
32
+
30
33
  Then run:
31
34
 
32
35
  ```bash
@@ -53,6 +56,14 @@ bundle exec rails generate omni_agent:agent ResearchAgent --model gpt-4.1-mini -
53
56
  OPENAI_ACCESS_TOKEN=your_api_key_here
54
57
  ```
55
58
 
59
+ To use Ollama instead, set `config.default_provider = :ollama` (see below) and, if needed, override its endpoint/model:
60
+
61
+ ```dotenv
62
+ OLLAMA_HOST=http://localhost:11434 # defaults to this
63
+ OLLAMA_MODEL=llama3.1 # defaults to this; pick a tool-calling-capable model
64
+ OLLAMA_API_KEY=ollama # unused by Ollama, but required by the openai client
65
+ ```
66
+
56
67
  4. Implement your agent prompt and optional tools under:
57
68
 
58
69
  ```text
@@ -129,6 +140,23 @@ class SupervisorAgent < OmniAgent::Agent
129
140
  end
130
141
  ```
131
142
 
143
+ ## Streaming
144
+
145
+ Prefix any run entrypoint with `.stream` and pass a block to receive the response as it's generated instead of waiting for the full result:
146
+
147
+ ```ruby
148
+ ResearchAgent.with(user_id: 42).stream.run("What's new?") do |event|
149
+ case event.type
150
+ when :text then print event.text
151
+ when :tool_call then puts "\n[using #{event.tool_name}...]"
152
+ when :tool_result then puts "[#{event.error? ? "failed" : "done"}]"
153
+ when :done then puts "\n---"
154
+ end
155
+ end
156
+ ```
157
+
158
+ `.stream` must come before the call (`.stream.run(...)`, not `.run(...).stream`) — without it, or without a block, behavior is unchanged and the same `Response` is returned either way. Only the `openai`, `ollama`, and `mock` providers stream today. See [Streaming Responses](omniagent-docs/docs/agent/streaming.mdx) for the full event reference.
159
+
132
160
  ## Evals
133
161
 
134
162
  `OmniAgent::Eval` lets you test agent quality: deterministic assertions (tool calls, output matching) and pluggable LLM-as-judge scoring.
@@ -213,6 +241,51 @@ There's also an equivalent `rake omni_agent:eval` task (`rake "omni_agent:eval[p
213
241
 
214
242
  Calls real LLM providers (cost, non-determinism) — deliberately **not** part of `bundle exec rspec` or CI.
215
243
 
244
+ ## MCP Servers
245
+
246
+ Expose your tools (and whole agents) to MCP clients such as Claude Code, Claude Desktop and Cursor. The protocol is implemented natively; no extra gem is needed.
247
+
248
+ ```bash
249
+ rails generate omni_agent:mcp_server Support --with-tools LookupOrder --agents SupportAgent
250
+ ```
251
+
252
+ ```ruby
253
+ # app/mcp_servers/support_server.rb
254
+ class SupportServer < OmniAgent::MCP::Server
255
+ instructions "Tools for the support team."
256
+
257
+ # Tools in app/mcp_servers/support_server/tools/ (SupportServer::Tools::*) are added automatically.
258
+ # Reuse existing tools and agents too:
259
+ tools ResearchAgent::Tools::GetWeather
260
+ tools_from SupportAgent
261
+ expose_agent ResearchAgent, as: :research, description: "Ask the research agent", forward: [ :current_user ]
262
+
263
+ authenticate :bearer, tokens: -> { ENV.fetch("SUPPORT_MCP_TOKENS", "").split(",") }
264
+ # or: authenticate { |request| User.find_by(api_token: request.bearer_token) }
265
+
266
+ authorize_tool { |tool_class, principal| !tool_class.tags.include?(:admin) }
267
+ context { |principal, _request| { current_user: principal } }
268
+ end
269
+ ```
270
+
271
+ ```ruby
272
+ # config/routes.rb -- Streamable HTTP (stateless, POST only)
273
+ mcp_server :support # SupportServer at /mcp/support
274
+ mcp_server :billing, path: "/internal/mcp"
275
+ # or mount every server in app/mcp_servers at /mcp/<name>:
276
+ mcp_servers
277
+ ```
278
+
279
+ ```bash
280
+ # stdio, for local clients
281
+ bundle exec omni_agent mcp SupportServer
282
+
283
+ claude mcp add --transport http support http://localhost:3000/mcp/support --header "Authorization: Bearer $TOKEN"
284
+ claude mcp add support -- bundle exec omni_agent mcp SupportServer
285
+ ```
286
+
287
+ Tools can declare MCP-only hints with `title "Get weather"` and `annotations read_only: true, destructive: false, idempotent: true, open_world: true`. HTTP servers must declare `authenticate` (use `authenticate :none` to opt out on purpose). Requests from browser origins that are not in `allowed_origins` / `config.mcp_allowed_origins` are rejected.
288
+
216
289
  ## Configuration
217
290
 
218
291
  Global defaults can be configured through `OmniAgent.configure`:
data/exe/omni_agent CHANGED
@@ -5,10 +5,21 @@ require "optparse"
5
5
 
6
6
  options = { fresh: false }
7
7
  parser = OptionParser.new do |opts|
8
- opts.banner = "Usage: omni_agent eval [pattern] [options]"
8
+ opts.banner = <<~BANNER
9
+ Usage: omni_agent eval [pattern] [options]
10
+ omni_agent mcp <ServerClass>
11
+ BANNER
9
12
  opts.on("--fresh", "Clear the eval cache before running") { options[:fresh] = true }
10
13
  end
11
14
 
15
+ def require_rails_environment(command)
16
+ environment_file = File.expand_path("config/environment", Dir.pwd)
17
+ unless File.exist?("#{environment_file}.rb")
18
+ abort "Could not find config/environment.rb -- run `omni_agent #{command}` from your Rails app root."
19
+ end
20
+ require environment_file
21
+ end
22
+
12
23
  command = ARGV.shift
13
24
 
14
25
  case command
@@ -16,11 +27,7 @@ when "eval"
16
27
  parser.parse!(ARGV)
17
28
  pattern = ARGV.shift
18
29
 
19
- environment_file = File.expand_path("config/environment", Dir.pwd)
20
- unless File.exist?("#{environment_file}.rb")
21
- abort "Could not find config/environment.rb -- run `omni_agent eval` from your Rails app root."
22
- end
23
- require environment_file
30
+ require_rails_environment("eval")
24
31
 
25
32
  status = OmniAgent::Eval::CLI.run(pattern: pattern, fresh: options[:fresh])
26
33
 
@@ -32,6 +39,15 @@ when "eval"
32
39
  when :failed
33
40
  exit 1
34
41
  end
42
+ when "mcp"
43
+ server = ARGV.shift.to_s.strip
44
+ if server.empty?
45
+ warn "Missing server class. Example: omni_agent mcp SupportServer"
46
+ exit 1
47
+ end
48
+
49
+ require_rails_environment("mcp")
50
+ OmniAgent::MCP::Stdio.start(server)
35
51
  when nil
36
52
  warn parser.banner
37
53
  exit 1
@@ -7,6 +7,7 @@ module OmniAgent
7
7
  class_option :with_tools,
8
8
  type: :array,
9
9
  default: nil,
10
+ lazy_default: [],
10
11
  banner: "[Tool1 Tool2 ...]",
11
12
  desc: "Create tools folder and scaffold tools. Optionally pass tool names"
12
13
  class_option :model,
@@ -15,6 +15,7 @@ module OmniAgent
15
15
  def show_next_steps
16
16
  say "OmniAgent install complete."
17
17
  say "Next: rails generate omni_agent:agent SupportAgent --with-tools Tool1 Tool2"
18
+ say "Expose tools to MCP clients: rails generate omni_agent:mcp_server Support --agents SupportAgent"
18
19
  end
19
20
  end
20
21
  end
@@ -0,0 +1,151 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module OmniAgent
4
+ module Generators
5
+ class McpServerGenerator < Rails::Generators::NamedBase
6
+ desc "Creates an OmniAgent MCP server in app/mcp_servers and mounts it in config/routes.rb"
7
+
8
+ class_option :with_tools,
9
+ type: :array,
10
+ default: nil,
11
+ lazy_default: [],
12
+ banner: "[Tool1 Tool2 ...]",
13
+ desc: "Scaffold server-local tools in app/mcp_servers/<name>_server/tools. Optionally pass tool names"
14
+ class_option :tools,
15
+ type: :array,
16
+ default: [],
17
+ banner: "[ResearchAgent::Tools::GetWeather ...]",
18
+ desc: "Existing tool classes to expose"
19
+ class_option :agents,
20
+ type: :array,
21
+ default: [],
22
+ banner: "[ResearchAgent ...]",
23
+ desc: "Agents whose tools are all exposed (tools_from)"
24
+ class_option :path,
25
+ type: :string,
26
+ default: nil,
27
+ desc: "Mount path for the HTTP endpoint (default: /mcp/<name>)"
28
+
29
+ def create_server_file
30
+ create_file(File.join("app", "mcp_servers", "#{server_file_name}.rb"), server_template)
31
+ end
32
+
33
+ def create_tools_if_requested
34
+ return if options[:with_tools].nil?
35
+
36
+ requested_tool_names.each do |tool_name|
37
+ create_file(File.join("app", "mcp_servers", server_file_name, "tools", "#{tool_name.underscore}.rb"), tool_template(tool_name))
38
+ end
39
+ end
40
+
41
+ def mount_route
42
+ if mounts_all_servers?
43
+ say_status :skip, "config/routes.rb already mounts every server with `mcp_servers`", :yellow
44
+ return
45
+ end
46
+
47
+ route_line = "mcp_server #{server_slug.include?('/') ? server_slug.inspect : ":#{server_slug}"}"
48
+ route_line += %(, path: "#{options[:path]}") if options[:path].present?
49
+ route route_line
50
+ end
51
+
52
+ def show_next_steps
53
+ say ""
54
+ say "MCP server #{server_class_name} created."
55
+ say ""
56
+ say "Set a token, start the app and connect Claude Code over HTTP:"
57
+ say " #{token_env_var}=change-me bin/rails server"
58
+ say " claude mcp add --transport http #{server_identifier} http://localhost:3000#{mount_path} --header \"Authorization: Bearer change-me\""
59
+ say ""
60
+ say "Or run it locally over stdio (no HTTP auth; see stdio_principal):"
61
+ say " claude mcp add #{server_identifier} -- bundle exec omni_agent mcp #{server_class_name}"
62
+ end
63
+
64
+ private
65
+
66
+ def server_class_name
67
+ class_name.end_with?("Server") ? class_name : "#{class_name}Server"
68
+ end
69
+
70
+ def server_file_name
71
+ server_class_name.underscore
72
+ end
73
+
74
+ def server_slug
75
+ OmniAgent::MCP::Routing.slug_for(server_class_name)
76
+ end
77
+
78
+ def server_identifier
79
+ server_slug.tr("/", "_")
80
+ end
81
+
82
+ def mount_path
83
+ options[:path].presence || "#{OmniAgent::MCP::Routing::DEFAULT_PATH_PREFIX}/#{server_slug.dasherize}"
84
+ end
85
+
86
+ def mounts_all_servers?
87
+ routes_file = File.join(destination_root, "config", "routes.rb")
88
+ File.exist?(routes_file) && File.read(routes_file).match?(/^\s*mcp_servers\s*(\(\s*\)|except:.*)?\s*$/)
89
+ end
90
+
91
+ def token_env_var
92
+ "#{server_identifier.upcase}_MCP_TOKENS"
93
+ end
94
+
95
+ def server_template
96
+ <<~RUBY
97
+ class #{server_class_name} < OmniAgent::MCP::Server
98
+ instructions "Tools exposed by #{server_class_name}."
99
+
100
+ #{tool_lines.map { |line| " #{line}" }.join("\n")}
101
+
102
+ # Comma-separated list of accepted bearer tokens.
103
+ authenticate :bearer, tokens: -> { ENV.fetch("#{token_env_var}", "").split(",") }
104
+
105
+ # Hide tools per principal:
106
+ # authorize_tool { |tool_class, principal| true }
107
+
108
+ # Extra values merged into each tool's `context`:
109
+ # context { |principal, request| { current_user: principal } }
110
+ end
111
+ RUBY
112
+ end
113
+
114
+ def requested_tool_names
115
+ names = Array(options[:with_tools]).map(&:strip).reject(&:empty?)
116
+ names.empty? ? [ "ExampleTool" ] : names.map(&:camelize)
117
+ end
118
+
119
+ def tool_template(tool_name)
120
+ <<~RUBY
121
+ module #{server_class_name}::Tools
122
+ class #{tool_name} < OmniAgent::Tool
123
+ description "#{tool_name} generated by omni_agent:mcp_server"
124
+ annotations read_only: true
125
+
126
+ input do
127
+ string :input_text, description: "Text input"
128
+ end
129
+
130
+ def execute(input_text:)
131
+ "Echo: \#{input_text}"
132
+ end
133
+ end
134
+ end
135
+ RUBY
136
+ end
137
+
138
+ def tool_lines
139
+ tools = Array(options[:tools]).map(&:strip).reject(&:empty?)
140
+ agents = Array(options[:agents]).map(&:strip).reject(&:empty?)
141
+
142
+ lines = []
143
+ lines << "tools #{tools.join(', ')}" if tools.any?
144
+ lines.concat(agents.map { |agent| "tools_from #{agent}" })
145
+ lines.unshift("# Tools in app/mcp_servers/#{server_file_name}/tools/ are added automatically.")
146
+ lines << "# tools SomeAgent::Tools::SomeTool" if tools.empty? && agents.empty?
147
+ lines
148
+ end
149
+ end
150
+ end
151
+ end
@@ -20,7 +20,7 @@ module OmniAgent
20
20
  @_omni_agent_wrapping_method = true
21
21
  alias_method alias_name, method_name
22
22
 
23
- define_method(method_name) do |input = nil, context: {}, **context_keywords|
23
+ define_method(method_name) do |input = nil, context: {}, **context_keywords, &block|
24
24
  if input.nil? && context == {} && context_keywords.empty?
25
25
  run_alias_entrypoint_logic(alias_name)
26
26
  else
@@ -29,7 +29,7 @@ module OmniAgent
29
29
 
30
30
  run_input = run_alias_entrypoint_logic(alias_name, fallback_input: input)
31
31
 
32
- run(run_input, context: merged_context, prompt_method: method_name)
32
+ run(run_input, context: merged_context, prompt_method: method_name, &block)
33
33
  end
34
34
  end
35
35
  ensure
@@ -78,8 +78,8 @@ module OmniAgent
78
78
  aliases = normalize_callbacks(:run_aliases, method_names)
79
79
 
80
80
  aliases.each do |method_name|
81
- define_method(method_name) do |input, context: {}|
82
- run(input, context: context, prompt_method: method_name)
81
+ define_method(method_name) do |input, context: {}, &block|
82
+ run(input, context: context, prompt_method: method_name, &block)
83
83
  end
84
84
  end
85
85
  end
@@ -99,6 +99,38 @@ module OmniAgent
99
99
  @delegated_tool_classes || []
100
100
  end
101
101
 
102
+ def tool_classes
103
+ tool_namespace = "#{name}::Tools".safe_constantize
104
+
105
+ namespace_tools = if tool_namespace
106
+ tool_namespace.constants.filter_map do |const_name|
107
+ const = tool_namespace.const_get(const_name)
108
+ const if const.is_a?(Class) && const < OmniAgent::Tool
109
+ end
110
+ else
111
+ []
112
+ end
113
+
114
+ namespace_tools + configured_delegated_tool_classes
115
+ end
116
+
117
+ def build_delegated_tool_class(agent_class, description:, run_alias:, forward:)
118
+ tool_description = description || "Delegate to #{agent_class.name}."
119
+
120
+ Class.new(OmniAgent::Tool) do
121
+ description tool_description
122
+
123
+ input do
124
+ string :input, description: "Input/question to send to the delegated agent."
125
+ end
126
+
127
+ define_method(:execute) do |input:|
128
+ forwarded_context = OmniAgent::Agent.__send__(:filter_forwarded_context, context, forward)
129
+ OmniAgent::Agent.__send__(:run_delegated_agent, agent_class, input, run_alias, forwarded_context)
130
+ end
131
+ end
132
+ end
133
+
102
134
  def with(context = nil, provider_override: nil, model_override: nil, options_override: {}, **context_keywords)
103
135
  merged_context = {}
104
136
  merged_context.merge!(context) if context.is_a?(Hash)
@@ -135,23 +167,6 @@ module OmniAgent
135
167
  as.to_s.split(/[_\s]+/).reject(&:empty?).map { |part| part[0].upcase + part[1..] }.join
136
168
  end
137
169
 
138
- def build_delegated_tool_class(agent_class, description:, run_alias:, forward:)
139
- tool_description = description || "Delegate to #{agent_class.name}."
140
-
141
- Class.new(OmniAgent::Tool) do
142
- description tool_description
143
-
144
- input do
145
- string :input, description: "Input/question to send to the delegated agent."
146
- end
147
-
148
- define_method(:execute) do |input:|
149
- forwarded_context = OmniAgent::Agent.__send__(:filter_forwarded_context, context, forward)
150
- OmniAgent::Agent.__send__(:run_delegated_agent, agent_class, input, run_alias, forwarded_context)
151
- end
152
- end
153
- end
154
-
155
170
  def filter_forwarded_context(context, forward)
156
171
  return {} unless context.is_a?(Hash)
157
172
  return context.dup if forward == true
@@ -212,7 +227,11 @@ module OmniAgent
212
227
  @default_context = context_override || {}
213
228
  end
214
229
 
215
- def run(input, context: {}, prompt_method: nil)
230
+ def stream
231
+ OmniAgent::Streaming::Proxy.new(self)
232
+ end
233
+
234
+ def run(input, context: {}, prompt_method: nil, &stream)
216
235
  context = @default_context.merge(context || {})
217
236
  bind_context_instance_variables(context)
218
237
 
@@ -237,13 +256,14 @@ module OmniAgent
237
256
  "Increase OmniAgent.configuration.max_tool_iterations if more tool calls are expected."
238
257
  end
239
258
 
240
- response = provider.chat(messages: messages, tools: filtered_tools, **@chat_options)
259
+ response = provider.chat(messages: messages, tools: filtered_tools, stream: stream, **@chat_options)
241
260
 
242
261
  if response.content && !response.tool_calls?
243
262
  messages << { role: "assistant", content: response.content }
244
263
  set_after_generation_state(response: response, messages: messages, initial_messages_count: initial_messages_count)
245
264
  run_after_generation_callbacks(input: input, context: context, messages: messages, response: response)
246
265
  sync_context_from_instance_variables(context)
266
+ stream&.call(OmniAgent::Streaming::Event.done(response))
247
267
  return response
248
268
  end
249
269
 
@@ -265,6 +285,8 @@ module OmniAgent
265
285
  tool_instance = tool_class.new
266
286
  tool_instance.context = context if tool_instance.respond_to?(:context=)
267
287
 
288
+ stream&.call(OmniAgent::Streaming::Event.tool_call(name: tool_name, arguments: tool_args, id: tool_id))
289
+
268
290
  begin
269
291
  result = tool_instance.invoke(tool_args)
270
292
 
@@ -274,13 +296,16 @@ module OmniAgent
274
296
  name: tool_name,
275
297
  content: result.to_s
276
298
  }
299
+ stream&.call(OmniAgent::Streaming::Event.tool_result(name: tool_name, id: tool_id, content: result.to_s))
277
300
  rescue => e
301
+ error_content = "Error executing tool: #{e.message}"
278
302
  messages << {
279
303
  role: "tool",
280
304
  tool_call_id: tool_id,
281
305
  name: tool_name,
282
- content: "Error executing tool: #{e.message}"
306
+ content: error_content
283
307
  }
308
+ stream&.call(OmniAgent::Streaming::Event.tool_result(name: tool_name, id: tool_id, content: error_content, error: true))
284
309
  end
285
310
 
286
311
  should_stop_generation ||= tool_class.respond_to?(:stops_generation?) && tool_class.stops_generation?
@@ -299,24 +324,14 @@ module OmniAgent
299
324
  set_after_generation_state(response: response, messages: messages, initial_messages_count: initial_messages_count)
300
325
  run_after_generation_callbacks(input: input, context: context, messages: messages, response: response)
301
326
  sync_context_from_instance_variables(context)
327
+ stream&.call(OmniAgent::Streaming::Event.done(response))
302
328
  return response
303
329
  end
304
330
  end
305
331
  end
306
332
 
307
333
  def available_tools
308
- tool_namespace = "#{self.class.name}::Tools".safe_constantize
309
-
310
- namespace_tools = if tool_namespace
311
- tool_namespace.constants.filter_map do |const_name|
312
- const = tool_namespace.const_get(const_name)
313
- const if const.is_a?(Class) && const < OmniAgent::Tool
314
- end
315
- else
316
- []
317
- end
318
-
319
- namespace_tools + self.class.configured_delegated_tool_classes
334
+ self.class.tool_classes
320
335
  end
321
336
 
322
337
  private
@@ -1,7 +1,8 @@
1
1
  module OmniAgent
2
2
  class Configuration
3
3
  attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations,
4
- :max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path
4
+ :max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path,
5
+ :mcp_allowed_origins, :mcp_max_request_bytes
5
6
 
6
7
  def initialize
7
8
  @default_provider = :openai
@@ -14,6 +15,8 @@ module OmniAgent
14
15
  @eval_judge_model = nil
15
16
  @eval_cache_enabled = true
16
17
  @eval_cache_path = "tmp/omni_agent_eval_cache.json"
18
+ @mcp_allowed_origins = []
19
+ @mcp_max_request_bytes = 1_048_576
17
20
  end
18
21
  end
19
22
  end
@@ -1,5 +1,9 @@
1
1
  module OmniAgent
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace OmniAgent
4
+
5
+ initializer "omni_agent.mcp_routing" do
6
+ ActionDispatch::Routing::Mapper.include(OmniAgent::MCP::Routing)
7
+ end
4
8
  end
5
9
  end
@@ -6,6 +6,7 @@ module OmniAgent
6
6
  class MaxToolIterationsError < Error; end
7
7
  class MaxDelegationDepthError < Error; end
8
8
  class EvalAssertionError < Error; end
9
+ class MCPError < Error; end
9
10
  end
10
11
 
11
12
  Error = Errors::Error
@@ -14,4 +15,5 @@ module OmniAgent
14
15
  MaxToolIterationsError = Errors::MaxToolIterationsError
15
16
  MaxDelegationDepthError = Errors::MaxDelegationDepthError
16
17
  EvalAssertionError = Errors::EvalAssertionError
18
+ MCPError = Errors::MCPError
17
19
  end
@@ -0,0 +1,82 @@
1
+ require "active_support/security_utils"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ class Authenticator
6
+ Result = Struct.new(:authenticated, :principal) do
7
+ def authenticated?
8
+ authenticated == true
9
+ end
10
+ end
11
+
12
+ ARRAY_TOKEN_PRINCIPAL = :bearer_token
13
+
14
+ def initialize(server_class)
15
+ @server_class = server_class
16
+ end
17
+
18
+ def authenticate(request)
19
+ authentication = @server_class.configured_authentication
20
+
21
+ if authentication.nil?
22
+ raise OmniAgent::MCPError,
23
+ "#{@server_class.name} does not declare authentication. " \
24
+ "Add `authenticate :bearer, tokens: ...`, `authenticate { |request| ... }`, " \
25
+ "or explicitly opt out with `authenticate :none`."
26
+ end
27
+
28
+ case authentication[:strategy]
29
+ when :none
30
+ success(nil)
31
+ when :bearer
32
+ authenticate_bearer(request, authentication[:tokens])
33
+ when :custom
34
+ principal = authentication[:block].call(request)
35
+ principal ? success(principal) : failure
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def authenticate_bearer(request, tokens)
42
+ presented = request.bearer_token
43
+ return failure if presented.nil?
44
+
45
+ matched = false
46
+ principal = nil
47
+
48
+ # Compare against every configured token (no early exit) so response
49
+ # timing does not reveal which token, or how many tokens, matched.
50
+ token_candidates(tokens).each do |candidate_principal, token|
51
+ next if token.nil? || token.to_s.empty?
52
+
53
+ if ActiveSupport::SecurityUtils.secure_compare(token.to_s, presented) && !matched
54
+ matched = true
55
+ principal = candidate_principal
56
+ end
57
+ end
58
+
59
+ matched ? success(principal) : failure
60
+ end
61
+
62
+ def token_candidates(tokens)
63
+ tokens = tokens.call if tokens.respond_to?(:call)
64
+
65
+ case tokens
66
+ when Hash
67
+ tokens.map { |principal, token| [ principal, token ] }
68
+ else
69
+ Array(tokens).map { |token| [ ARRAY_TOKEN_PRINCIPAL, token ] }
70
+ end
71
+ end
72
+
73
+ def success(principal)
74
+ Result.new(true, principal)
75
+ end
76
+
77
+ def failure
78
+ Result.new(false, nil)
79
+ end
80
+ end
81
+ end
82
+ end