omni_agent 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +50 -0
- data/exe/omni_agent +22 -6
- data/lib/generators/omni_agent/agent/agent_generator.rb +1 -0
- data/lib/generators/omni_agent/install/install_generator.rb +1 -0
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +155 -0
- data/lib/omni_agent/agent.rb +33 -29
- data/lib/omni_agent/configuration.rb +4 -1
- data/lib/omni_agent/engine.rb +4 -0
- data/lib/omni_agent/errors.rb +2 -0
- data/lib/omni_agent/mcp/authenticator.rb +85 -0
- data/lib/omni_agent/mcp/handler.rb +128 -0
- data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
- data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
- data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
- data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
- data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
- data/lib/omni_agent/mcp/oauth.rb +18 -0
- data/lib/omni_agent/mcp/rack_app.rb +131 -0
- data/lib/omni_agent/mcp/request.rb +24 -0
- data/lib/omni_agent/mcp/routing.rb +57 -0
- data/lib/omni_agent/mcp/server.rb +255 -0
- data/lib/omni_agent/mcp/stdio.rb +56 -0
- data/lib/omni_agent/mcp/tool_adapter.rb +45 -0
- data/lib/omni_agent/mcp.rb +31 -0
- data/lib/omni_agent/tool.rb +25 -0
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +4 -1
- data/lib/tasks/omni_agent_mcp_tasks.rake +9 -0
- metadata +18 -1
|
@@ -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
|