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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +50 -0
- data/exe/omni_agent +22 -6
- data/lib/generators/omni_agent/agent/agent_generator.rb +1 -0
- data/lib/generators/omni_agent/install/install_generator.rb +1 -0
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +155 -0
- data/lib/omni_agent/agent.rb +33 -29
- data/lib/omni_agent/configuration.rb +4 -1
- data/lib/omni_agent/engine.rb +4 -0
- data/lib/omni_agent/errors.rb +2 -0
- data/lib/omni_agent/mcp/authenticator.rb +85 -0
- data/lib/omni_agent/mcp/handler.rb +128 -0
- data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
- data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
- data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
- data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
- data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
- data/lib/omni_agent/mcp/oauth.rb +18 -0
- data/lib/omni_agent/mcp/rack_app.rb +131 -0
- data/lib/omni_agent/mcp/request.rb +24 -0
- data/lib/omni_agent/mcp/routing.rb +57 -0
- data/lib/omni_agent/mcp/server.rb +255 -0
- data/lib/omni_agent/mcp/stdio.rb +56 -0
- data/lib/omni_agent/mcp/tool_adapter.rb +45 -0
- data/lib/omni_agent/mcp.rb +31 -0
- data/lib/omni_agent/tool.rb +25 -0
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +4 -1
- data/lib/tasks/omni_agent_mcp_tasks.rake +9 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc288d35e0b6c6e5e4ea763c36d05aa37ee95d20aeb2f4b1369e15a6ba22bc92
|
|
4
|
+
data.tar.gz: 428b4d96e1b1f7ad2bf1f7cd2c99893333c0eac59d2add9a94faf18198376baa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
@@ -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
|
data/lib/omni_agent/agent.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/omni_agent/engine.rb
CHANGED
data/lib/omni_agent/errors.rb
CHANGED
|
@@ -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
|