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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module MCP
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []