mcp 1.7.0 → 1.8.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "access_token"
4
+ require_relative "errors"
5
+
6
+ module MCP
7
+ class Server
8
+ module OAuth
9
+ # The single integration point of the resource-server role: token in, `AccessToken` out. `Authenticator` accepts any object
10
+ # that responds to `verify(token)`, so subclassing is optional; this base class documents the contract and shares the claim mapping
11
+ # used by the built-in verifiers.
12
+ #
13
+ # Implementations MUST raise `InvalidTokenError` when the token is invalid, and that includes an expired token: expiry enforcement
14
+ # is part of this contract, not something the caller re-checks (the built-in verifiers honor their configured clock leeway when doing so).
15
+ # Returning nil is also treated as a rejection by `Authenticator`, with a generic message. Any other exception is treated as
16
+ # an internal error (HTTP 500), not as a client failure.
17
+ #
18
+ # Error messages are echoed into the `WWW-Authenticate` response header, so they must never contain token material or other secrets.
19
+ class TokenVerifier
20
+ # Upper bound for response bodies read from authorization-server endpoints (JWKS, introspection). Matches the bound the client-side
21
+ # OAuth support applies to authorization-server responses; the documents involved are orders of magnitude smaller.
22
+ MAX_UPSTREAM_RESPONSE_BYTES = 4 * 1024 * 1024
23
+
24
+ # @param token [String] the raw bearer token from the `Authorization` header
25
+ # @return [AccessToken]
26
+ # @raise [InvalidTokenError] when the token is invalid, expired, or not meant for this resource
27
+ def verify(token)
28
+ raise NotImplementedError, "#{self.class.name}#verify is not implemented"
29
+ end
30
+
31
+ private
32
+
33
+ # Guards an expected `iss` or `aud` value at construction. The built-in verifiers compare tokens against these values,
34
+ # and a nil or empty expectation would silently turn the comparison off (the jwt gem skips a check whose expected value is nil),
35
+ # so a misconfiguration such as an unset environment variable fails loudly instead.
36
+ def require_expected_claim!(name, value)
37
+ return if value.is_a?(String) && !value.empty?
38
+
39
+ claim = name == :issuer ? "iss" : "aud"
40
+
41
+ raise ArgumentError, "#{name} must be a non-empty String (got #{value.inspect}); without it the #{claim} check would be skipped"
42
+ end
43
+
44
+ # The expected `aud` is the RFC 9728 document's `resource`: the canonical URL tokens are issued for, and the one this server publishes,
45
+ # so the verifier cannot drift from it.
46
+ def resource_from(resource_metadata)
47
+ metadata_member(resource_metadata, :resource)
48
+ end
49
+
50
+ # The expected `iss` comes from the document as well: its authorization servers are issuer identifiers (RFC 9728),
51
+ # and a JWT verifier holds one key set, so it verifies the tokens of exactly one of them. A document naming several
52
+ # would advertise an authorization server whose tokens this verifier rejects, so it is refused outright.
53
+ def issuer_from(resource_metadata)
54
+ servers = metadata_member(resource_metadata, :authorization_servers)
55
+ return servers.first if servers.one?
56
+
57
+ raise ArgumentError, <<~MESSAGE
58
+ JWTVerifier verifies the tokens of one authorization server, and resource_metadata names \
59
+ #{servers.size}; publish one, or verify with a custom verifier that holds each issuer's keys
60
+ MESSAGE
61
+ end
62
+
63
+ # A document standing in for `ProtectedResourceMetadata` must still hand over the shapes that class guarantees,
64
+ # so a wrong one fails here with a named member rather than deeper in with a `NoMethodError`.
65
+ def metadata_member(resource_metadata, name)
66
+ unless resource_metadata.respond_to?(name)
67
+ raise ArgumentError, "resource_metadata must be a ProtectedResourceMetadata (got #{resource_metadata.class})"
68
+ end
69
+
70
+ value = resource_metadata.public_send(name)
71
+ shape = name == :resource ? "a String" : "an Array of Strings"
72
+ valid = name == :resource ? value.is_a?(String) : value.is_a?(Array) && value.all?(String)
73
+ raise ArgumentError, "resource_metadata.#{name} must be #{shape} (got #{value.inspect})" unless valid
74
+
75
+ value
76
+ end
77
+
78
+ # Maps a string-keyed claims Hash (JWT payload or RFC 7662 introspection response; the relevant claim names are identical) to an `AccessToken`.
79
+ def access_token_from_claims(token, claims, resource: nil)
80
+ AccessToken.new(
81
+ token: token,
82
+ client_id: claims["client_id"] || claims["azp"],
83
+ scopes: scopes_from_claims(claims),
84
+ expires_at: expiry_from_claims(claims),
85
+ subject: claims["sub"],
86
+ issuer: claims["iss"],
87
+ audience: claims["aud"],
88
+ resource: resource,
89
+ claims: claims,
90
+ )
91
+ end
92
+
93
+ # RFC 8693 style `scope` is a space-delimited string, but some authorization servers emit an array of scope strings instead.
94
+ def scopes_from_claims(claims)
95
+ scope = claims["scope"]
96
+
97
+ scope.is_a?(Array) ? scope.map(&:to_s) : scope.to_s.split
98
+ end
99
+
100
+ # `exp` is a numeric timestamp in a JWT payload and in an introspection response alike, but a lenient authorization server may emit it as
101
+ # a numeric string. Any other shape is rejected as an invalid token: passed through, it would make `AccessToken#expired?` raise on comparison
102
+ # and turn a malformed upstream response into an internal error.
103
+ def expiry_from_claims(claims)
104
+ exp = claims["exp"]
105
+
106
+ case exp
107
+ when nil
108
+ nil
109
+ when Numeric
110
+ # JSON parses `1e1000` to Infinity, whose `to_i` raises.
111
+ raise InvalidTokenError, "Malformed exp claim" unless exp.finite?
112
+
113
+ exp.to_i
114
+ when /\A\d+\z/
115
+ exp.to_i
116
+ else
117
+ raise InvalidTokenError, "Malformed exp claim"
118
+ end
119
+ end
120
+
121
+ def require_non_negative_number!(name, value)
122
+ return if value.is_a?(Numeric) && value.finite? && value >= 0
123
+
124
+ raise ArgumentError, "#{name} must be a non-negative finite number (got #{value.inspect})"
125
+ end
126
+
127
+ def require_positive_number!(name, value)
128
+ return if value.is_a?(Numeric) && value.finite? && value.positive?
129
+
130
+ raise ArgumentError, "#{name} must be a positive finite number (got #{value.inspect})"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oauth/errors"
4
+
5
+ module MCP
6
+ class Server
7
+ # OAuth 2.1 resource-server support per the MCP authorization specification: bearer token verification,
8
+ # Protected Resource Metadata (RFC 9728) serving, and RFC 6750 `WWW-Authenticate` challenges.
9
+ # The MCP server never acts as an authorization server; token issuance belongs to an external provider.
10
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
11
+ module OAuth
12
+ # The Rack env key under which the transport's built-in enforcement and `Middleware` store
13
+ # the verified `AccessToken`. The streamable HTTP transport reads this key and threads the value through
14
+ # to handlers as `server_context.auth_info`. Custom integrations that verify tokens themselves can set
15
+ # the same key to opt in to that propagation.
16
+ ENV_KEY = "mcp.auth_info"
17
+
18
+ autoload :AccessToken, "mcp/server/oauth/access_token"
19
+ autoload :Authenticator, "mcp/server/oauth/authenticator"
20
+ autoload :Challenge, "mcp/server/oauth/challenge"
21
+ autoload :IntrospectionVerifier, "mcp/server/oauth/introspection_verifier"
22
+ autoload :JWTVerifier, "mcp/server/oauth/jwt_verifier"
23
+ autoload :Middleware, "mcp/server/oauth/middleware"
24
+ autoload :ProtectedResourceMetadata, "mcp/server/oauth/protected_resource_metadata"
25
+ autoload :ProtectedResourceMetadataMiddleware, "mcp/server/oauth/protected_resource_metadata_middleware"
26
+ autoload :TokenVerifier, "mcp/server/oauth/token_verifier"
27
+ end
28
+ end
29
+ end