keycardai-mcp 0.1.0
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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +9 -0
- data/README.md +98 -0
- data/lib/keycardai/mcp/auth_provider.rb +138 -0
- data/lib/keycardai/mcp/metadata_app.rb +117 -0
- data/lib/keycardai/mcp/rack_support.rb +50 -0
- data/lib/keycardai/mcp/require_bearer_auth.rb +71 -0
- data/lib/keycardai/mcp/version.rb +7 -0
- data/lib/keycardai/mcp.rb +40 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 598ccfb7138983e92a8e17d1b139689b313a9e7b2ebc4c0e422a47c9730f73a8
|
|
4
|
+
data.tar.gz: 5f0828148a2565f81fa33574639418aeae0c0f64540cbda9e3e20cf7749070fe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 38a310adbe756ab5f33bd3989235cdaa6873cb89b5b99e18e342f56d881d032ed461c2209988f45b0952d77243631c997f401e60f85245a0a045fad031c1a8e3
|
|
7
|
+
data.tar.gz: 63256d53e222d04096c844e371721341c44891e42bdd1dd3202ad405d216908500f658f8380a294a19236231fd639895ae25c028d710a7c1f1ddc9e46b27a6e6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Entries from 0.2.0 onward are generated by commitizen on each bump. This first
|
|
4
|
+
one is written by hand because 0.1.0 is published from a baseline tag rather
|
|
5
|
+
than from a bump.
|
|
6
|
+
|
|
7
|
+
## 0.1.0
|
|
8
|
+
|
|
9
|
+
Initial release.
|
|
10
|
+
|
|
11
|
+
Keycard integration for MCP servers at the Rack seam: fail-closed bearer
|
|
12
|
+
verification with RFC 6750 challenges, OAuth metadata endpoints (RFC 9728
|
|
13
|
+
protected-resource with path insertion, an RFC 8414 authorization-server proxy,
|
|
14
|
+
and JWKS), and an AuthProvider whose `grant` middleware exchanges the caller's
|
|
15
|
+
token per resource into an AccessContext. Wraps no MCP SDK.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Keycard Labs, inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# keycardai-mcp
|
|
2
|
+
|
|
3
|
+
Keycard integration for MCP servers in Ruby, attached at the Rack seam.
|
|
4
|
+
|
|
5
|
+
> **Preview.** APIs may change between minor versions while the surface settles.
|
|
6
|
+
> Conformance against the cross-SDK contract is tracked in the
|
|
7
|
+
> [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bundle add keycardai-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
What it ships (per [keycard-sdk-spec](https://github.com/keycardai/keycard-sdk-spec)):
|
|
14
|
+
|
|
15
|
+
- Bearer-token verification middleware: fail-closed 401 with an RFC 6750
|
|
16
|
+
`WWW-Authenticate` challenge advertising `resource_metadata`
|
|
17
|
+
- OAuth metadata endpoints: RFC 9728 protected-resource metadata with path
|
|
18
|
+
insertion, RFC 8414 authorization-server metadata proxy, `/.well-known/jwks.json`
|
|
19
|
+
- AuthProvider: `grant(resources)` middleware for delegated token exchange
|
|
20
|
+
(RFC 8693) plus imperative `exchange_tokens`
|
|
21
|
+
- Rack env accessors for the verified `AuthInfo` and the `AccessContext`
|
|
22
|
+
|
|
23
|
+
This gem wraps no MCP SDK. It works with any Rack app; compatibility with
|
|
24
|
+
servers built on the official [`mcp` gem](https://github.com/modelcontextprotocol/ruby-sdk)
|
|
25
|
+
is proven by the example server in the repo's `examples/` directory.
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
### Protect an endpoint
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "keycardai/mcp"
|
|
33
|
+
|
|
34
|
+
zone_url = ENV.fetch("KEYCARD_URL")
|
|
35
|
+
|
|
36
|
+
verifier = Keycardai::OAuth::TokenVerifier.new(issuers: zone_url)
|
|
37
|
+
metadata = Keycardai::MCP::MetadataApp.new(
|
|
38
|
+
issuer: zone_url,
|
|
39
|
+
resource_name: "mcp-server-ruby",
|
|
40
|
+
scopes_supported: ["mcp:tools"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
protected_mcp = Keycardai::MCP::RequireBearerAuth.new(
|
|
44
|
+
your_mcp_endpoint, verifier: verifier, required_scopes: ["mcp:tools"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
run lambda { |env|
|
|
48
|
+
case env["PATH_INFO"]
|
|
49
|
+
when %r{\A/\.well-known/} then metadata.call(env)
|
|
50
|
+
when "/mcp" then protected_mcp.call(env)
|
|
51
|
+
else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']]
|
|
52
|
+
end
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
An unauthenticated request gets a 401 with an RFC 6750 `WWW-Authenticate`
|
|
57
|
+
challenge pointing at `resource_metadata`, which is what lets a client discover
|
|
58
|
+
where to authenticate. `MetadataApp` serves the RFC 9728 protected-resource
|
|
59
|
+
document, proxies the zone's RFC 8414 authorization-server metadata, and serves
|
|
60
|
+
`/.well-known/jwks.json`.
|
|
61
|
+
|
|
62
|
+
Inside the handler, the verified token is on the Rack env:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
auth = Keycardai::MCP.auth_info(env)
|
|
66
|
+
auth.subject
|
|
67
|
+
auth.scopes
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Get tokens for downstream resources
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
provider = Keycardai::MCP::AuthProvider.new(
|
|
74
|
+
zone_url: zone_url,
|
|
75
|
+
client_id: ENV.fetch("KEYCARD_CLIENT_ID"),
|
|
76
|
+
client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
use provider.grant(["https://api.github.com", "https://slack.com/api"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
By the time the handler runs, the caller's token has been exchanged once per
|
|
83
|
+
resource and the results are on the env:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
context = Keycardai::MCP.access_context(env)
|
|
87
|
+
context.access("https://api.github.com") # raises if that resource failed
|
|
88
|
+
context.status # "success", "partial_error", or "error"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A single resource failing does not abort the request; it lands on the context.
|
|
92
|
+
A missing verified token does abort, 401, before any exchange is attempted.
|
|
93
|
+
Stacked `grant` calls merge into one context.
|
|
94
|
+
|
|
95
|
+
To act as a named user instead of the caller, pass `user_identifier:` (a string,
|
|
96
|
+
or a callable that receives the Rack env). For an imperative call outside the
|
|
97
|
+
middleware, `provider.exchange_tokens(token, resources)` returns the same
|
|
98
|
+
`AccessContext`.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keycardai
|
|
4
|
+
module MCP
|
|
5
|
+
# The delegated-access provider for an MCP server: holds the zone and the
|
|
6
|
+
# application credential, builds the inbound TokenVerifier, and exposes
|
|
7
|
+
# grant middleware plus imperative token exchange. Multi-zone is inferred
|
|
8
|
+
# from a multi-zone credential (the credential is self-describing).
|
|
9
|
+
#
|
|
10
|
+
# provider = Keycardai::MCP::AuthProvider.new(
|
|
11
|
+
# zone_url: "https://acme.keycard.cloud",
|
|
12
|
+
# credential: Keycardai::OAuth::ClientSecret.new(client_id, client_secret),
|
|
13
|
+
# )
|
|
14
|
+
# use Keycardai::MCP::RequireBearerAuth, verifier: provider.token_verifier
|
|
15
|
+
# use provider.grant("https://api.example.com")
|
|
16
|
+
class AuthProvider
|
|
17
|
+
# @param zone_url [String] the zone's issuer URL
|
|
18
|
+
# @param credential [Object, nil] an application credential; a
|
|
19
|
+
# multi-zone ClientSecret switches the provider to multi-zone
|
|
20
|
+
# @param client_id [String, nil] shared-secret pair alternative
|
|
21
|
+
# @param client_secret [String, nil]
|
|
22
|
+
# @param audiences [String, Array<String>, nil] enforced on inbound tokens
|
|
23
|
+
# @param http_client [#get, #post_form] pluggable transport
|
|
24
|
+
# @param timeout [Numeric, nil]
|
|
25
|
+
def initialize(zone_url:, credential: nil, client_id: nil, client_secret: nil, audiences: nil,
|
|
26
|
+
http_client: Keycardai::OAuth::HTTP::NetHTTPClient.new, timeout: nil)
|
|
27
|
+
@zone_url = zone_url
|
|
28
|
+
@credential = credential || (client_id ? Keycardai::OAuth::ClientSecret.new(client_id, client_secret) : nil)
|
|
29
|
+
@audiences = audiences
|
|
30
|
+
@http_client = http_client
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The issuers this provider trusts: the zone URL, plus every zone of a
|
|
36
|
+
# multi-zone credential.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
def issuers
|
|
40
|
+
zones = @credential.respond_to?(:multi_zone?) && @credential.multi_zone? ? @credential.issuers : []
|
|
41
|
+
([@zone_url] + zones).uniq
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The inbound bearer-token verifier for this provider's zone(s).
|
|
45
|
+
#
|
|
46
|
+
# @return [Keycardai::OAuth::TokenVerifier]
|
|
47
|
+
def token_verifier
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@token_verifier ||= Keycardai::OAuth::TokenVerifier.new(
|
|
50
|
+
issuers: issuers, audiences: @audiences, http_client: @http_client
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Grant middleware: declare the downstream resources a route needs, and
|
|
56
|
+
# the caller's verified token is exchanged for one token per resource
|
|
57
|
+
# before the handler runs. Results and per-resource errors land on the
|
|
58
|
+
# AccessContext in the Rack env (Keycardai::MCP.access_context); a
|
|
59
|
+
# per-resource failure never aborts the request. Stacked grants merge
|
|
60
|
+
# into one context. A missing verified token is rejected 401 fail-fast.
|
|
61
|
+
#
|
|
62
|
+
# @param resources [String, Array<String>]
|
|
63
|
+
# @param user_identifier [String, #call, nil] impersonation target; a
|
|
64
|
+
# callable receives the Rack env and returns the user id
|
|
65
|
+
# @param request_scopes [String, Hash{String => String}, nil]
|
|
66
|
+
# @return [Class] a Rack middleware class for `use`
|
|
67
|
+
def grant(resources, user_identifier: nil, request_scopes: nil)
|
|
68
|
+
provider = self
|
|
69
|
+
Class.new(Grant) do
|
|
70
|
+
define_method(:initialize) do |app|
|
|
71
|
+
super(app, provider: provider, resources: Array(resources),
|
|
72
|
+
user_identifier: user_identifier, request_scopes: request_scopes)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Imperative exchange: the caller's token for one token per resource.
|
|
78
|
+
#
|
|
79
|
+
# @return [Keycardai::OAuth::AccessContext]
|
|
80
|
+
def exchange_tokens(subject_token, resources, access_context: Keycardai::OAuth::AccessContext.new,
|
|
81
|
+
user_identifier: nil, request_scopes: nil, issuer: nil)
|
|
82
|
+
Keycardai::OAuth.exchange_tokens_for_resources(
|
|
83
|
+
client: exchange_client, resources: Array(resources), subject_token: subject_token,
|
|
84
|
+
access_context: access_context, user_identifier: user_identifier,
|
|
85
|
+
request_scopes: request_scopes, issuer: issuer
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Zone-selected imperative exchange for multi-zone providers.
|
|
90
|
+
#
|
|
91
|
+
# @return [Keycardai::OAuth::AccessContext]
|
|
92
|
+
def exchange_tokens_for_zone(issuer, subject_token, resources, **)
|
|
93
|
+
exchange_tokens(subject_token, resources, issuer: issuer, **)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def exchange_client
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@exchange_client ||= Keycardai::OAuth::TokenExchangeClient.new(
|
|
101
|
+
issuer: @zone_url, credential: @credential, http_client: @http_client, timeout: @timeout
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The middleware realizing a grant declaration. Not used directly;
|
|
107
|
+
# constructed through AuthProvider#grant.
|
|
108
|
+
class Grant
|
|
109
|
+
def initialize(app, provider:, resources:, user_identifier:, request_scopes:)
|
|
110
|
+
@app = app
|
|
111
|
+
@provider = provider
|
|
112
|
+
@resources = resources
|
|
113
|
+
@user_identifier = user_identifier
|
|
114
|
+
@request_scopes = request_scopes
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def call(env)
|
|
118
|
+
auth_info = env[ENV_AUTH_INFO]
|
|
119
|
+
return RackSupport.challenge_response(env, status: 401) if auth_info.nil?
|
|
120
|
+
|
|
121
|
+
context = env[ENV_ACCESS_CONTEXT] || Keycardai::OAuth::AccessContext.new
|
|
122
|
+
@provider.exchange_tokens(
|
|
123
|
+
auth_info.token, @resources,
|
|
124
|
+
access_context: context, user_identifier: resolve_user(env), request_scopes: @request_scopes
|
|
125
|
+
)
|
|
126
|
+
env[ENV_ACCESS_CONTEXT] = context
|
|
127
|
+
@app.call(env)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def resolve_user(env)
|
|
133
|
+
@user_identifier.respond_to?(:call) ? @user_identifier.call(env) : @user_identifier
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Keycardai
|
|
6
|
+
module MCP
|
|
7
|
+
# Rack app serving the resource server's OAuth discovery surface
|
|
8
|
+
# (RFC 9728 / RFC 8414): the protected-resource metadata (with path
|
|
9
|
+
# insertion for sub-path mounts), a server-side proxy of the zone's
|
|
10
|
+
# authorization-server metadata (with the resource= rewrite shim for
|
|
11
|
+
# pre-RFC-8707 MCP clients), and optionally the server's own public JWKS.
|
|
12
|
+
# Responses carry permissive CORS so browser clients can read them.
|
|
13
|
+
#
|
|
14
|
+
# run Keycardai::MCP::MetadataApp.new(issuer: zone_url, scopes_supported: ["mcp:tools"])
|
|
15
|
+
class MetadataApp
|
|
16
|
+
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
|
17
|
+
AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server"
|
|
18
|
+
JWKS_PATH = "/.well-known/jwks.json"
|
|
19
|
+
LEGACY_MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
20
|
+
|
|
21
|
+
# @param issuer [String] the zone's issuer URL
|
|
22
|
+
# @param scopes_supported [Array<String>, nil]
|
|
23
|
+
# @param resource_name [String, nil]
|
|
24
|
+
# @param resource_documentation [String, nil]
|
|
25
|
+
# @param public_jwks [Hash, nil] served at /.well-known/jwks.json when set
|
|
26
|
+
# @param http_client [#get] transport for the AS-metadata proxy
|
|
27
|
+
# @param timeout [Numeric] upstream fetch timeout
|
|
28
|
+
def initialize(issuer:, scopes_supported: nil, resource_name: nil, resource_documentation: nil,
|
|
29
|
+
public_jwks: nil, http_client: Keycardai::OAuth::HTTP::NetHTTPClient.new, timeout: 10)
|
|
30
|
+
raise Keycardai::OAuth::ConfigurationError, "MetadataApp requires an issuer" if issuer.nil? || issuer.empty?
|
|
31
|
+
|
|
32
|
+
@issuer = issuer
|
|
33
|
+
@scopes_supported = scopes_supported
|
|
34
|
+
@resource_name = resource_name
|
|
35
|
+
@resource_documentation = resource_documentation
|
|
36
|
+
@public_jwks = public_jwks
|
|
37
|
+
@http_client = http_client
|
|
38
|
+
@timeout = timeout
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(env)
|
|
42
|
+
return preflight_response if env["REQUEST_METHOD"] == "OPTIONS"
|
|
43
|
+
|
|
44
|
+
path = env["PATH_INFO"].to_s
|
|
45
|
+
case path
|
|
46
|
+
when %r{\A#{Regexp.escape(PROTECTED_RESOURCE_PATH)}(/.*)?\z}
|
|
47
|
+
protected_resource_response(env, Regexp.last_match(1).to_s)
|
|
48
|
+
when AUTHORIZATION_SERVER_PATH
|
|
49
|
+
authorization_server_response(env)
|
|
50
|
+
when JWKS_PATH
|
|
51
|
+
jwks_response
|
|
52
|
+
else
|
|
53
|
+
RackSupport.json_response({ "error" => "not_found" }, status: 404)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def preflight_response
|
|
60
|
+
[204,
|
|
61
|
+
{ "access-control-allow-origin" => "*", "access-control-allow-methods" => "GET, OPTIONS",
|
|
62
|
+
"access-control-allow-headers" => "MCP-Protocol-Version" },
|
|
63
|
+
[]]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# RFC 9728, with path insertion: a resource mounted at /mcp is described
|
|
67
|
+
# at /.well-known/oauth-protected-resource/mcp and identified as
|
|
68
|
+
# origin + /mcp.
|
|
69
|
+
def protected_resource_response(env, resource_path)
|
|
70
|
+
origin = RackSupport.origin(env)
|
|
71
|
+
legacy = env["HTTP_MCP_PROTOCOL_VERSION"] == LEGACY_MCP_PROTOCOL_VERSION
|
|
72
|
+
document = {
|
|
73
|
+
"resource" => "#{origin}#{resource_path}",
|
|
74
|
+
"authorization_servers" => [legacy ? origin : @issuer],
|
|
75
|
+
"bearer_methods_supported" => ["header"],
|
|
76
|
+
"scopes_supported" => @scopes_supported,
|
|
77
|
+
"resource_name" => @resource_name,
|
|
78
|
+
"resource_documentation" => @resource_documentation,
|
|
79
|
+
"jwks_uri" => @public_jwks ? "#{origin}#{JWKS_PATH}" : nil
|
|
80
|
+
}.compact
|
|
81
|
+
RackSupport.json_response(document)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# RFC 8414 proxy of the zone's metadata, rewriting the
|
|
85
|
+
# authorization_endpoint to carry resource=<origin> (replacing any
|
|
86
|
+
# resource parameter already present in the upstream value).
|
|
87
|
+
def authorization_server_response(env)
|
|
88
|
+
metadata = Keycardai::OAuth.fetch_authorization_server_metadata(
|
|
89
|
+
@issuer, http_client: @http_client, timeout: @timeout
|
|
90
|
+
)
|
|
91
|
+
document = metadata.raw.dup
|
|
92
|
+
if document["authorization_endpoint"]
|
|
93
|
+
document["authorization_endpoint"] =
|
|
94
|
+
with_resource_param(document["authorization_endpoint"], RackSupport.origin(env))
|
|
95
|
+
end
|
|
96
|
+
RackSupport.json_response(document)
|
|
97
|
+
rescue Keycardai::Error
|
|
98
|
+
RackSupport.json_response({ "error" => "bad_gateway" }, status: 502)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def with_resource_param(endpoint, resource)
|
|
102
|
+
uri = URI(endpoint)
|
|
103
|
+
params = URI.decode_www_form(uri.query.to_s)
|
|
104
|
+
params.delete_if { |pair| pair.first == "resource" }
|
|
105
|
+
params << ["resource", resource]
|
|
106
|
+
uri.query = URI.encode_www_form(params)
|
|
107
|
+
uri.to_s
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def jwks_response
|
|
111
|
+
return RackSupport.json_response({ "error" => "not_found" }, status: 404) unless @public_jwks
|
|
112
|
+
|
|
113
|
+
RackSupport.json_response(@public_jwks)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Keycardai
|
|
6
|
+
module MCP
|
|
7
|
+
# Shared Rack plumbing: request-origin derivation and the RFC 6750
|
|
8
|
+
# challenge responses. Not public API.
|
|
9
|
+
module RackSupport
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The request origin (scheme + host [+ non-default port]).
|
|
13
|
+
def origin(env)
|
|
14
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
15
|
+
host = env["HTTP_HOST"]
|
|
16
|
+
unless host
|
|
17
|
+
host = env["SERVER_NAME"].to_s
|
|
18
|
+
port = env["SERVER_PORT"].to_s
|
|
19
|
+
default = scheme == "https" ? "443" : "80"
|
|
20
|
+
host = "#{host}:#{port}" unless port.empty? || port == default
|
|
21
|
+
end
|
|
22
|
+
"#{scheme}://#{host}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The RFC 9728 protected-resource metadata URL advertised in challenges.
|
|
26
|
+
def resource_metadata_url(env)
|
|
27
|
+
"#{origin(env)}/.well-known/oauth-protected-resource"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An RFC 6750 challenge response. A nil error code produces the bare
|
|
31
|
+
# challenge used for missing credentials.
|
|
32
|
+
def challenge_response(env, status:, error: nil, description: nil)
|
|
33
|
+
params = []
|
|
34
|
+
params << %(error="#{error}") if error
|
|
35
|
+
params << %(error_description="#{description}") if description
|
|
36
|
+
params << %(resource_metadata="#{resource_metadata_url(env)}")
|
|
37
|
+
body = JSON.dump({ "error" => error || "unauthorized" })
|
|
38
|
+
[status,
|
|
39
|
+
{ "content-type" => "application/json", "www-authenticate" => "Bearer #{params.join(", ")}" },
|
|
40
|
+
[body]]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def json_response(payload, status: 200, headers: {})
|
|
44
|
+
[status,
|
|
45
|
+
{ "content-type" => "application/json", "access-control-allow-origin" => "*" }.merge(headers),
|
|
46
|
+
[JSON.dump(payload)]]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keycardai
|
|
4
|
+
module MCP
|
|
5
|
+
# Rack middleware verifying the Authorization bearer token against the
|
|
6
|
+
# zone's JWKS. Fail-closed: unauthenticated requests are rejected with an
|
|
7
|
+
# RFC 6750 challenge advertising the RFC 9728 resource_metadata URL, and
|
|
8
|
+
# never reach the app. On success the verified AccessToken is stored in
|
|
9
|
+
# the Rack env (Keycardai::MCP.auth_info).
|
|
10
|
+
#
|
|
11
|
+
# use Keycardai::MCP::RequireBearerAuth,
|
|
12
|
+
# verifier: Keycardai::OAuth::TokenVerifier.new(issuers: zone_url),
|
|
13
|
+
# required_scopes: ["mcp:tools"]
|
|
14
|
+
#
|
|
15
|
+
# Route-level gating is this same middleware applied per route with a
|
|
16
|
+
# required_scopes set; every configured scope must be present (all-of), a
|
|
17
|
+
# missing one yielding 403 insufficient_scope.
|
|
18
|
+
class RequireBearerAuth
|
|
19
|
+
# @param app [#call] the downstream Rack app
|
|
20
|
+
# @param verifier [#verify_token] a TokenVerifier
|
|
21
|
+
# @param required_scopes [Array<String>, String, nil] all-of scope set
|
|
22
|
+
# @raise [Keycardai::OAuth::ConfigurationError] nil verifier; an auth
|
|
23
|
+
# boundary with no verifier is a programming error caught at boot
|
|
24
|
+
def initialize(app, verifier:, required_scopes: nil)
|
|
25
|
+
raise Keycardai::OAuth::ConfigurationError, "RequireBearerAuth requires a verifier" if verifier.nil?
|
|
26
|
+
|
|
27
|
+
@app = app
|
|
28
|
+
@verifier = verifier
|
|
29
|
+
@required_scopes = Array(required_scopes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
header = env["HTTP_AUTHORIZATION"]
|
|
34
|
+
return RackSupport.challenge_response(env, status: 401) if header.nil? || header.empty?
|
|
35
|
+
|
|
36
|
+
parts = header.split
|
|
37
|
+
return [400, { "content-type" => "application/json" }, ['{"error":"invalid_request"}']] if parts.length != 2
|
|
38
|
+
|
|
39
|
+
scheme, token = parts
|
|
40
|
+
unless scheme.casecmp("bearer").zero?
|
|
41
|
+
return RackSupport.challenge_response(env, status: 401, error: "invalid_token",
|
|
42
|
+
description: "unsupported authorization scheme")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
authorize(env, token)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def authorize(env, token)
|
|
51
|
+
access_token, failure = verify(token)
|
|
52
|
+
return RackSupport.challenge_response(env, status: 401, error: "invalid_token", description: failure) if failure
|
|
53
|
+
|
|
54
|
+
missing = @required_scopes - access_token.scopes
|
|
55
|
+
unless missing.empty?
|
|
56
|
+
return RackSupport.challenge_response(env, status: 403, error: "insufficient_scope",
|
|
57
|
+
description: "missing required scopes: #{missing.join(" ")}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
env[ENV_AUTH_INFO] = access_token
|
|
61
|
+
@app.call(env)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def verify(token)
|
|
65
|
+
[@verifier.verify_token(token), nil]
|
|
66
|
+
rescue Keycardai::Error => e
|
|
67
|
+
[nil, e.message]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keycardai/oauth"
|
|
4
|
+
require_relative "mcp/version"
|
|
5
|
+
require_relative "mcp/rack_support"
|
|
6
|
+
require_relative "mcp/require_bearer_auth"
|
|
7
|
+
require_relative "mcp/metadata_app"
|
|
8
|
+
require_relative "mcp/auth_provider"
|
|
9
|
+
|
|
10
|
+
module Keycardai
|
|
11
|
+
# Keycard integration for MCP servers, attached at the Rack seam: bearer
|
|
12
|
+
# middleware (RFC 6750), OAuth metadata endpoints (RFC 9728 / RFC 8414), and
|
|
13
|
+
# an AuthProvider for delegated token exchange. Handlers read the verified
|
|
14
|
+
# identity and exchanged tokens from the Rack env.
|
|
15
|
+
#
|
|
16
|
+
# Contract: https://github.com/keycardai/keycard-sdk-spec
|
|
17
|
+
module MCP
|
|
18
|
+
# Rack env key holding the verified inbound token's AuthInfo.
|
|
19
|
+
ENV_AUTH_INFO = "keycardai.auth_info"
|
|
20
|
+
|
|
21
|
+
# Rack env key holding the AccessContext produced by grant middleware.
|
|
22
|
+
ENV_ACCESS_CONTEXT = "keycardai.access_context"
|
|
23
|
+
|
|
24
|
+
# The verified inbound token for this request, set by RequireBearerAuth.
|
|
25
|
+
#
|
|
26
|
+
# @param env [Hash] the Rack env
|
|
27
|
+
# @return [Keycardai::OAuth::AccessToken, nil]
|
|
28
|
+
def self.auth_info(env)
|
|
29
|
+
env[ENV_AUTH_INFO]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The delegated tokens granted for this request, set by grant middleware.
|
|
33
|
+
#
|
|
34
|
+
# @param env [Hash] the Rack env
|
|
35
|
+
# @return [Keycardai::OAuth::AccessContext, nil]
|
|
36
|
+
def self.access_context(env)
|
|
37
|
+
env[ENV_ACCESS_CONTEXT]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: keycardai-mcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Keycard
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: keycardai-oauth
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.2'
|
|
40
|
+
description: Rack bearer-token middleware (RFC 6750), OAuth protected-resource and
|
|
41
|
+
authorization-server metadata endpoints (RFC 9728 / RFC 8414), and an AuthProvider
|
|
42
|
+
for delegated token exchange in MCP servers. Wraps no MCP SDK; attaches to any Rack
|
|
43
|
+
app, including servers built on the official mcp gem.
|
|
44
|
+
email:
|
|
45
|
+
- support@keycard.ai
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE
|
|
52
|
+
- README.md
|
|
53
|
+
- lib/keycardai/mcp.rb
|
|
54
|
+
- lib/keycardai/mcp/auth_provider.rb
|
|
55
|
+
- lib/keycardai/mcp/metadata_app.rb
|
|
56
|
+
- lib/keycardai/mcp/rack_support.rb
|
|
57
|
+
- lib/keycardai/mcp/require_bearer_auth.rb
|
|
58
|
+
- lib/keycardai/mcp/version.rb
|
|
59
|
+
homepage: https://github.com/keycardai/ruby-sdk
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
source_code_uri: https://github.com/keycardai/ruby-sdk/tree/main/mcp
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '3.2'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 3.6.9
|
|
80
|
+
specification_version: 4
|
|
81
|
+
summary: Keycard MCP server integration at the Rack seam
|
|
82
|
+
test_files: []
|