keycardai-mcp 0.1.0 → 0.1.2
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 +47 -0
- data/README.md +4 -2
- data/lib/keycardai/mcp/metadata_app.rb +8 -27
- data/lib/keycardai/mcp/require_bearer_auth.rb +17 -1
- data/lib/keycardai/mcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0880d377eb3029f73175e4ba00d78bca2c6bbdaf0c7ace5a8012efc44416033e'
|
|
4
|
+
data.tar.gz: 73ba72fadb3d1f534847f171f46fed3c5db52744521f45658e9c1960979e4f21
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: daf1d161bb279219b0c7abc1c2bb801fb6f7e77507b64309425722bb6449e8ca76b2191115a9571b3db1cfd50155e7ae3ec85994ab5bac2659906520d68b814a
|
|
7
|
+
data.tar.gz: 104a2890048bfbda6935b39339946a82722e6c918dccda8b6f699aba27e8cea98bd243eae8b9d76a37e25f68b8a116dfab58c5f14f251bd033436a32582b5b01
|
data/CHANGELOG.md
CHANGED
|
@@ -13,3 +13,50 @@ verification with RFC 6750 challenges, OAuth metadata endpoints (RFC 9728
|
|
|
13
13
|
protected-resource with path insertion, an RFC 8414 authorization-server proxy,
|
|
14
14
|
and JWKS), and an AuthProvider whose `grant` middleware exchanges the caller's
|
|
15
15
|
token per resource into an AccessContext. Wraps no MCP SDK.
|
|
16
|
+
|
|
17
|
+
## 0.1.2-keycardai-mcp (2026-10-12)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
- fix(keycardai-mcp): RequireBearerAuth answers 503 when the zone's signing keys can't be fetched (SDK-20) (#44)
|
|
21
|
+
- RequireBearerAuth answers 503 {"error":"temporarily_unavailable"} with no challenge when verification fails with JWKSFetchError, JWKSDiscoveryError, HTTPError, or NetworkError, because the token was never judged. A kid absent from a JWKS that loaded, and any other invalid token, stays 401 invalid_token.
|
|
22
|
+
|
|
23
|
+
## 0.1.1-keycardai-mcp (2026-09-16)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
- fix(keycardai-mcp): retire the 2025-03-26 metadata rewrites (SDK-3) (#37)
|
|
27
|
+
- MetadataApp no longer rewrites authorization_servers to the request origin
|
|
28
|
+
for MCP-Protocol-Version: 2025-03-26 and no longer appends resource=<origin>
|
|
29
|
+
to the proxied authorization_endpoint. Both documents match keycard-sdk-spec
|
|
30
|
+
oauth-metadata-endpoints spec-version 2.
|
|
31
|
+
- Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
|
|
32
|
+
Co-authored-by: Larry Osakwe <larry@keycard.ai>
|
|
33
|
+
|
|
34
|
+
## 0.1.0-keycardai-mcp (2026-08-19)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
- feat(keycardai-mcp): Rack bearer middleware, metadata endpoints, AuthProvider grant (#8)
|
|
38
|
+
- Phase 2: the MCP server integration at the Rack seam, implementing
|
|
39
|
+
specs/server-bearer-auth/{bearer-token-verification-middleware,
|
|
40
|
+
oauth-metadata-endpoints,route-level-auth-gating}.md and
|
|
41
|
+
specs/delegated-access/grant-decorator.md:
|
|
42
|
+
- - RequireBearerAuth: fail-closed Rack middleware; 401 RFC 6750
|
|
43
|
+
challenge with the RFC 9728 resource_metadata URL, 400 on malformed
|
|
44
|
+
headers, 401 invalid_token on non-bearer schemes (TS/Go position),
|
|
45
|
+
403 insufficient_scope on missing required_scopes (all-of; gating is
|
|
46
|
+
this middleware re-applied per route, the TS/Go idiom), verified
|
|
47
|
+
AccessToken in the Rack env, construction error on a nil verifier
|
|
48
|
+
- MetadataApp: RFC 9728 protected-resource metadata with path
|
|
49
|
+
insertion, the 2025-03-26 MCP-version shim, RFC 8414 AS proxy with
|
|
50
|
+
the resource=<origin> rewrite (replace, not append), optional
|
|
51
|
+
/.well-known/jwks.json, permissive CORS + preflight, 502 on upstream
|
|
52
|
+
failure
|
|
53
|
+
- AuthProvider: zone + credential holder; token_verifier with
|
|
54
|
+
multi-zone issuers inferred from a self-describing credential;
|
|
55
|
+
grant(resources, user_identifier:, request_scopes:) middleware that
|
|
56
|
+
fails fast 401 without a verified token, never aborts on per-resource
|
|
57
|
+
errors, and merges stacked grants into one AccessContext;
|
|
58
|
+
exchange_tokens + exchange_tokens_for_zone
|
|
59
|
+
- Env accessors Keycardai::MCP.auth_info / .access_context
|
|
60
|
+
- No MCP SDK dependency, per the plan: compatibility with the official
|
|
61
|
+
mcp gem is proven by the phase 4 example server. Conformance suites map
|
|
62
|
+
to all four spec Testing tables (28 examples).
|
data/README.md
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Keycard integration for MCP servers in Ruby, attached at the Rack seam.
|
|
4
4
|
|
|
5
|
-
> **Preview.** APIs may change between minor versions while the surface settles.
|
|
6
5
|
> Conformance against the cross-SDK contract is tracked in the
|
|
7
6
|
> [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
|
|
8
7
|
|
|
@@ -32,8 +31,11 @@ is proven by the example server in the repo's `examples/` directory.
|
|
|
32
31
|
require "keycardai/mcp"
|
|
33
32
|
|
|
34
33
|
zone_url = ENV.fetch("KEYCARD_URL")
|
|
34
|
+
# This server's registered Resource identifier. Leaving audiences: unset
|
|
35
|
+
# accepts tokens minted for any resource in the zone and warns once at boot.
|
|
36
|
+
resource_id = ENV.fetch("KEYCARD_RESOURCE_ID")
|
|
35
37
|
|
|
36
|
-
verifier = Keycardai::OAuth::TokenVerifier.new(issuers: zone_url)
|
|
38
|
+
verifier = Keycardai::OAuth::TokenVerifier.new(issuers: zone_url, audiences: resource_id)
|
|
37
39
|
metadata = Keycardai::MCP::MetadataApp.new(
|
|
38
40
|
issuer: zone_url,
|
|
39
41
|
resource_name: "mcp-server-ruby",
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
module Keycardai
|
|
6
4
|
module MCP
|
|
7
5
|
# Rack app serving the resource server's OAuth discovery surface
|
|
8
6
|
# (RFC 9728 / RFC 8414): the protected-resource metadata (with path
|
|
9
7
|
# insertion for sub-path mounts), a server-side proxy of the zone's
|
|
10
|
-
# authorization-server metadata (
|
|
11
|
-
#
|
|
8
|
+
# authorization-server metadata (returned unmodified), and optionally
|
|
9
|
+
# the server's own public JWKS.
|
|
12
10
|
# Responses carry permissive CORS so browser clients can read them.
|
|
13
11
|
#
|
|
14
12
|
# run Keycardai::MCP::MetadataApp.new(issuer: zone_url, scopes_supported: ["mcp:tools"])
|
|
@@ -16,7 +14,6 @@ module Keycardai
|
|
|
16
14
|
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
|
17
15
|
AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server"
|
|
18
16
|
JWKS_PATH = "/.well-known/jwks.json"
|
|
19
|
-
LEGACY_MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
20
17
|
|
|
21
18
|
# @param issuer [String] the zone's issuer URL
|
|
22
19
|
# @param scopes_supported [Array<String>, nil]
|
|
@@ -46,7 +43,7 @@ module Keycardai
|
|
|
46
43
|
when %r{\A#{Regexp.escape(PROTECTED_RESOURCE_PATH)}(/.*)?\z}
|
|
47
44
|
protected_resource_response(env, Regexp.last_match(1).to_s)
|
|
48
45
|
when AUTHORIZATION_SERVER_PATH
|
|
49
|
-
authorization_server_response
|
|
46
|
+
authorization_server_response
|
|
50
47
|
when JWKS_PATH
|
|
51
48
|
jwks_response
|
|
52
49
|
else
|
|
@@ -68,10 +65,9 @@ module Keycardai
|
|
|
68
65
|
# origin + /mcp.
|
|
69
66
|
def protected_resource_response(env, resource_path)
|
|
70
67
|
origin = RackSupport.origin(env)
|
|
71
|
-
legacy = env["HTTP_MCP_PROTOCOL_VERSION"] == LEGACY_MCP_PROTOCOL_VERSION
|
|
72
68
|
document = {
|
|
73
69
|
"resource" => "#{origin}#{resource_path}",
|
|
74
|
-
"authorization_servers" => [
|
|
70
|
+
"authorization_servers" => [@issuer],
|
|
75
71
|
"bearer_methods_supported" => ["header"],
|
|
76
72
|
"scopes_supported" => @scopes_supported,
|
|
77
73
|
"resource_name" => @resource_name,
|
|
@@ -81,32 +77,17 @@ module Keycardai
|
|
|
81
77
|
RackSupport.json_response(document)
|
|
82
78
|
end
|
|
83
79
|
|
|
84
|
-
# RFC 8414 proxy of the zone's metadata,
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
def authorization_server_response(env)
|
|
80
|
+
# RFC 8414 proxy of the zone's metadata, returned unmodified. MCP
|
|
81
|
+
# 2025-06-18 clients send the RFC 8707 resource parameter themselves.
|
|
82
|
+
def authorization_server_response
|
|
88
83
|
metadata = Keycardai::OAuth.fetch_authorization_server_metadata(
|
|
89
84
|
@issuer, http_client: @http_client, timeout: @timeout
|
|
90
85
|
)
|
|
91
|
-
|
|
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)
|
|
86
|
+
RackSupport.json_response(metadata.raw)
|
|
97
87
|
rescue Keycardai::Error
|
|
98
88
|
RackSupport.json_response({ "error" => "bad_gateway" }, status: 502)
|
|
99
89
|
end
|
|
100
90
|
|
|
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
91
|
def jwks_response
|
|
111
92
|
return RackSupport.json_response({ "error" => "not_found" }, status: 404) unless @public_jwks
|
|
112
93
|
|
|
@@ -9,7 +9,7 @@ module Keycardai
|
|
|
9
9
|
# the Rack env (Keycardai::MCP.auth_info).
|
|
10
10
|
#
|
|
11
11
|
# use Keycardai::MCP::RequireBearerAuth,
|
|
12
|
-
# verifier: Keycardai::OAuth::TokenVerifier.new(issuers: zone_url),
|
|
12
|
+
# verifier: Keycardai::OAuth::TokenVerifier.new(issuers: zone_url, audiences: resource_id),
|
|
13
13
|
# required_scopes: ["mcp:tools"]
|
|
14
14
|
#
|
|
15
15
|
# Route-level gating is this same middleware applied per route with a
|
|
@@ -49,6 +49,9 @@ module Keycardai
|
|
|
49
49
|
|
|
50
50
|
def authorize(env, token)
|
|
51
51
|
access_token, failure = verify(token)
|
|
52
|
+
if failure == :unavailable
|
|
53
|
+
return RackSupport.json_response({ "error" => "temporarily_unavailable" }, status: 503)
|
|
54
|
+
end
|
|
52
55
|
return RackSupport.challenge_response(env, status: 401, error: "invalid_token", description: failure) if failure
|
|
53
56
|
|
|
54
57
|
missing = @required_scopes - access_token.scopes
|
|
@@ -61,8 +64,21 @@ module Keycardai
|
|
|
61
64
|
@app.call(env)
|
|
62
65
|
end
|
|
63
66
|
|
|
67
|
+
# The zone's signing keys or its authorization-server metadata could not
|
|
68
|
+
# be fetched, so verification never completed. Answered 503 with no
|
|
69
|
+
# challenge: the token was not judged, and the client should retry.
|
|
70
|
+
# A kid absent from a JWKS that did load (JWKSKeyNotFoundError) is an
|
|
71
|
+
# invalid token and keeps the 401 path.
|
|
72
|
+
UNAVAILABLE_ERRORS = [
|
|
73
|
+
Keycardai::OAuth::JWKSFetchError, Keycardai::OAuth::JWKSDiscoveryError,
|
|
74
|
+
Keycardai::OAuth::HTTPError, Keycardai::OAuth::NetworkError
|
|
75
|
+
].freeze
|
|
76
|
+
private_constant :UNAVAILABLE_ERRORS
|
|
77
|
+
|
|
64
78
|
def verify(token)
|
|
65
79
|
[@verifier.verify_token(token), nil]
|
|
80
|
+
rescue *UNAVAILABLE_ERRORS
|
|
81
|
+
[nil, :unavailable]
|
|
66
82
|
rescue Keycardai::Error => e
|
|
67
83
|
[nil, e.message]
|
|
68
84
|
end
|