omni_agent 0.1.10 → 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 +12 -0
- data/README.md +4 -0
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +4 -0
- data/lib/omni_agent/mcp/authenticator.rb +5 -2
- 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 +31 -7
- data/lib/omni_agent/mcp/server.rb +22 -5
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +4 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc288d35e0b6c6e5e4ea763c36d05aa37ee95d20aeb2f4b1369e15a6ba22bc92
|
|
4
|
+
data.tar.gz: 428b4d96e1b1f7ad2bf1f7cd2c99893333c0eac59d2add9a94faf18198376baa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c55eaf9a31782bb1c16c152d15627943cef09f5a179c6039590b412dba505196aae36ed325905992bd26df567c74553a83fec629c59062c2cfa5d4981a03bf4
|
|
7
|
+
data.tar.gz: 904fdc44a855a5b7cfe66817cd531cc86bd44d5a8f8bccd6b97c6bf3d313a4b4e4a7a2f18fb18305bf5ae5be4bca7424562c1e39f77ecbb12ee63bea4d00fcf3
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.11](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.10...omni_agent/v0.1.11) (2026-10-15)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **mcp:** add OAuth 2.1 authentication per the MCP authorization spec ([160c3d4](https://github.com/ACR1209/omni_agent/commit/160c3d47fb70272c58162359296c795a03b52219))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **mcp:** stop blocking requests while fetching the JWKS ([5074eb2](https://github.com/ACR1209/omni_agent/commit/5074eb2d939b2c1d6c365b15482cc9024f361527))
|
|
16
|
+
|
|
5
17
|
## [0.1.10](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.9...omni_agent/v0.1.10) (2026-10-09)
|
|
6
18
|
|
|
7
19
|
|
data/README.md
CHANGED
|
@@ -262,6 +262,10 @@ class SupportServer < OmniAgent::MCP::Server
|
|
|
262
262
|
|
|
263
263
|
authenticate :bearer, tokens: -> { ENV.fetch("SUPPORT_MCP_TOKENS", "").split(",") }
|
|
264
264
|
# or: authenticate { |request| User.find_by(api_token: request.bearer_token) }
|
|
265
|
+
# or OAuth 2.1 (MCP authorization spec), with tokens from your authorization server:
|
|
266
|
+
# authenticate :oauth, resource: "https://app.example.com/mcp/support",
|
|
267
|
+
# authorization_servers: "https://auth.example.com",
|
|
268
|
+
# jwks_uri: "https://auth.example.com/.well-known/jwks.json"
|
|
265
269
|
|
|
266
270
|
authorize_tool { |tool_class, principal| !tool_class.tags.include?(:admin) }
|
|
267
271
|
context { |principal, _request| { current_user: principal } }
|
|
@@ -101,6 +101,10 @@ module OmniAgent
|
|
|
101
101
|
|
|
102
102
|
# Comma-separated list of accepted bearer tokens.
|
|
103
103
|
authenticate :bearer, tokens: -> { ENV.fetch("#{token_env_var}", "").split(",") }
|
|
104
|
+
# Or OAuth 2.1 with tokens from your authorization server (add `gem "jwt"`):
|
|
105
|
+
# authenticate :oauth, resource: "https://app.example.com/mcp/...",
|
|
106
|
+
# authorization_servers: "https://auth.example.com",
|
|
107
|
+
# jwks_uri: "https://auth.example.com/.well-known/jwks.json"
|
|
104
108
|
|
|
105
109
|
# Hide tools per principal:
|
|
106
110
|
# authorize_tool { |tool_class, principal| true }
|
|
@@ -3,7 +3,8 @@ require "active_support/security_utils"
|
|
|
3
3
|
module OmniAgent
|
|
4
4
|
module MCP
|
|
5
5
|
class Authenticator
|
|
6
|
-
|
|
6
|
+
# error and access_token are only set by `authenticate :oauth`.
|
|
7
|
+
Result = Struct.new(:authenticated, :principal, :error, :access_token) do
|
|
7
8
|
def authenticated?
|
|
8
9
|
authenticated == true
|
|
9
10
|
end
|
|
@@ -21,7 +22,7 @@ module OmniAgent
|
|
|
21
22
|
if authentication.nil?
|
|
22
23
|
raise OmniAgent::MCPError,
|
|
23
24
|
"#{@server_class.name} does not declare authentication. " \
|
|
24
|
-
"Add `authenticate :bearer, tokens: ...`, `authenticate { |request| ... }`, " \
|
|
25
|
+
"Add `authenticate :bearer, tokens: ...`, `authenticate :oauth, ...`, `authenticate { |request| ... }`, " \
|
|
25
26
|
"or explicitly opt out with `authenticate :none`."
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -30,6 +31,8 @@ module OmniAgent
|
|
|
30
31
|
success(nil)
|
|
31
32
|
when :bearer
|
|
32
33
|
authenticate_bearer(request, authentication[:tokens])
|
|
34
|
+
when :oauth
|
|
35
|
+
authentication[:resource_server].authenticate(request)
|
|
33
36
|
when :custom
|
|
34
37
|
principal = authentication[:block].call(request)
|
|
35
38
|
principal ? success(principal) : failure
|
|
@@ -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
|
|
@@ -7,6 +7,11 @@ module OmniAgent
|
|
|
7
7
|
class RackApp
|
|
8
8
|
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
9
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
|
|
10
15
|
|
|
11
16
|
# Accepts the server class or its name. Passing a String defers the
|
|
12
17
|
# constant lookup to request time, which keeps routes reload-safe.
|
|
@@ -23,11 +28,17 @@ module OmniAgent
|
|
|
23
28
|
def call(env)
|
|
24
29
|
server_class = MCP.resolve_server_class(@server)
|
|
25
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
|
|
26
37
|
|
|
27
38
|
return forbidden unless origin_allowed?(request, server_class)
|
|
28
39
|
|
|
29
40
|
auth = Authenticator.new(server_class).authenticate(request)
|
|
30
|
-
return unauthorized(server_class) unless auth.authenticated?
|
|
41
|
+
return unauthorized(server_class, resource_server, auth) unless auth.authenticated?
|
|
31
42
|
|
|
32
43
|
return method_not_allowed unless request.post?
|
|
33
44
|
|
|
@@ -41,7 +52,7 @@ module OmniAgent
|
|
|
41
52
|
message = parse_json(body)
|
|
42
53
|
return json_response(400, parse_error_response) if message.equal?(PARSE_FAILED)
|
|
43
54
|
|
|
44
|
-
server = server_class.new(principal: auth.principal, request: request, transport: :http)
|
|
55
|
+
server = server_class.new(principal: auth.principal, access_token: auth.access_token, request: request, transport: :http)
|
|
45
56
|
response = Handler.new(server).call(message)
|
|
46
57
|
return [ 202, {}, [] ] if response.nil?
|
|
47
58
|
|
|
@@ -94,13 +105,26 @@ module OmniAgent
|
|
|
94
105
|
http_error(403, "Origin not allowed")
|
|
95
106
|
end
|
|
96
107
|
|
|
97
|
-
def method_not_allowed
|
|
98
|
-
[ 405, JSON_HEADERS.merge("allow" =>
|
|
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 }) ] ]
|
|
99
118
|
end
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) ] ]
|
|
104
128
|
end
|
|
105
129
|
end
|
|
106
130
|
end
|
|
@@ -50,8 +50,17 @@ module OmniAgent
|
|
|
50
50
|
add_tool_declaration(type: :tool, tool_class: tool_class, name: as.to_s)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def authenticate(strategy = nil, tokens: nil, &block)
|
|
54
|
-
|
|
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
|
|
55
64
|
{ strategy: :custom, block: block }
|
|
56
65
|
elsif strategy == :none
|
|
57
66
|
{ strategy: :none }
|
|
@@ -60,7 +69,7 @@ module OmniAgent
|
|
|
60
69
|
|
|
61
70
|
{ strategy: :bearer, tokens: tokens }
|
|
62
71
|
else
|
|
63
|
-
raise ArgumentError, "authenticate expects :bearer (with tokens:), :none, or a block"
|
|
72
|
+
raise ArgumentError, "authenticate expects :bearer (with tokens:), :oauth, :none, or a block"
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
|
|
@@ -93,6 +102,11 @@ module OmniAgent
|
|
|
93
102
|
def configured_stdio_principal_block; setting(:@stdio_principal_block); end
|
|
94
103
|
def configured_allowed_origins; list_setting(:@allowed_origins); end
|
|
95
104
|
|
|
105
|
+
def configured_oauth_resource_server
|
|
106
|
+
authentication = configured_authentication
|
|
107
|
+
authentication[:resource_server] if authentication && authentication[:strategy] == :oauth
|
|
108
|
+
end
|
|
109
|
+
|
|
96
110
|
# Ordered { "ToolName" => tool_class }: for each server in the
|
|
97
111
|
# inheritance chain, its <Server>::Tools namespace first, then its
|
|
98
112
|
# declared tools. Resolved on every call so lazily referenced tools
|
|
@@ -203,12 +217,14 @@ module OmniAgent
|
|
|
203
217
|
end
|
|
204
218
|
end
|
|
205
219
|
|
|
206
|
-
|
|
220
|
+
# access_token is the OAuth::AccessToken for `authenticate :oauth`, nil otherwise.
|
|
221
|
+
attr_reader :principal, :request, :transport, :access_token
|
|
207
222
|
|
|
208
|
-
def initialize(principal: nil, request: nil, transport: :http)
|
|
223
|
+
def initialize(principal: nil, request: nil, transport: :http, access_token: nil)
|
|
209
224
|
@principal = principal
|
|
210
225
|
@request = request
|
|
211
226
|
@transport = transport
|
|
227
|
+
@access_token = access_token
|
|
212
228
|
end
|
|
213
229
|
|
|
214
230
|
def visible_tools
|
|
@@ -227,6 +243,7 @@ module OmniAgent
|
|
|
227
243
|
|
|
228
244
|
def tool_context
|
|
229
245
|
base = { mcp_principal: principal, mcp_request: request }
|
|
246
|
+
base[:mcp_access_token] = access_token if access_token
|
|
230
247
|
context_block = self.class.configured_context_block
|
|
231
248
|
return base unless context_block
|
|
232
249
|
|
data/lib/omni_agent/version.rb
CHANGED
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(
|
|
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
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omni_agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ACR1209
|
|
@@ -85,6 +85,12 @@ files:
|
|
|
85
85
|
- lib/omni_agent/mcp.rb
|
|
86
86
|
- lib/omni_agent/mcp/authenticator.rb
|
|
87
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
|
|
88
94
|
- lib/omni_agent/mcp/rack_app.rb
|
|
89
95
|
- lib/omni_agent/mcp/request.rb
|
|
90
96
|
- lib/omni_agent/mcp/routing.rb
|