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.
@@ -0,0 +1,255 @@
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, **oauth_options, &block)
54
+ if strategy != :oauth && oauth_options.any?
55
+ raise ArgumentError, "unknown keywords for authenticate: #{oauth_options.keys.map(&:inspect).join(', ')}"
56
+ end
57
+
58
+ @authentication = if strategy == :oauth
59
+ raise ArgumentError, "authenticate :oauth does not take tokens:" unless tokens.nil?
60
+
61
+ # The block, if any, maps the validated access token to the principal.
62
+ { strategy: :oauth, resource_server: OAuth::ResourceServer.new(**oauth_options, &block) }
63
+ elsif block
64
+ { strategy: :custom, block: block }
65
+ elsif strategy == :none
66
+ { strategy: :none }
67
+ elsif strategy == :bearer
68
+ raise ArgumentError, "authenticate :bearer requires tokens:" if tokens.nil?
69
+
70
+ { strategy: :bearer, tokens: tokens }
71
+ else
72
+ raise ArgumentError, "authenticate expects :bearer (with tokens:), :oauth, :none, or a block"
73
+ end
74
+ end
75
+
76
+ def authorize_tool(&block)
77
+ raise ArgumentError, "authorize_tool requires a block" unless block
78
+
79
+ @authorize_tool_block = block
80
+ end
81
+
82
+ def context(&block)
83
+ raise ArgumentError, "context requires a block" unless block
84
+
85
+ @context_block = block
86
+ end
87
+
88
+ def stdio_principal(&block)
89
+ raise ArgumentError, "stdio_principal requires a block" unless block
90
+
91
+ @stdio_principal_block = block
92
+ end
93
+
94
+ def allowed_origins(*origins)
95
+ @allowed_origins = (@allowed_origins || []) + origins.flatten
96
+ end
97
+
98
+ def configured_tool_declarations; list_setting(:@tool_declarations); end
99
+ def configured_authentication; setting(:@authentication); end
100
+ def configured_authorize_tool_block; setting(:@authorize_tool_block); end
101
+ def configured_context_block; setting(:@context_block); end
102
+ def configured_stdio_principal_block; setting(:@stdio_principal_block); end
103
+ def configured_allowed_origins; list_setting(:@allowed_origins); end
104
+
105
+ def configured_oauth_resource_server
106
+ authentication = configured_authentication
107
+ authentication[:resource_server] if authentication && authentication[:strategy] == :oauth
108
+ end
109
+
110
+ # Ordered { "ToolName" => tool_class }: for each server in the
111
+ # inheritance chain, its <Server>::Tools namespace first, then its
112
+ # declared tools. Resolved on every call so lazily referenced tools
113
+ # (strings, tools_from, the Tools namespace) pick up code reloads.
114
+ def tool_registry
115
+ server_ancestors.reverse.each_with_object({}) do |server_class, registry|
116
+ entries = server_class.namespace_tool_classes.map { |tool_class| [ default_tool_name(tool_class), tool_class ] }
117
+ entries += (server_class.instance_variable_get(:@tool_declarations) || []).flat_map do |declaration|
118
+ resolve_declaration(declaration)
119
+ end
120
+
121
+ entries.each { |name, tool_class| register_tool(registry, name, tool_class) }
122
+ end
123
+ end
124
+
125
+ # Tools defined under this server's own namespace, e.g.
126
+ # app/mcp_servers/support_server/tools/*.rb -> SupportServer::Tools::*.
127
+ def namespace_tool_classes
128
+ return [] unless const_defined?(:Tools, false)
129
+
130
+ namespace = const_get(:Tools, false)
131
+ return [] unless namespace.is_a?(Module)
132
+
133
+ namespace.constants.sort.filter_map do |const_name|
134
+ const = namespace.const_get(const_name, false)
135
+ const if const.is_a?(Class) && const < OmniAgent::Tool
136
+ end
137
+ end
138
+
139
+ def resolve_stdio_principal
140
+ configured_stdio_principal_block&.call
141
+ end
142
+
143
+ private
144
+
145
+ # Servers inherit settings from parent servers (e.g. an ApplicationMCPServer
146
+ # that declares `authenticate` once). Scalars: nearest wins. Lists: concatenated.
147
+ def server_ancestors
148
+ ancestors.select { |klass| klass.is_a?(Class) && klass < OmniAgent::MCP::Server }
149
+ end
150
+
151
+ def setting(ivar)
152
+ server_ancestors.each do |klass|
153
+ value = klass.instance_variable_get(ivar)
154
+ return value unless value.nil?
155
+ end
156
+
157
+ nil
158
+ end
159
+
160
+ def list_setting(ivar)
161
+ server_ancestors.reverse.flat_map { |klass| klass.instance_variable_get(ivar) || [] }
162
+ end
163
+
164
+ def default_server_name
165
+ return "omni_agent" if name.nil?
166
+
167
+ base = name.demodulize.underscore
168
+ base = base.delete_suffix("_server") unless base == "server"
169
+ base
170
+ end
171
+
172
+ def add_tool_declaration(declaration)
173
+ @tool_declarations = (@tool_declarations || []) + [ declaration ]
174
+ end
175
+
176
+ def resolve_declaration(declaration)
177
+ case declaration[:type]
178
+ when :tool
179
+ tool_class = resolve_constant(declaration[:tool_class])
180
+ [ [ declaration[:name] || default_tool_name(tool_class), tool_class ] ]
181
+ when :agent_tools
182
+ agent_class = resolve_constant(declaration[:agent_class])
183
+ agent_class.tool_classes.map { |tool_class| [ default_tool_name(tool_class), tool_class ] }
184
+ end
185
+ end
186
+
187
+ def resolve_constant(value)
188
+ return value if value.is_a?(Class)
189
+
190
+ Object.const_get(value.to_s)
191
+ end
192
+
193
+ def default_tool_name(tool_class)
194
+ if tool_class.name.nil?
195
+ raise OmniAgent::MCPError, "Anonymous tool classes need an explicit name: `tool klass, as: \"name\"`"
196
+ end
197
+
198
+ tool_class.name.demodulize
199
+ end
200
+
201
+ def register_tool(registry, name, tool_class)
202
+ unless tool_class.is_a?(Class) && tool_class < OmniAgent::Tool
203
+ raise OmniAgent::MCPError, "#{tool_class.inspect} is not an OmniAgent::Tool subclass"
204
+ end
205
+
206
+ unless name.match?(TOOL_NAME_PATTERN)
207
+ raise OmniAgent::MCPError,
208
+ "Invalid MCP tool name #{name.inspect} in #{self.name}: use 1-128 characters from A-Z, a-z, 0-9, _, -, ."
209
+ end
210
+
211
+ if registry.key?(name)
212
+ raise OmniAgent::MCPError,
213
+ "Duplicate MCP tool name #{name.inspect} in #{self.name}. Use `tool klass, as: \"other_name\"` to rename one."
214
+ end
215
+
216
+ registry[name] = tool_class
217
+ end
218
+ end
219
+
220
+ # access_token is the OAuth::AccessToken for `authenticate :oauth`, nil otherwise.
221
+ attr_reader :principal, :request, :transport, :access_token
222
+
223
+ def initialize(principal: nil, request: nil, transport: :http, access_token: nil)
224
+ @principal = principal
225
+ @request = request
226
+ @transport = transport
227
+ @access_token = access_token
228
+ end
229
+
230
+ def visible_tools
231
+ authorizer = self.class.configured_authorize_tool_block
232
+ registry = self.class.tool_registry
233
+ return registry unless authorizer
234
+
235
+ registry.select do |name, tool_class|
236
+ instance_exec(tool_class, principal, name, &authorizer)
237
+ end
238
+ end
239
+
240
+ def find_tool(name)
241
+ visible_tools[name.to_s]
242
+ end
243
+
244
+ def tool_context
245
+ base = { mcp_principal: principal, mcp_request: request }
246
+ base[:mcp_access_token] = access_token if access_token
247
+ context_block = self.class.configured_context_block
248
+ return base unless context_block
249
+
250
+ extra = instance_exec(principal, request, &context_block)
251
+ extra.is_a?(Hash) ? base.merge(extra) : base
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,56 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ # stdio transport: newline-delimited JSON-RPC over stdin/stdout. Runs as a
6
+ # local subprocess of the client, so it skips HTTP authentication and takes
7
+ # its principal from the server's `stdio_principal` block instead.
8
+ class Stdio
9
+ # Entry point for `omni_agent mcp` and `rake omni_agent:mcp`. Keeps a
10
+ # private handle on the real stdout for the protocol, then points fd 1
11
+ # at stderr so stray `puts` or logger output cannot corrupt the stream.
12
+ def self.start(server, input: $stdin)
13
+ server_class = MCP.resolve_server_class(server)
14
+
15
+ protocol_output = $stdout.dup
16
+ $stdout.flush
17
+ $stdout.reopen($stderr)
18
+
19
+ new(server_class, input: input, output: protocol_output).run
20
+ end
21
+
22
+ def initialize(server, input:, output:)
23
+ @server_class = MCP.resolve_server_class(server)
24
+ @input = input
25
+ @output = output
26
+ end
27
+
28
+ def run
29
+ server = @server_class.new(principal: @server_class.resolve_stdio_principal, transport: :stdio)
30
+ handler = Handler.new(server)
31
+
32
+ @input.each_line do |line|
33
+ line = line.strip
34
+ next if line.empty?
35
+
36
+ message = begin
37
+ JSON.parse(line)
38
+ rescue JSON::ParserError
39
+ write(jsonrpc: JSONRPC_VERSION, id: nil, error: { code: PARSE_ERROR, message: "Parse error" })
40
+ next
41
+ end
42
+
43
+ response = handler.call(message)
44
+ write(response) if response
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def write(payload)
51
+ @output.write("#{JSON.generate(payload)}\n")
52
+ @output.flush
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module ToolAdapter
6
+ module_function
7
+
8
+ def definition(name, tool_class)
9
+ definition = { name: name }
10
+ definition[:title] = tool_class.title if tool_class.title
11
+ definition[:description] = tool_class.description
12
+ definition[:inputSchema] = tool_class.json_schema
13
+
14
+ annotations = tool_class.mcp_annotations
15
+ definition[:annotations] = annotations unless annotations.empty?
16
+
17
+ definition
18
+ end
19
+
20
+ def result(value)
21
+ case value
22
+ when nil
23
+ { content: [ text_content("") ] }
24
+ when String
25
+ { content: [ text_content(value) ] }
26
+ when Hash
27
+ json = JSON.generate(value)
28
+ { content: [ text_content(json) ], structuredContent: JSON.parse(json) }
29
+ when Array
30
+ { content: [ text_content(JSON.generate(value)) ] }
31
+ else
32
+ { content: [ text_content(value.to_s) ] }
33
+ end
34
+ end
35
+
36
+ def error_result(message)
37
+ { content: [ text_content(message) ], isError: true }
38
+ end
39
+
40
+ def text_content(text)
41
+ { type: "text", text: text }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ module OmniAgent
2
+ module MCP
3
+ SUPPORTED_PROTOCOL_VERSIONS = [ "2025-11-25", "2025-06-18", "2025-03-26" ].freeze
4
+ LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
5
+
6
+ JSONRPC_VERSION = "2.0".freeze
7
+
8
+ PARSE_ERROR = -32_700
9
+ INVALID_REQUEST = -32_600
10
+ METHOD_NOT_FOUND = -32_601
11
+ INVALID_PARAMS = -32_602
12
+ INTERNAL_ERROR = -32_603
13
+
14
+ def self.logger
15
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
16
+
17
+ Rails.logger
18
+ end
19
+
20
+ def self.resolve_server_class(server)
21
+ return server if server.is_a?(Class)
22
+
23
+ server_class = Object.const_get(server.to_s)
24
+ unless server_class.is_a?(Class) && server_class < OmniAgent::MCP::Server
25
+ raise OmniAgent::MCPError, "#{server} is not an OmniAgent::MCP::Server subclass"
26
+ end
27
+
28
+ server_class
29
+ end
30
+ end
31
+ end
@@ -11,6 +11,31 @@ module OmniAgent
11
11
  @metadata || {}
12
12
  end
13
13
 
14
+ def title(text = nil)
15
+ @title = text if text
16
+ @title
17
+ end
18
+
19
+ def annotations(read_only: nil, destructive: nil, idempotent: nil, open_world: nil)
20
+ @annotations = {
21
+ read_only: read_only,
22
+ destructive: destructive,
23
+ idempotent: idempotent,
24
+ open_world: open_world
25
+ }.compact
26
+ end
27
+
28
+ def mcp_annotations
29
+ hints = @annotations || {}
30
+
31
+ {
32
+ readOnlyHint: hints[:read_only],
33
+ destructiveHint: hints[:destructive],
34
+ idempotentHint: hints[:idempotent],
35
+ openWorldHint: hints[:open_world]
36
+ }.compact
37
+ end
38
+
14
39
  def tags(*tag_names)
15
40
  return @tags || [] if tag_names.empty?
16
41
 
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.9"
2
+ VERSION = "0.1.11"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -3,7 +3,10 @@ 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(
7
+ "openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP",
8
+ "oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient"
9
+ )
7
10
  loader.ignore(File.expand_path("generators", __dir__))
8
11
  loader.setup
9
12
 
@@ -0,0 +1,9 @@
1
+ namespace :omni_agent do
2
+ desc 'Run an MCP server over stdio. Usage: rake "omni_agent:mcp[SupportServer]"'
3
+ task :mcp, [ :server ] => :environment do |_task, args|
4
+ server = args[:server].to_s.strip
5
+ abort 'Please provide a server class. Example: bin/rails "omni_agent:mcp[SupportServer]"' if server.empty?
6
+
7
+ OmniAgent::MCP::Stdio.start(server)
8
+ end
9
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
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,21 @@ 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/oauth.rb
89
+ - lib/omni_agent/mcp/oauth/access_token.rb
90
+ - lib/omni_agent/mcp/oauth/http_client.rb
91
+ - lib/omni_agent/mcp/oauth/introspection_verifier.rb
92
+ - lib/omni_agent/mcp/oauth/jwt_verifier.rb
93
+ - lib/omni_agent/mcp/oauth/resource_server.rb
94
+ - lib/omni_agent/mcp/rack_app.rb
95
+ - lib/omni_agent/mcp/request.rb
96
+ - lib/omni_agent/mcp/routing.rb
97
+ - lib/omni_agent/mcp/server.rb
98
+ - lib/omni_agent/mcp/stdio.rb
99
+ - lib/omni_agent/mcp/tool_adapter.rb
84
100
  - lib/omni_agent/providers.rb
85
101
  - lib/omni_agent/providers/base.rb
86
102
  - lib/omni_agent/providers/mock.rb
@@ -96,6 +112,7 @@ files:
96
112
  - lib/omni_agent/tool/schema_builder.rb
97
113
  - lib/omni_agent/version.rb
98
114
  - lib/tasks/omni_agent_eval_tasks.rake
115
+ - lib/tasks/omni_agent_mcp_tasks.rake
99
116
  homepage: https://github.com/ACR1209/omni_agent
100
117
  licenses:
101
118
  - MIT