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,60 @@
1
+ module OmniAgent
2
+ module MCP
3
+ module OAuth
4
+ # A validated access token and its claims (JWT payload or introspection
5
+ # response). It is the principal unless `authenticate :oauth` has a block.
6
+ class AccessToken
7
+ attr_reader :token, :claims
8
+
9
+ def initialize(token, claims)
10
+ @token = token
11
+ @claims = claims.transform_keys(&:to_s).freeze
12
+ end
13
+
14
+ def [](key)
15
+ claims[key.to_s]
16
+ end
17
+
18
+ def subject
19
+ claims["sub"]
20
+ end
21
+
22
+ def issuer
23
+ claims["iss"]
24
+ end
25
+
26
+ def client_id
27
+ claims["client_id"] || claims["azp"]
28
+ end
29
+
30
+ def audience
31
+ Array(claims["aud"]).map(&:to_s)
32
+ end
33
+
34
+ # RFC 9068 / RFC 7662 use a space-separated "scope"; some providers
35
+ # (Okta, Entra ID) use "scp", as a string or an array.
36
+ def scopes
37
+ raw = claims.key?("scope") ? claims["scope"] : claims["scp"]
38
+ raw.is_a?(Array) ? raw.map(&:to_s) : raw.to_s.split
39
+ end
40
+
41
+ def scope?(*names)
42
+ (names.flatten.map(&:to_s) - scopes).empty?
43
+ end
44
+
45
+ def expires_at
46
+ Time.at(claims["exp"]) if claims["exp"].is_a?(Numeric)
47
+ end
48
+
49
+ def to_s
50
+ subject.to_s
51
+ end
52
+
53
+ # Never print the raw token.
54
+ def inspect
55
+ "#<#{self.class.name} sub=#{subject.inspect} client_id=#{client_id.inspect} scopes=#{scopes.inspect}>"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module OmniAgent
7
+ module MCP
8
+ module OAuth
9
+ # Minimal JSON client for the authorization server (JWKS, introspection).
10
+ module HTTPClient
11
+ OPEN_TIMEOUT = 5
12
+ READ_TIMEOUT = 5
13
+
14
+ NETWORK_ERRORS = [
15
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError
16
+ ].freeze
17
+
18
+ module_function
19
+
20
+ def get_json(url)
21
+ uri = URI.parse(url.to_s)
22
+ request(uri, Net::HTTP::Get.new(uri))
23
+ end
24
+
25
+ def post_form_json(url, form, basic_auth: nil)
26
+ uri = URI.parse(url.to_s)
27
+ post = Net::HTTP::Post.new(uri)
28
+ post.set_form_data(form)
29
+ post.basic_auth(*basic_auth) if basic_auth
30
+ request(uri, post)
31
+ end
32
+
33
+ def request(uri, http_request)
34
+ http_request["accept"] = "application/json"
35
+
36
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
37
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
38
+ http.request(http_request)
39
+ end
40
+
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ raise VerificationError, "#{http_request.method} #{uri} returned HTTP #{response.code}"
43
+ end
44
+
45
+ JSON.parse(response.body.to_s)
46
+ rescue JSON::ParserError, *NETWORK_ERRORS => e
47
+ raise VerificationError, "#{http_request.method} #{uri} failed: #{e.class}: #{e.message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ require "uri"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module OAuth
6
+ # Asks the authorization server whether a token is active (RFC 7662).
7
+ # Works with opaque tokens. Returns the introspection response, or nil
8
+ # when the token is not active.
9
+ class IntrospectionVerifier
10
+ def initialize(url:, client_id: nil, client_secret: nil)
11
+ raise ArgumentError, "introspection: requires url:" if url.nil?
12
+
13
+ @url = url
14
+ @client_id = client_id
15
+ @client_secret = client_secret
16
+ end
17
+
18
+ def call(token, _request = nil)
19
+ response = HTTPClient.post_form_json(
20
+ OAuth.resolve(@url),
21
+ { "token" => token, "token_type_hint" => "access_token" },
22
+ basic_auth: credentials
23
+ )
24
+ response.is_a?(Hash) && response["active"] == true ? response : nil
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class.name} url=#{@url.inspect}>"
29
+ end
30
+
31
+ private
32
+
33
+ # client_secret_basic: RFC 6749 section 2.3.1 form-encodes both values.
34
+ def credentials
35
+ client_id = OAuth.resolve(@client_id)
36
+ return if client_id.nil?
37
+
38
+ [ client_id, OAuth.resolve(@client_secret) ].map { |value| URI.encode_www_form_component(value.to_s) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,122 @@
1
+ module OmniAgent
2
+ module MCP
3
+ module OAuth
4
+ # Verifies JWT access tokens (RFC 9068) with the authorization server's
5
+ # JSON Web Key Set. Returns the payload, or nil when the token is invalid.
6
+ # Issuer, audience and scopes are checked afterwards by ResourceServer.
7
+ class JWTVerifier
8
+ # Asymmetric algorithms only: a JWKS publishes public keys.
9
+ DEFAULT_ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
10
+ CACHE_TTL = 300
11
+ # A token signed with an unknown `kid` refetches the key set (key
12
+ # rotation), but at most this often, so forged tokens cannot make every
13
+ # request call the authorization server. Also the wait after a failed fetch.
14
+ MIN_REFRESH_INTERVAL = 30
15
+
16
+ def self.require_jwt!
17
+ require "jwt"
18
+ rescue LoadError
19
+ raise OmniAgent::MissingDependencyError,
20
+ "The 'jwt' gem is required for `authenticate :oauth, jwks_uri: ...`. " \
21
+ "Please add `gem 'jwt'` to your Gemfile."
22
+ end
23
+
24
+ def initialize(jwks_uri, algorithms: DEFAULT_ALGORITHMS, leeway: 0)
25
+ self.class.require_jwt!
26
+
27
+ @jwks_uri = jwks_uri
28
+ @algorithms = Array(algorithms).map(&:to_s)
29
+ @leeway = leeway
30
+ # Held only while fetching, so at most one fetch runs at a time.
31
+ # Reading the cached keys never takes it.
32
+ @fetch_mutex = Mutex.new
33
+ @jwks = nil
34
+ @fetched_at = nil
35
+ @failed_at = nil
36
+ end
37
+
38
+ def call(token, _request = nil)
39
+ payload, _header = ::JWT.decode(
40
+ token, nil, true,
41
+ algorithms: @algorithms,
42
+ jwks: method(:jwks),
43
+ required_claims: %w[exp iss],
44
+ leeway: @leeway
45
+ )
46
+ payload
47
+ rescue ::JWT::DecodeError
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ # Key loader for JWT.decode. Called with `kid_not_found: true` when the
54
+ # token's `kid` is not in the cached set.
55
+ def jwks(options = {})
56
+ if @jwks.nil?
57
+ load_initial_jwks
58
+ elsif refresh_due?(options) && @fetch_mutex.try_lock
59
+ # Only the thread that got the lock refreshes; the others keep
60
+ # using the cached keys instead of waiting for it.
61
+ begin
62
+ fetch_jwks
63
+ ensure
64
+ @fetch_mutex.unlock
65
+ end
66
+ end
67
+
68
+ @jwks
69
+ end
70
+
71
+ # With no keys yet, concurrent requests wait for one shared fetch rather
72
+ # than each fetching in turn. After a failure they fail fast until
73
+ # MIN_REFRESH_INTERVAL has passed, so a down authorization server does
74
+ # not tie up every app thread on timeouts.
75
+ def load_initial_jwks
76
+ raise_unavailable if backing_off?
77
+
78
+ @fetch_mutex.synchronize do
79
+ next if @jwks
80
+ raise_unavailable if backing_off?
81
+
82
+ fetch_jwks
83
+ end
84
+
85
+ raise_unavailable if @jwks.nil?
86
+ end
87
+
88
+ def refresh_due?(options)
89
+ return false if backing_off?
90
+
91
+ age = monotonic_now - @fetched_at
92
+ age > CACHE_TTL || (options[:kid_not_found] && age > MIN_REFRESH_INTERVAL)
93
+ end
94
+
95
+ def fetch_jwks
96
+ keys = HTTPClient.get_json(OAuth.resolve(@jwks_uri))
97
+ # @fetched_at first: readers that see @jwks expect it to be set.
98
+ @fetched_at = monotonic_now
99
+ @jwks = keys
100
+ @failed_at = nil
101
+ rescue VerificationError => e
102
+ @failed_at = monotonic_now
103
+ fallback = @jwks ? "using cached keys" : "retrying in #{MIN_REFRESH_INTERVAL}s"
104
+ MCP.logger&.warn("[OmniAgent::MCP] Could not fetch JWKS, #{fallback}: #{e.message}")
105
+ end
106
+
107
+ def backing_off?
108
+ failed_at = @failed_at
109
+ !failed_at.nil? && monotonic_now - failed_at < MIN_REFRESH_INTERVAL
110
+ end
111
+
112
+ def raise_unavailable
113
+ raise VerificationError, "JWKS from #{OAuth.resolve(@jwks_uri)} is unavailable"
114
+ end
115
+
116
+ def monotonic_now
117
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,166 @@
1
+ require "uri"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module OAuth
6
+ # Built by `authenticate :oauth, ...`. Validates bearer access tokens
7
+ # (signature or introspection, issuer, audience, expiry and scopes) and
8
+ # describes the server in Protected Resource Metadata (RFC 9728) so MCP
9
+ # clients can find the authorization server.
10
+ class ResourceServer
11
+ METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
12
+ # Seconds of clock skew tolerated when checking exp and nbf.
13
+ LEEWAY = 30
14
+
15
+ attr_reader :scopes
16
+
17
+ def initialize(resource:, authorization_servers:, scopes: [], scopes_supported: nil, audience: nil,
18
+ jwks_uri: nil, algorithms: JWTVerifier::DEFAULT_ALGORITHMS, introspection: nil,
19
+ verify_token: nil, &principal_block)
20
+ raise ArgumentError, "authenticate :oauth requires resource:" if resource.nil?
21
+ raise ArgumentError, "authenticate :oauth requires authorization_servers:" if Array(authorization_servers).empty?
22
+
23
+ validate_resource!(resource) unless resource.respond_to?(:call)
24
+
25
+ @resource = resource
26
+ @authorization_servers = authorization_servers
27
+ @scopes = Array(scopes).map(&:to_s).freeze
28
+ @scopes_supported = scopes_supported && Array(scopes_supported).map(&:to_s).freeze
29
+ @audience = audience
30
+ @principal_block = principal_block
31
+ @verifier = build_verifier(jwks_uri, algorithms, introspection, verify_token)
32
+ end
33
+
34
+ # Returns an Authenticator::Result. `error` is nil when no token was
35
+ # sent, :invalid_token or :insufficient_scope (RFC 6750 section 3.1).
36
+ def authenticate(request)
37
+ token = request.bearer_token
38
+ return failure(nil) if token.nil?
39
+
40
+ claims = verify(token, request)
41
+ return failure(:invalid_token) unless claims.is_a?(Hash)
42
+
43
+ access_token = AccessToken.new(token, claims)
44
+ return failure(:invalid_token) unless valid?(access_token)
45
+ return failure(:insufficient_scope) unless access_token.scope?(scopes)
46
+
47
+ principal = @principal_block ? @principal_block.call(access_token, request) : access_token
48
+ return failure(:invalid_token) unless principal
49
+
50
+ Authenticator::Result.new(true, principal, nil, access_token)
51
+ rescue VerificationError => e
52
+ MCP.logger&.error("[OmniAgent::MCP] OAuth token verification failed: #{e.message}")
53
+ failure(:invalid_token)
54
+ end
55
+
56
+ def metadata(resource_name: nil)
57
+ supported = @scopes_supported || scopes
58
+
59
+ {
60
+ resource: resource,
61
+ authorization_servers: authorization_servers,
62
+ scopes_supported: supported.empty? ? nil : supported,
63
+ bearer_methods_supported: [ "header" ],
64
+ resource_name: resource_name
65
+ }.compact
66
+ end
67
+
68
+ # Value of the WWW-Authenticate header for a 401 or 403 response.
69
+ def challenge(realm:, error: nil)
70
+ params = { realm: realm, resource_metadata: metadata_url }
71
+ params[:error] = error.to_s if error
72
+ params[:scope] = scopes.join(" ") unless scopes.empty?
73
+
74
+ "Bearer " + params.map { |key, value| %(#{key}="#{value}") }.join(", ")
75
+ end
76
+
77
+ # Served by RackApp under the MCP endpoint itself, so it works wherever
78
+ # the server is mounted.
79
+ def metadata_url
80
+ "#{resource.chomp('/')}#{METADATA_PATH}"
81
+ end
82
+
83
+ def resource
84
+ OAuth.resolve(@resource).to_s
85
+ end
86
+
87
+ def authorization_servers
88
+ Array(OAuth.resolve(@authorization_servers)).map(&:to_s)
89
+ end
90
+
91
+ private
92
+
93
+ def build_verifier(jwks_uri, algorithms, introspection, verify_token)
94
+ given = { jwks_uri: jwks_uri, introspection: introspection, verify_token: verify_token }.compact
95
+ unless given.size == 1
96
+ raise ArgumentError, "authenticate :oauth requires exactly one of jwks_uri:, introspection: or verify_token:"
97
+ end
98
+
99
+ if jwks_uri
100
+ JWTVerifier.new(jwks_uri, algorithms: algorithms, leeway: LEEWAY)
101
+ elsif introspection
102
+ IntrospectionVerifier.new(**introspection.transform_keys(&:to_sym))
103
+ else
104
+ raise ArgumentError, "verify_token: must respond to #call" unless verify_token.respond_to?(:call)
105
+
106
+ verify_token
107
+ end
108
+ end
109
+
110
+ def verify(token, request)
111
+ if @verifier.respond_to?(:arity) && @verifier.arity == 1
112
+ @verifier.call(token)
113
+ else
114
+ @verifier.call(token, request)
115
+ end
116
+ end
117
+
118
+ def valid?(access_token)
119
+ claims = access_token.claims
120
+
121
+ fresh?(claims) && trusted_issuer?(claims) && intended_audience?(access_token)
122
+ end
123
+
124
+ def fresh?(claims)
125
+ now = Time.now.to_i
126
+ return false if claims.key?("exp") && !(claims["exp"].is_a?(Numeric) && now < claims["exp"] + LEEWAY)
127
+ return false if claims.key?("nbf") && !(claims["nbf"].is_a?(Numeric) && now >= claims["nbf"] - LEEWAY)
128
+
129
+ true
130
+ end
131
+
132
+ # JWTs must carry `iss`; introspection responses and custom verifiers may omit it.
133
+ def trusted_issuer?(claims)
134
+ return true unless claims.key?("iss")
135
+
136
+ authorization_servers.map { |server| normalize(server) }.include?(normalize(claims["iss"]))
137
+ end
138
+
139
+ # RFC 8707: the token must have been issued for this server. Tokens
140
+ # meant for another resource are rejected, never passed through.
141
+ def intended_audience?(access_token)
142
+ expected = Array(OAuth.resolve(@audience) || resource).map { |value| normalize(value) }
143
+
144
+ access_token.audience.any? { |value| expected.include?(normalize(value)) }
145
+ end
146
+
147
+ def normalize(uri)
148
+ uri.to_s.chomp("/")
149
+ end
150
+
151
+ def validate_resource!(resource)
152
+ uri = URI.parse(resource.to_s)
153
+ return if uri.is_a?(URI::HTTP) && uri.host && uri.fragment.nil?
154
+
155
+ raise ArgumentError, "resource: must be the absolute http(s) URL of the MCP endpoint, without a fragment"
156
+ rescue URI::InvalidURIError
157
+ raise ArgumentError, "resource: #{resource.inspect} is not a valid URL"
158
+ end
159
+
160
+ def failure(error)
161
+ Authenticator::Result.new(false, nil, error, nil)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,18 @@
1
+ module OmniAgent
2
+ module MCP
3
+ # Resource server side of the MCP authorization spec (OAuth 2.1). Access
4
+ # tokens are issued by an external authorization server and validated here;
5
+ # see ResourceServer.
6
+ module OAuth
7
+ # The authorization server could not be reached or answered with something
8
+ # unusable. The request is rejected as unauthenticated.
9
+ class VerificationError < OmniAgent::MCPError; end
10
+
11
+ # Settings accept a value or a callable, so they can be read from ENV or
12
+ # credentials on every request.
13
+ def self.resolve(value)
14
+ value.respond_to?(:call) ? value.call : value
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,131 @@
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
+ METADATA_CORS_HEADERS = {
11
+ "access-control-allow-origin" => "*",
12
+ "access-control-allow-methods" => "GET, OPTIONS",
13
+ "access-control-allow-headers" => "mcp-protocol-version"
14
+ }.freeze
15
+
16
+ # Accepts the server class or its name. Passing a String defers the
17
+ # constant lookup to request time, which keeps routes reload-safe.
18
+ attr_reader :server
19
+
20
+ def initialize(server)
21
+ @server = server
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class.name} #{server}>"
26
+ end
27
+
28
+ def call(env)
29
+ server_class = MCP.resolve_server_class(@server)
30
+ request = Request.new(env)
31
+ resource_server = server_class.configured_oauth_resource_server
32
+
33
+ # Public, so clients can discover the authorization server before they have a token.
34
+ if resource_server && request.path_info == OAuth::ResourceServer::METADATA_PATH
35
+ return protected_resource_metadata(server_class, resource_server, request)
36
+ end
37
+
38
+ return forbidden unless origin_allowed?(request, server_class)
39
+
40
+ auth = Authenticator.new(server_class).authenticate(request)
41
+ return unauthorized(server_class, resource_server, auth) unless auth.authenticated?
42
+
43
+ return method_not_allowed unless request.post?
44
+
45
+ if request.protocol_version && !SUPPORTED_PROTOCOL_VERSIONS.include?(request.protocol_version)
46
+ return http_error(400, "Unsupported MCP-Protocol-Version: #{request.protocol_version}")
47
+ end
48
+
49
+ body = read_body(request)
50
+ return http_error(413, "Request body too large") if body.nil?
51
+
52
+ message = parse_json(body)
53
+ return json_response(400, parse_error_response) if message.equal?(PARSE_FAILED)
54
+
55
+ server = server_class.new(principal: auth.principal, access_token: auth.access_token, request: request, transport: :http)
56
+ response = Handler.new(server).call(message)
57
+ return [ 202, {}, [] ] if response.nil?
58
+
59
+ status = response[:error] && response[:id].nil? ? 400 : 200
60
+ json_response(status, response)
61
+ end
62
+
63
+ private
64
+
65
+ def origin_allowed?(request, server_class)
66
+ origin = request.origin
67
+ return true if origin.nil? || origin.empty?
68
+
69
+ allowed = server_class.configured_allowed_origins + Array(OmniAgent.configuration.mcp_allowed_origins)
70
+ allowed.any? do |pattern|
71
+ pattern.is_a?(Regexp) ? pattern.match?(origin) : pattern.to_s == origin
72
+ end
73
+ end
74
+
75
+ def read_body(request)
76
+ max_bytes = OmniAgent.configuration.mcp_max_request_bytes
77
+ return nil if request.content_length.to_i > max_bytes
78
+
79
+ io = request.body
80
+ return "" if io.nil?
81
+
82
+ body = io.read(max_bytes + 1).to_s
83
+ body.bytesize > max_bytes ? nil : body
84
+ end
85
+
86
+ def parse_json(body)
87
+ JSON.parse(body)
88
+ rescue JSON::ParserError
89
+ PARSE_FAILED
90
+ end
91
+
92
+ def parse_error_response
93
+ { jsonrpc: JSONRPC_VERSION, id: nil, error: { code: PARSE_ERROR, message: "Parse error" } }
94
+ end
95
+
96
+ def json_response(status, payload)
97
+ [ status, JSON_HEADERS.dup, [ JSON.generate(payload) ] ]
98
+ end
99
+
100
+ def http_error(status, message)
101
+ json_response(status, { error: message })
102
+ end
103
+
104
+ def forbidden
105
+ http_error(403, "Origin not allowed")
106
+ end
107
+
108
+ def method_not_allowed(allow = "POST")
109
+ [ 405, JSON_HEADERS.merge("allow" => allow), [ JSON.generate({ error: "Method not allowed" }) ] ]
110
+ end
111
+
112
+ def unauthorized(server_class, resource_server, auth)
113
+ realm = server_class.server_name
114
+ challenge = resource_server ? resource_server.challenge(realm: realm, error: auth.error) : %(Bearer realm="#{realm}")
115
+ status, message = auth.error == :insufficient_scope ? [ 403, "Insufficient scope" ] : [ 401, "Unauthorized" ]
116
+
117
+ [ status, JSON_HEADERS.merge("www-authenticate" => challenge), [ JSON.generate({ error: message }) ] ]
118
+ end
119
+
120
+ # RFC 9728 Protected Resource Metadata. Browser-based clients fetch it
121
+ # cross-origin, and it holds nothing secret, so any origin may read it.
122
+ def protected_resource_metadata(server_class, resource_server, request)
123
+ return [ 204, METADATA_CORS_HEADERS.dup, [] ] if request.options?
124
+ return method_not_allowed("GET") unless request.get? || request.head?
125
+
126
+ metadata = resource_server.metadata(resource_name: server_class.server_name)
127
+ [ 200, JSON_HEADERS.merge(METADATA_CORS_HEADERS), [ JSON.generate(metadata) ] ]
128
+ end
129
+ end
130
+ end
131
+ 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