omni_agent 0.1.8 → 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 +26 -0
- data/README.md +74 -1
- 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 +51 -36
- 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/providers/base.rb +1 -1
- data/lib/omni_agent/providers/mock.rb +8 -1
- data/lib/omni_agent/providers/ollama.rb +30 -0
- data/lib/omni_agent/providers/openai.rb +23 -3
- data/lib/omni_agent/providers.rb +1 -0
- data/lib/omni_agent/streaming/event.rb +54 -0
- data/lib/omni_agent/streaming/proxy.rb +17 -0
- data/lib/omni_agent/tool/schema_builder.rb +130 -10
- data/lib/omni_agent/tool.rb +117 -1
- 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 +15 -1
|
@@ -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
|