omni_agent 0.1.9 → 0.1.11

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: bc288d35e0b6c6e5e4ea763c36d05aa37ee95d20aeb2f4b1369e15a6ba22bc92
4
+ data.tar.gz: 428b4d96e1b1f7ad2bf1f7cd2c99893333c0eac59d2add9a94faf18198376baa
5
5
  SHA512:
6
- metadata.gz: 522e4be90a757a9056e95cf45f5aafb1f18d7b6914e1ca667e32b3774ab70422f552e333ac2e340c4d1dab1c628350e04d494a9726b908467eb53723ae300dd6
7
- data.tar.gz: c2c7939747237e554df0ac2569b84e36b164c1235f75fa876f04518cb644ad02579e18f4ee168c53ff55f32b75c8a58284ff921b217eda4b9148f67a8d5e4de7
6
+ metadata.gz: 9c55eaf9a31782bb1c16c152d15627943cef09f5a179c6039590b412dba505196aae36ed325905992bd26df567c74553a83fec629c59062c2cfa5d4981a03bf4
7
+ data.tar.gz: 904fdc44a855a5b7cfe66817cd531cc86bd44d5a8f8bccd6b97c6bf3d313a4b4e4a7a2f18fb18305bf5ae5be4bca7424562c1e39f77ecbb12ee63bea4d00fcf3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.11](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.10...omni_agent/v0.1.11) (2026-10-15)
6
+
7
+
8
+ ### Features
9
+
10
+ * **mcp:** add OAuth 2.1 authentication per the MCP authorization spec ([160c3d4](https://github.com/ACR1209/omni_agent/commit/160c3d47fb70272c58162359296c795a03b52219))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **mcp:** stop blocking requests while fetching the JWKS ([5074eb2](https://github.com/ACR1209/omni_agent/commit/5074eb2d939b2c1d6c365b15482cc9024f361527))
16
+
17
+ ## [0.1.10](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.9...omni_agent/v0.1.10) (2026-10-09)
18
+
19
+
20
+ ### Features
21
+
22
+ * **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))
23
+
5
24
  ## [0.1.9](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.8...omni_agent/v0.1.9) (2026-07-12)
6
25
 
7
26
 
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,55 @@ 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
+ # or OAuth 2.1 (MCP authorization spec), with tokens from your authorization server:
266
+ # authenticate :oauth, resource: "https://app.example.com/mcp/support",
267
+ # authorization_servers: "https://auth.example.com",
268
+ # jwks_uri: "https://auth.example.com/.well-known/jwks.json"
269
+
270
+ authorize_tool { |tool_class, principal| !tool_class.tags.include?(:admin) }
271
+ context { |principal, _request| { current_user: principal } }
272
+ end
273
+ ```
274
+
275
+ ```ruby
276
+ # config/routes.rb -- Streamable HTTP (stateless, POST only)
277
+ mcp_server :support # SupportServer at /mcp/support
278
+ mcp_server :billing, path: "/internal/mcp"
279
+ # or mount every server in app/mcp_servers at /mcp/<name>:
280
+ mcp_servers
281
+ ```
282
+
283
+ ```bash
284
+ # stdio, for local clients
285
+ bundle exec omni_agent mcp SupportServer
286
+
287
+ claude mcp add --transport http support http://localhost:3000/mcp/support --header "Authorization: Bearer $TOKEN"
288
+ claude mcp add support -- bundle exec omni_agent mcp SupportServer
289
+ ```
290
+
291
+ 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.
292
+
243
293
  ## Configuration
244
294
 
245
295
  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,155 @@
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
+ # Or OAuth 2.1 with tokens from your authorization server (add `gem "jwt"`):
105
+ # authenticate :oauth, resource: "https://app.example.com/mcp/...",
106
+ # authorization_servers: "https://auth.example.com",
107
+ # jwks_uri: "https://auth.example.com/.well-known/jwks.json"
108
+
109
+ # Hide tools per principal:
110
+ # authorize_tool { |tool_class, principal| true }
111
+
112
+ # Extra values merged into each tool's `context`:
113
+ # context { |principal, request| { current_user: principal } }
114
+ end
115
+ RUBY
116
+ end
117
+
118
+ def requested_tool_names
119
+ names = Array(options[:with_tools]).map(&:strip).reject(&:empty?)
120
+ names.empty? ? [ "ExampleTool" ] : names.map(&:camelize)
121
+ end
122
+
123
+ def tool_template(tool_name)
124
+ <<~RUBY
125
+ module #{server_class_name}::Tools
126
+ class #{tool_name} < OmniAgent::Tool
127
+ description "#{tool_name} generated by omni_agent:mcp_server"
128
+ annotations read_only: true
129
+
130
+ input do
131
+ string :input_text, description: "Text input"
132
+ end
133
+
134
+ def execute(input_text:)
135
+ "Echo: \#{input_text}"
136
+ end
137
+ end
138
+ end
139
+ RUBY
140
+ end
141
+
142
+ def tool_lines
143
+ tools = Array(options[:tools]).map(&:strip).reject(&:empty?)
144
+ agents = Array(options[:agents]).map(&:strip).reject(&:empty?)
145
+
146
+ lines = []
147
+ lines << "tools #{tools.join(', ')}" if tools.any?
148
+ lines.concat(agents.map { |agent| "tools_from #{agent}" })
149
+ lines.unshift("# Tools in app/mcp_servers/#{server_file_name}/tools/ are added automatically.")
150
+ lines << "# tools SomeAgent::Tools::SomeTool" if tools.empty? && agents.empty?
151
+ lines
152
+ end
153
+ end
154
+ end
155
+ 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,85 @@
1
+ require "active_support/security_utils"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ class Authenticator
6
+ # error and access_token are only set by `authenticate :oauth`.
7
+ Result = Struct.new(:authenticated, :principal, :error, :access_token) do
8
+ def authenticated?
9
+ authenticated == true
10
+ end
11
+ end
12
+
13
+ ARRAY_TOKEN_PRINCIPAL = :bearer_token
14
+
15
+ def initialize(server_class)
16
+ @server_class = server_class
17
+ end
18
+
19
+ def authenticate(request)
20
+ authentication = @server_class.configured_authentication
21
+
22
+ if authentication.nil?
23
+ raise OmniAgent::MCPError,
24
+ "#{@server_class.name} does not declare authentication. " \
25
+ "Add `authenticate :bearer, tokens: ...`, `authenticate :oauth, ...`, `authenticate { |request| ... }`, " \
26
+ "or explicitly opt out with `authenticate :none`."
27
+ end
28
+
29
+ case authentication[:strategy]
30
+ when :none
31
+ success(nil)
32
+ when :bearer
33
+ authenticate_bearer(request, authentication[:tokens])
34
+ when :oauth
35
+ authentication[:resource_server].authenticate(request)
36
+ when :custom
37
+ principal = authentication[:block].call(request)
38
+ principal ? success(principal) : failure
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def authenticate_bearer(request, tokens)
45
+ presented = request.bearer_token
46
+ return failure if presented.nil?
47
+
48
+ matched = false
49
+ principal = nil
50
+
51
+ # Compare against every configured token (no early exit) so response
52
+ # timing does not reveal which token, or how many tokens, matched.
53
+ token_candidates(tokens).each do |candidate_principal, token|
54
+ next if token.nil? || token.to_s.empty?
55
+
56
+ if ActiveSupport::SecurityUtils.secure_compare(token.to_s, presented) && !matched
57
+ matched = true
58
+ principal = candidate_principal
59
+ end
60
+ end
61
+
62
+ matched ? success(principal) : failure
63
+ end
64
+
65
+ def token_candidates(tokens)
66
+ tokens = tokens.call if tokens.respond_to?(:call)
67
+
68
+ case tokens
69
+ when Hash
70
+ tokens.map { |principal, token| [ principal, token ] }
71
+ else
72
+ Array(tokens).map { |token| [ ARRAY_TOKEN_PRINCIPAL, token ] }
73
+ end
74
+ end
75
+
76
+ def success(principal)
77
+ Result.new(true, principal)
78
+ end
79
+
80
+ def failure
81
+ Result.new(false, nil)
82
+ end
83
+ end
84
+ end
85
+ 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