omni_agent 0.1.9 → 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: 66c390e526b0bd6176f8755538304d0e0e99d9cc36627be1ee9b63119b53ce34
4
- data.tar.gz: 4918452fbba6d30772abbf9d072d0c16cf363d5220a099183e9caa04d7481220
3
+ metadata.gz: f8a84d4b38e835bc00b73a88bc233932198498112e3837e33fdfdf3dbabab981
4
+ data.tar.gz: 6d5cd7709dc61be01ae892a67732b08ee3e17a6d4af7d81f8640f0fd618010e8
5
5
  SHA512:
6
- metadata.gz: 522e4be90a757a9056e95cf45f5aafb1f18d7b6914e1ca667e32b3774ab70422f552e333ac2e340c4d1dab1c628350e04d494a9726b908467eb53723ae300dd6
7
- data.tar.gz: c2c7939747237e554df0ac2569b84e36b164c1235f75fa876f04518cb644ad02579e18f4ee168c53ff55f32b75c8a58284ff921b217eda4b9148f67a8d5e4de7
6
+ metadata.gz: b56c77174212b7a4ba2e45b2b3bb6e0f384631fcdd2bcbf30e2a32c8edb1617b239e8bdd482dbb259baf48746b20c38d29fd325685262337e38ab1e9669a5220
7
+ data.tar.gz: 5fcb5179527eda96d3dc6726ad78c73b1bba4b3ef7c34825cbfec03c2437e7018647b43cf9f9d43791599efdc628530d5c6cfefcb6f431bd7548a97f7c9a8365
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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
+
5
12
  ## [0.1.9](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.8...omni_agent/v0.1.9) (2026-07-12)
6
13
 
7
14
 
data/README.md CHANGED
@@ -12,6 +12,7 @@ tool schemas, and generation lifecycle callbacks.
12
12
  - Agent callbacks (`before_generation`, `after_generation`)
13
13
  - Agent and tool tags to support filtering strategies
14
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
@@ -240,6 +241,51 @@ There's also an equivalent `rake omni_agent:eval` task (`rake "omni_agent:eval[p
240
241
 
241
242
  Calls real LLM providers (cost, non-determinism) — deliberately **not** part of `bundle exec rspec` or CI.
242
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
+
243
289
  ## Configuration
244
290
 
245
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
@@ -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
@@ -316,18 +331,7 @@ module OmniAgent
316
331
  end
317
332
 
318
333
  def available_tools
319
- tool_namespace = "#{self.class.name}::Tools".safe_constantize
320
-
321
- namespace_tools = if tool_namespace
322
- tool_namespace.constants.filter_map do |const_name|
323
- const = tool_namespace.const_get(const_name)
324
- const if const.is_a?(Class) && const < OmniAgent::Tool
325
- end
326
- else
327
- []
328
- end
329
-
330
- namespace_tools + self.class.configured_delegated_tool_classes
334
+ self.class.tool_classes
331
335
  end
332
336
 
333
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
@@ -0,0 +1,128 @@
1
+ module OmniAgent
2
+ module MCP
3
+ class Handler
4
+ class ProtocolError < StandardError
5
+ attr_reader :code
6
+
7
+ def initialize(code, message)
8
+ super(message)
9
+ @code = code
10
+ end
11
+ end
12
+
13
+ def initialize(server)
14
+ @server = server
15
+ end
16
+
17
+ # Takes a parsed JSON-RPC message and returns the response Hash, or nil
18
+ # when no response is due (notifications and client responses).
19
+ def call(message)
20
+ unless message.is_a?(Hash)
21
+ reason = message.is_a?(Array) ? "Batch requests are not supported" : "Invalid Request"
22
+ return error_response(nil, INVALID_REQUEST, reason)
23
+ end
24
+
25
+ message = message.transform_keys(&:to_s)
26
+ return nil if client_response?(message)
27
+
28
+ id = message["id"]
29
+ notification = !message.key?("id")
30
+
31
+ unless message["jsonrpc"] == JSONRPC_VERSION && message["method"].is_a?(String)
32
+ return notification ? nil : error_response(id, INVALID_REQUEST, "Invalid Request")
33
+ end
34
+
35
+ return nil if notification
36
+
37
+ result = dispatch(message["method"], message["params"])
38
+ success_response(id, result)
39
+ rescue ProtocolError => e
40
+ error_response(id, e.code, e.message)
41
+ rescue StandardError => e
42
+ log_error("MCP internal error", e)
43
+ error_response(id, INTERNAL_ERROR, "Internal error")
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :server
49
+
50
+ def dispatch(method, params)
51
+ params = normalize_params(params)
52
+
53
+ case method
54
+ when "initialize" then initialize_result(params)
55
+ when "ping" then {}
56
+ when "tools/list" then tools_list_result
57
+ when "tools/call" then tools_call_result(params)
58
+ else
59
+ raise ProtocolError.new(METHOD_NOT_FOUND, "Method not found: #{method}")
60
+ end
61
+ end
62
+
63
+ def normalize_params(params)
64
+ return {} if params.nil?
65
+ raise ProtocolError.new(INVALID_PARAMS, "params must be an object") unless params.is_a?(Hash)
66
+
67
+ params.transform_keys(&:to_s)
68
+ end
69
+
70
+ def initialize_result(params)
71
+ requested = params["protocolVersion"]
72
+ protocol_version = SUPPORTED_PROTOCOL_VERSIONS.include?(requested) ? requested : LATEST_PROTOCOL_VERSION
73
+ server_class = server.class
74
+
75
+ result = {
76
+ protocolVersion: protocol_version,
77
+ capabilities: { tools: { listChanged: false } },
78
+ serverInfo: { name: server_class.server_name, version: server_class.server_version }
79
+ }
80
+ result[:instructions] = server_class.instructions if server_class.instructions
81
+ result
82
+ end
83
+
84
+ def tools_list_result
85
+ tools = server.visible_tools.map { |name, tool_class| ToolAdapter.definition(name, tool_class) }
86
+ { tools: tools }
87
+ end
88
+
89
+ def tools_call_result(params)
90
+ name = params["name"]
91
+ raise ProtocolError.new(INVALID_PARAMS, "Missing tool name") unless name.is_a?(String)
92
+
93
+ tool_class = server.find_tool(name)
94
+ raise ProtocolError.new(INVALID_PARAMS, "Unknown tool: #{name}") unless tool_class
95
+
96
+ arguments = params["arguments"] || {}
97
+ raise ProtocolError.new(INVALID_PARAMS, "arguments must be an object") unless arguments.is_a?(Hash)
98
+
99
+ execute_tool(name, tool_class, arguments)
100
+ end
101
+
102
+ def execute_tool(name, tool_class, arguments)
103
+ tool = tool_class.new
104
+ tool.context = server.tool_context
105
+ ToolAdapter.result(tool.invoke(arguments))
106
+ rescue StandardError => e
107
+ log_error("MCP tool #{name} failed", e)
108
+ ToolAdapter.error_result("Error executing tool: #{e.message}")
109
+ end
110
+
111
+ def client_response?(message)
112
+ !message.key?("method") && (message.key?("result") || message.key?("error"))
113
+ end
114
+
115
+ def success_response(id, result)
116
+ { jsonrpc: JSONRPC_VERSION, id: id, result: result }
117
+ end
118
+
119
+ def error_response(id, code, message)
120
+ { jsonrpc: JSONRPC_VERSION, id: id, error: { code: code, message: message } }
121
+ end
122
+
123
+ def log_error(prefix, error)
124
+ MCP.logger&.error("[OmniAgent::MCP] #{prefix}: #{error.class}: #{error.message}")
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,107 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ # Streamable HTTP transport, stateless: every POST carries one JSON-RPC
6
+ # message and gets a plain JSON response. No SSE stream, no sessions.
7
+ class RackApp
8
+ JSON_HEADERS = { "content-type" => "application/json" }.freeze
9
+ PARSE_FAILED = Object.new.freeze
10
+
11
+ # Accepts the server class or its name. Passing a String defers the
12
+ # constant lookup to request time, which keeps routes reload-safe.
13
+ attr_reader :server
14
+
15
+ def initialize(server)
16
+ @server = server
17
+ end
18
+
19
+ def inspect
20
+ "#<#{self.class.name} #{server}>"
21
+ end
22
+
23
+ def call(env)
24
+ server_class = MCP.resolve_server_class(@server)
25
+ request = Request.new(env)
26
+
27
+ return forbidden unless origin_allowed?(request, server_class)
28
+
29
+ auth = Authenticator.new(server_class).authenticate(request)
30
+ return unauthorized(server_class) unless auth.authenticated?
31
+
32
+ return method_not_allowed unless request.post?
33
+
34
+ if request.protocol_version && !SUPPORTED_PROTOCOL_VERSIONS.include?(request.protocol_version)
35
+ return http_error(400, "Unsupported MCP-Protocol-Version: #{request.protocol_version}")
36
+ end
37
+
38
+ body = read_body(request)
39
+ return http_error(413, "Request body too large") if body.nil?
40
+
41
+ message = parse_json(body)
42
+ return json_response(400, parse_error_response) if message.equal?(PARSE_FAILED)
43
+
44
+ server = server_class.new(principal: auth.principal, request: request, transport: :http)
45
+ response = Handler.new(server).call(message)
46
+ return [ 202, {}, [] ] if response.nil?
47
+
48
+ status = response[:error] && response[:id].nil? ? 400 : 200
49
+ json_response(status, response)
50
+ end
51
+
52
+ private
53
+
54
+ def origin_allowed?(request, server_class)
55
+ origin = request.origin
56
+ return true if origin.nil? || origin.empty?
57
+
58
+ allowed = server_class.configured_allowed_origins + Array(OmniAgent.configuration.mcp_allowed_origins)
59
+ allowed.any? do |pattern|
60
+ pattern.is_a?(Regexp) ? pattern.match?(origin) : pattern.to_s == origin
61
+ end
62
+ end
63
+
64
+ def read_body(request)
65
+ max_bytes = OmniAgent.configuration.mcp_max_request_bytes
66
+ return nil if request.content_length.to_i > max_bytes
67
+
68
+ io = request.body
69
+ return "" if io.nil?
70
+
71
+ body = io.read(max_bytes + 1).to_s
72
+ body.bytesize > max_bytes ? nil : body
73
+ end
74
+
75
+ def parse_json(body)
76
+ JSON.parse(body)
77
+ rescue JSON::ParserError
78
+ PARSE_FAILED
79
+ end
80
+
81
+ def parse_error_response
82
+ { jsonrpc: JSONRPC_VERSION, id: nil, error: { code: PARSE_ERROR, message: "Parse error" } }
83
+ end
84
+
85
+ def json_response(status, payload)
86
+ [ status, JSON_HEADERS.dup, [ JSON.generate(payload) ] ]
87
+ end
88
+
89
+ def http_error(status, message)
90
+ json_response(status, { error: message })
91
+ end
92
+
93
+ def forbidden
94
+ http_error(403, "Origin not allowed")
95
+ end
96
+
97
+ def method_not_allowed
98
+ [ 405, JSON_HEADERS.merge("allow" => "POST"), [ JSON.generate({ error: "Method not allowed" }) ] ]
99
+ end
100
+
101
+ def unauthorized(server_class)
102
+ headers = JSON_HEADERS.merge("www-authenticate" => %(Bearer realm="#{server_class.server_name}"))
103
+ [ 401, headers, [ JSON.generate({ error: "Unauthorized" }) ] ]
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,24 @@
1
+ require "rack"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ class Request < Rack::Request
6
+ def bearer_token
7
+ header = get_header("HTTP_AUTHORIZATION").to_s
8
+ match = header.match(/\ABearer\s+(.+)\z/i)
9
+ return if match.nil?
10
+
11
+ token = match[1].strip
12
+ token.empty? ? nil : token
13
+ end
14
+
15
+ def protocol_version
16
+ get_header("HTTP_MCP_PROTOCOL_VERSION")
17
+ end
18
+
19
+ def origin
20
+ get_header("HTTP_ORIGIN")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ # Router helpers, included into ActionDispatch::Routing::Mapper by the engine.
6
+ #
7
+ # mcp_server :support # SupportServer at /mcp/support
8
+ # mcp_server :support, path: "/internal/mcp" # custom path
9
+ # mcp_servers :support, :billing # several at once
10
+ # mcp_servers # every app/mcp_servers/**/*_server.rb
11
+ module Routing
12
+ DEFAULT_PATH_PREFIX = "/mcp".freeze
13
+
14
+ def mcp_server(name, path: nil, server: nil, as: nil)
15
+ slug = OmniAgent::MCP::Routing.slug_for(name)
16
+ server_name = server.is_a?(Class) ? server.name : (server || "#{slug}_server".camelize).to_s
17
+
18
+ mount OmniAgent::MCP::RackApp.new(server_name) => (path || "#{DEFAULT_PATH_PREFIX}/#{slug.dasherize}"),
19
+ as: (as || "#{slug.tr('/', '_')}_mcp_server")
20
+ end
21
+
22
+ def mcp_servers(*names, except: [], directory: nil)
23
+ names = names.flatten
24
+ names = OmniAgent::MCP::Routing.discover(directory || Rails.root.join("app", "mcp_servers")) if names.empty?
25
+ excluded = Array(except).map { |name| OmniAgent::MCP::Routing.slug_for(name) }
26
+
27
+ names.each do |name|
28
+ mcp_server(name) unless excluded.include?(OmniAgent::MCP::Routing.slug_for(name))
29
+ end
30
+ end
31
+
32
+ # :support, "support", "support_server", "SupportServer" -> "support"
33
+ # "Admin::SupportServer", "admin/support" -> "admin/support"
34
+ def self.slug_for(name)
35
+ name.to_s.underscore.delete_suffix("_server")
36
+ end
37
+
38
+ # Server slugs for every *_server.rb file, without loading any code.
39
+ # Skips files inside a server's own directory (e.g. support_server/tools/)
40
+ # and abstract base servers named application_*.
41
+ def self.discover(directory)
42
+ root = Pathname.new(directory.to_s)
43
+ return [] unless root.directory?
44
+
45
+ Dir.glob(root.join("**", "*_server.rb").to_s).sort.filter_map do |file|
46
+ relative = Pathname.new(file).relative_path_from(root).to_s.delete_suffix(".rb")
47
+ *dirs, base = relative.split("/")
48
+
49
+ next if dirs.any? { |dir| dir.end_with?("_server") }
50
+ next if base.start_with?("application_")
51
+
52
+ slug_for(relative)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,238 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ class Server
6
+ TOOL_NAME_PATTERN = /\A[A-Za-z0-9_.-]{1,128}\z/
7
+
8
+ class << self
9
+ def server_name(value = nil)
10
+ @server_name = value.to_s if value
11
+ setting(:@server_name) || default_server_name
12
+ end
13
+
14
+ def server_version(value = nil)
15
+ @server_version = value.to_s if value
16
+ setting(:@server_version) || "0.1.0"
17
+ end
18
+
19
+ def instructions(text = nil)
20
+ @instructions = text if text
21
+ setting(:@instructions)
22
+ end
23
+
24
+ def tools(*tool_classes)
25
+ tool_classes.flatten.each { |tool_class| tool(tool_class) }
26
+ end
27
+
28
+ def tool(tool_class, as: nil)
29
+ add_tool_declaration(type: :tool, tool_class: tool_class, name: as&.to_s)
30
+ end
31
+
32
+ def tools_from(*agent_classes)
33
+ agent_classes.flatten.each do |agent_class|
34
+ add_tool_declaration(type: :agent_tools, agent_class: agent_class)
35
+ end
36
+ end
37
+
38
+ def expose_agent(agent_class, as:, description: nil, run_alias: nil, forward: [])
39
+ unless agent_class.is_a?(Class) && agent_class <= OmniAgent::Agent
40
+ raise ArgumentError, "expose_agent requires an OmniAgent::Agent subclass"
41
+ end
42
+
43
+ tool_class = OmniAgent::Agent.build_delegated_tool_class(
44
+ agent_class,
45
+ description: description,
46
+ run_alias: run_alias,
47
+ forward: forward
48
+ )
49
+
50
+ add_tool_declaration(type: :tool, tool_class: tool_class, name: as.to_s)
51
+ end
52
+
53
+ def authenticate(strategy = nil, tokens: nil, &block)
54
+ @authentication = if block
55
+ { strategy: :custom, block: block }
56
+ elsif strategy == :none
57
+ { strategy: :none }
58
+ elsif strategy == :bearer
59
+ raise ArgumentError, "authenticate :bearer requires tokens:" if tokens.nil?
60
+
61
+ { strategy: :bearer, tokens: tokens }
62
+ else
63
+ raise ArgumentError, "authenticate expects :bearer (with tokens:), :none, or a block"
64
+ end
65
+ end
66
+
67
+ def authorize_tool(&block)
68
+ raise ArgumentError, "authorize_tool requires a block" unless block
69
+
70
+ @authorize_tool_block = block
71
+ end
72
+
73
+ def context(&block)
74
+ raise ArgumentError, "context requires a block" unless block
75
+
76
+ @context_block = block
77
+ end
78
+
79
+ def stdio_principal(&block)
80
+ raise ArgumentError, "stdio_principal requires a block" unless block
81
+
82
+ @stdio_principal_block = block
83
+ end
84
+
85
+ def allowed_origins(*origins)
86
+ @allowed_origins = (@allowed_origins || []) + origins.flatten
87
+ end
88
+
89
+ def configured_tool_declarations; list_setting(:@tool_declarations); end
90
+ def configured_authentication; setting(:@authentication); end
91
+ def configured_authorize_tool_block; setting(:@authorize_tool_block); end
92
+ def configured_context_block; setting(:@context_block); end
93
+ def configured_stdio_principal_block; setting(:@stdio_principal_block); end
94
+ def configured_allowed_origins; list_setting(:@allowed_origins); end
95
+
96
+ # Ordered { "ToolName" => tool_class }: for each server in the
97
+ # inheritance chain, its <Server>::Tools namespace first, then its
98
+ # declared tools. Resolved on every call so lazily referenced tools
99
+ # (strings, tools_from, the Tools namespace) pick up code reloads.
100
+ def tool_registry
101
+ server_ancestors.reverse.each_with_object({}) do |server_class, registry|
102
+ entries = server_class.namespace_tool_classes.map { |tool_class| [ default_tool_name(tool_class), tool_class ] }
103
+ entries += (server_class.instance_variable_get(:@tool_declarations) || []).flat_map do |declaration|
104
+ resolve_declaration(declaration)
105
+ end
106
+
107
+ entries.each { |name, tool_class| register_tool(registry, name, tool_class) }
108
+ end
109
+ end
110
+
111
+ # Tools defined under this server's own namespace, e.g.
112
+ # app/mcp_servers/support_server/tools/*.rb -> SupportServer::Tools::*.
113
+ def namespace_tool_classes
114
+ return [] unless const_defined?(:Tools, false)
115
+
116
+ namespace = const_get(:Tools, false)
117
+ return [] unless namespace.is_a?(Module)
118
+
119
+ namespace.constants.sort.filter_map do |const_name|
120
+ const = namespace.const_get(const_name, false)
121
+ const if const.is_a?(Class) && const < OmniAgent::Tool
122
+ end
123
+ end
124
+
125
+ def resolve_stdio_principal
126
+ configured_stdio_principal_block&.call
127
+ end
128
+
129
+ private
130
+
131
+ # Servers inherit settings from parent servers (e.g. an ApplicationMCPServer
132
+ # that declares `authenticate` once). Scalars: nearest wins. Lists: concatenated.
133
+ def server_ancestors
134
+ ancestors.select { |klass| klass.is_a?(Class) && klass < OmniAgent::MCP::Server }
135
+ end
136
+
137
+ def setting(ivar)
138
+ server_ancestors.each do |klass|
139
+ value = klass.instance_variable_get(ivar)
140
+ return value unless value.nil?
141
+ end
142
+
143
+ nil
144
+ end
145
+
146
+ def list_setting(ivar)
147
+ server_ancestors.reverse.flat_map { |klass| klass.instance_variable_get(ivar) || [] }
148
+ end
149
+
150
+ def default_server_name
151
+ return "omni_agent" if name.nil?
152
+
153
+ base = name.demodulize.underscore
154
+ base = base.delete_suffix("_server") unless base == "server"
155
+ base
156
+ end
157
+
158
+ def add_tool_declaration(declaration)
159
+ @tool_declarations = (@tool_declarations || []) + [ declaration ]
160
+ end
161
+
162
+ def resolve_declaration(declaration)
163
+ case declaration[:type]
164
+ when :tool
165
+ tool_class = resolve_constant(declaration[:tool_class])
166
+ [ [ declaration[:name] || default_tool_name(tool_class), tool_class ] ]
167
+ when :agent_tools
168
+ agent_class = resolve_constant(declaration[:agent_class])
169
+ agent_class.tool_classes.map { |tool_class| [ default_tool_name(tool_class), tool_class ] }
170
+ end
171
+ end
172
+
173
+ def resolve_constant(value)
174
+ return value if value.is_a?(Class)
175
+
176
+ Object.const_get(value.to_s)
177
+ end
178
+
179
+ def default_tool_name(tool_class)
180
+ if tool_class.name.nil?
181
+ raise OmniAgent::MCPError, "Anonymous tool classes need an explicit name: `tool klass, as: \"name\"`"
182
+ end
183
+
184
+ tool_class.name.demodulize
185
+ end
186
+
187
+ def register_tool(registry, name, tool_class)
188
+ unless tool_class.is_a?(Class) && tool_class < OmniAgent::Tool
189
+ raise OmniAgent::MCPError, "#{tool_class.inspect} is not an OmniAgent::Tool subclass"
190
+ end
191
+
192
+ unless name.match?(TOOL_NAME_PATTERN)
193
+ raise OmniAgent::MCPError,
194
+ "Invalid MCP tool name #{name.inspect} in #{self.name}: use 1-128 characters from A-Z, a-z, 0-9, _, -, ."
195
+ end
196
+
197
+ if registry.key?(name)
198
+ raise OmniAgent::MCPError,
199
+ "Duplicate MCP tool name #{name.inspect} in #{self.name}. Use `tool klass, as: \"other_name\"` to rename one."
200
+ end
201
+
202
+ registry[name] = tool_class
203
+ end
204
+ end
205
+
206
+ attr_reader :principal, :request, :transport
207
+
208
+ def initialize(principal: nil, request: nil, transport: :http)
209
+ @principal = principal
210
+ @request = request
211
+ @transport = transport
212
+ end
213
+
214
+ def visible_tools
215
+ authorizer = self.class.configured_authorize_tool_block
216
+ registry = self.class.tool_registry
217
+ return registry unless authorizer
218
+
219
+ registry.select do |name, tool_class|
220
+ instance_exec(tool_class, principal, name, &authorizer)
221
+ end
222
+ end
223
+
224
+ def find_tool(name)
225
+ visible_tools[name.to_s]
226
+ end
227
+
228
+ def tool_context
229
+ base = { mcp_principal: principal, mcp_request: request }
230
+ context_block = self.class.configured_context_block
231
+ return base unless context_block
232
+
233
+ extra = instance_exec(principal, request, &context_block)
234
+ extra.is_a?(Hash) ? base.merge(extra) : base
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,56 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ # stdio transport: newline-delimited JSON-RPC over stdin/stdout. Runs as a
6
+ # local subprocess of the client, so it skips HTTP authentication and takes
7
+ # its principal from the server's `stdio_principal` block instead.
8
+ class Stdio
9
+ # Entry point for `omni_agent mcp` and `rake omni_agent:mcp`. Keeps a
10
+ # private handle on the real stdout for the protocol, then points fd 1
11
+ # at stderr so stray `puts` or logger output cannot corrupt the stream.
12
+ def self.start(server, input: $stdin)
13
+ server_class = MCP.resolve_server_class(server)
14
+
15
+ protocol_output = $stdout.dup
16
+ $stdout.flush
17
+ $stdout.reopen($stderr)
18
+
19
+ new(server_class, input: input, output: protocol_output).run
20
+ end
21
+
22
+ def initialize(server, input:, output:)
23
+ @server_class = MCP.resolve_server_class(server)
24
+ @input = input
25
+ @output = output
26
+ end
27
+
28
+ def run
29
+ server = @server_class.new(principal: @server_class.resolve_stdio_principal, transport: :stdio)
30
+ handler = Handler.new(server)
31
+
32
+ @input.each_line do |line|
33
+ line = line.strip
34
+ next if line.empty?
35
+
36
+ message = begin
37
+ JSON.parse(line)
38
+ rescue JSON::ParserError
39
+ write(jsonrpc: JSONRPC_VERSION, id: nil, error: { code: PARSE_ERROR, message: "Parse error" })
40
+ next
41
+ end
42
+
43
+ response = handler.call(message)
44
+ write(response) if response
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def write(payload)
51
+ @output.write("#{JSON.generate(payload)}\n")
52
+ @output.flush
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module ToolAdapter
6
+ module_function
7
+
8
+ def definition(name, tool_class)
9
+ definition = { name: name }
10
+ definition[:title] = tool_class.title if tool_class.title
11
+ definition[:description] = tool_class.description
12
+ definition[:inputSchema] = tool_class.json_schema
13
+
14
+ annotations = tool_class.mcp_annotations
15
+ definition[:annotations] = annotations unless annotations.empty?
16
+
17
+ definition
18
+ end
19
+
20
+ def result(value)
21
+ case value
22
+ when nil
23
+ { content: [ text_content("") ] }
24
+ when String
25
+ { content: [ text_content(value) ] }
26
+ when Hash
27
+ json = JSON.generate(value)
28
+ { content: [ text_content(json) ], structuredContent: JSON.parse(json) }
29
+ when Array
30
+ { content: [ text_content(JSON.generate(value)) ] }
31
+ else
32
+ { content: [ text_content(value.to_s) ] }
33
+ end
34
+ end
35
+
36
+ def error_result(message)
37
+ { content: [ text_content(message) ], isError: true }
38
+ end
39
+
40
+ def text_content(text)
41
+ { type: "text", text: text }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ module OmniAgent
2
+ module MCP
3
+ SUPPORTED_PROTOCOL_VERSIONS = [ "2025-11-25", "2025-06-18", "2025-03-26" ].freeze
4
+ LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
5
+
6
+ JSONRPC_VERSION = "2.0".freeze
7
+
8
+ PARSE_ERROR = -32_700
9
+ INVALID_REQUEST = -32_600
10
+ METHOD_NOT_FOUND = -32_601
11
+ INVALID_PARAMS = -32_602
12
+ INTERNAL_ERROR = -32_603
13
+
14
+ def self.logger
15
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
16
+
17
+ Rails.logger
18
+ end
19
+
20
+ def self.resolve_server_class(server)
21
+ return server if server.is_a?(Class)
22
+
23
+ server_class = Object.const_get(server.to_s)
24
+ unless server_class.is_a?(Class) && server_class < OmniAgent::MCP::Server
25
+ raise OmniAgent::MCPError, "#{server} is not an OmniAgent::MCP::Server subclass"
26
+ end
27
+
28
+ server_class
29
+ end
30
+ end
31
+ end
@@ -11,6 +11,31 @@ module OmniAgent
11
11
  @metadata || {}
12
12
  end
13
13
 
14
+ def title(text = nil)
15
+ @title = text if text
16
+ @title
17
+ end
18
+
19
+ def annotations(read_only: nil, destructive: nil, idempotent: nil, open_world: nil)
20
+ @annotations = {
21
+ read_only: read_only,
22
+ destructive: destructive,
23
+ idempotent: idempotent,
24
+ open_world: open_world
25
+ }.compact
26
+ end
27
+
28
+ def mcp_annotations
29
+ hints = @annotations || {}
30
+
31
+ {
32
+ readOnlyHint: hints[:read_only],
33
+ destructiveHint: hints[:destructive],
34
+ idempotentHint: hints[:idempotent],
35
+ openWorldHint: hints[:open_world]
36
+ }.compact
37
+ end
38
+
14
39
  def tags(*tag_names)
15
40
  return @tags || [] if tag_names.empty?
16
41
 
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.9"
2
+ VERSION = "0.1.10"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -3,7 +3,7 @@ require "omni_agent/engine" if defined?(Rails)
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
- loader.inflector.inflect("openai" => "OpenAI", "cli" => "CLI")
6
+ loader.inflector.inflect("openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP")
7
7
  loader.ignore(File.expand_path("generators", __dir__))
8
8
  loader.setup
9
9
 
@@ -0,0 +1,9 @@
1
+ namespace :omni_agent do
2
+ desc 'Run an MCP server over stdio. Usage: rake "omni_agent:mcp[SupportServer]"'
3
+ task :mcp, [ :server ] => :environment do |_task, args|
4
+ server = args[:server].to_s.strip
5
+ abort 'Please provide a server class. Example: bin/rails "omni_agent:mcp[SupportServer]"' if server.empty?
6
+
7
+ OmniAgent::MCP::Stdio.start(server)
8
+ end
9
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - ACR1209
@@ -62,6 +62,7 @@ files:
62
62
  - lib/generators/omni_agent/agent/agent_generator.rb
63
63
  - lib/generators/omni_agent/eval/eval_generator.rb
64
64
  - lib/generators/omni_agent/install/install_generator.rb
65
+ - lib/generators/omni_agent/mcp_server/mcp_server_generator.rb
65
66
  - lib/omni_agent.rb
66
67
  - lib/omni_agent/agent.rb
67
68
  - lib/omni_agent/configuration.rb
@@ -81,6 +82,15 @@ files:
81
82
  - lib/omni_agent/eval/run.rb
82
83
  - lib/omni_agent/eval/runner.rb
83
84
  - lib/omni_agent/eval/tool_call_assertion.rb
85
+ - lib/omni_agent/mcp.rb
86
+ - lib/omni_agent/mcp/authenticator.rb
87
+ - lib/omni_agent/mcp/handler.rb
88
+ - lib/omni_agent/mcp/rack_app.rb
89
+ - lib/omni_agent/mcp/request.rb
90
+ - lib/omni_agent/mcp/routing.rb
91
+ - lib/omni_agent/mcp/server.rb
92
+ - lib/omni_agent/mcp/stdio.rb
93
+ - lib/omni_agent/mcp/tool_adapter.rb
84
94
  - lib/omni_agent/providers.rb
85
95
  - lib/omni_agent/providers/base.rb
86
96
  - lib/omni_agent/providers/mock.rb
@@ -96,6 +106,7 @@ files:
96
106
  - lib/omni_agent/tool/schema_builder.rb
97
107
  - lib/omni_agent/version.rb
98
108
  - lib/tasks/omni_agent_eval_tasks.rake
109
+ - lib/tasks/omni_agent_mcp_tasks.rake
99
110
  homepage: https://github.com/ACR1209/omni_agent
100
111
  licenses:
101
112
  - MIT