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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +46 -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 +151 -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 +82 -0
- data/lib/omni_agent/mcp/handler.rb +128 -0
- data/lib/omni_agent/mcp/rack_app.rb +107 -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 +238 -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 +1 -1
- data/lib/tasks/omni_agent_mcp_tasks.rake +9 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8a84d4b38e835bc00b73a88bc233932198498112e3837e33fdfdf3dbabab981
|
|
4
|
+
data.tar.gz: 6d5cd7709dc61be01ae892a67732b08ee3e17a6d4af7d81f8640f0fd618010e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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,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
|
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,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
|
data/lib/omni_agent/tool.rb
CHANGED
|
@@ -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
|
|
data/lib/omni_agent/version.rb
CHANGED
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.
|
|
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
|