mcp 1.6.1 → 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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authenticator"
4
+ require_relative "challenge"
5
+ require_relative "errors"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # Rack middleware that enforces bearer authentication for everything it wraps, per RFC 6750 and the MCP authorization specification:
11
+ #
12
+ # use MCP::Server::OAuth::Middleware,
13
+ # token_verifier: verifier,
14
+ # required_scopes: ["mcp:tools"],
15
+ # resource_metadata: metadata
16
+ # run transport
17
+ #
18
+ # See `Authenticator` for the option semantics; this class only adapts it to the Rack middleware calling convention.
19
+ # The streamable HTTP transport embeds the same authenticator via its `token_verifier:` option, so use this middleware when composition
20
+ # at the Rack layer is preferable (for example to share one authenticator across several apps).
21
+ #
22
+ # Tokens are accepted from the `Authorization` header only, never from a query string. On success the verified `AccessToken` is stored in
23
+ # `env[MCP::Server::OAuth::ENV_KEY]`, where the streamable HTTP transport picks it up and exposes it to handlers as `server_context.auth_info`.
24
+ #
25
+ # Because the middleware wraps the whole app, POST, GET (SSE), and DELETE requests are protected uniformly. Mount the metadata document outside
26
+ # the protected scope: it is how unauthenticated clients bootstrap. When a browser-based client is involved, run the CORS middleware before
27
+ # this one; otherwise its preflight OPTIONS request dies here with a 401 that the browser will not let the client see.
28
+ class Middleware
29
+ def initialize(app, token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil)
30
+ @app = app
31
+ @authenticator = Authenticator.new(
32
+ token_verifier: token_verifier,
33
+ required_scopes: required_scopes,
34
+ resource_metadata: resource_metadata,
35
+ resource_metadata_url: resource_metadata_url,
36
+ scope_matcher: scope_matcher,
37
+ )
38
+ end
39
+
40
+ def call(env)
41
+ begin
42
+ env[ENV_KEY] = @authenticator.authenticate(env)
43
+ rescue Error => e
44
+ return @authenticator.challenge_response(e)
45
+ rescue => e
46
+ # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure: report it
47
+ # and answer 500 without a challenge, so the client does not discard a perfectly good token.
48
+ MCP.configuration.exception_reporter.call(e, { middleware: self.class.name })
49
+
50
+ return [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
51
+ end
52
+
53
+ # Outside the rescue on purpose: only verification is this middleware's business, and a failure in the wrapped app
54
+ # must reach whatever handles that app's errors, as it does with the Python SDK's bearer middleware.
55
+ @app.call(env)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "mcp/client/oauth/discovery"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # OAuth 2.0 Protected Resource Metadata (RFC 9728) for an MCP server.
11
+ # The MCP authorization spec requires servers to publish this document so clients can discover which authorization servers can
12
+ # issue tokens for the resource.
13
+ #
14
+ # - `resource` - the canonical resource identifier: the URL clients connect to, without a fragment. Clients send this back to
15
+ # the authorization server as the RFC 8707 `resource` parameter.
16
+ # - `authorization_servers` - issuer URLs of the authorization servers that protect this resource (at least one).
17
+ # - `scopes_supported` - an Array of the scope strings clients may request (also the default scope hint in `WWW-Authenticate` challenges);
18
+ # `offline_access` is dropped, and a result with nothing left omits the member.
19
+ # - `bearer_methods_supported` - defaults to `["header"]`: the SDK accepts bearer tokens only in the `Authorization` header,
20
+ # never in a query string.
21
+ # - `extra` - additional RFC 9728 fields (e.g. `jwks_uri`, `resource_signing_alg_values_supported`, `resource_policy_uri`) merged into
22
+ # the document as given; the members above cannot be overridden through it.
23
+ #
24
+ # https://www.rfc-editor.org/rfc/rfc9728
25
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
26
+ class ProtectedResourceMetadata
27
+ WELL_KNOWN_PATH_PREFIX = "/.well-known/oauth-protected-resource"
28
+
29
+ DEFAULT_BEARER_METHODS_SUPPORTED = ["header"].freeze
30
+ private_constant :DEFAULT_BEARER_METHODS_SUPPORTED
31
+
32
+ # The members `initialize` validates. `extra` may not name them, or the merge in `to_h` would undo the validation.
33
+ VALIDATED_MEMBERS = ["resource", "authorization_servers", "scopes_supported", "resource_name", "resource_documentation", "bearer_methods_supported"].freeze
34
+ private_constant :VALIDATED_MEMBERS
35
+
36
+ attr_reader :resource, :authorization_servers, :scopes_supported, :resource_name, :resource_documentation, :bearer_methods_supported, :extra
37
+
38
+ def initialize(resource:, authorization_servers:, scopes_supported: nil, resource_name: nil, resource_documentation: nil, bearer_methods_supported: DEFAULT_BEARER_METHODS_SUPPORTED, extra: {})
39
+ @resource_uri = parse_resource_uri(resource)
40
+ @resource = resource
41
+
42
+ servers = Array(authorization_servers)
43
+ raise ArgumentError, "authorization_servers must contain at least one issuer URL" if servers.empty?
44
+
45
+ servers.each do |server|
46
+ next if Client::OAuth::Discovery.secure_url?(server)
47
+
48
+ raise ArgumentError, "authorization_servers must use https (http is allowed only on loopback): #{server.inspect}"
49
+ end
50
+
51
+ overridden = extra.keys.map(&:to_s) & VALIDATED_MEMBERS
52
+ unless overridden.empty?
53
+ raise ArgumentError, "extra must not override #{overridden.join(", ")}; pass them as keyword arguments so they are validated"
54
+ end
55
+
56
+ @authorization_servers = servers
57
+ @scopes_supported = advertisable_scopes(scopes_supported)
58
+ @resource_name = resource_name
59
+ @resource_documentation = resource_documentation
60
+ @bearer_methods_supported = bearer_methods_supported
61
+ @extra = extra
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ resource: resource,
67
+ authorization_servers: authorization_servers,
68
+ scopes_supported: scopes_supported,
69
+ resource_name: resource_name,
70
+ resource_documentation: resource_documentation,
71
+ bearer_methods_supported: bearer_methods_supported,
72
+ }.compact.merge(extra)
73
+ end
74
+
75
+ def to_json(*args)
76
+ to_h.to_json(*args)
77
+ end
78
+
79
+ # The path-inserted well-known path per RFC 9728 Section 3: a resource of `https://example.com/mcp` is described at
80
+ # `/.well-known/oauth-protected-resource/mcp`, and a resource at the origin root is described at `/.well-known/oauth-protected-resource`.
81
+ # The path math intentionally matches what `MCP::Client::OAuth::Discovery.protected_resource_metadata_urls` probes.
82
+ def well_known_path
83
+ path = @resource_uri.path
84
+ path = "" if path == "/"
85
+
86
+ "#{WELL_KNOWN_PATH_PREFIX}#{path}"
87
+ end
88
+
89
+ # The absolute URL of the metadata document. Pass this to `Middleware` (or `Challenge`) as `resource_metadata_url` so 401/403 challenges point
90
+ # clients at the document.
91
+ def well_known_url
92
+ port = @resource_uri.port && @resource_uri.port != @resource_uri.default_port ? ":#{@resource_uri.port}" : ""
93
+
94
+ "#{@resource_uri.scheme}://#{@resource_uri.host}#{port}#{well_known_path}"
95
+ end
96
+
97
+ private
98
+
99
+ # The MCP authorization specification tells protected resources not to advertise `offline_access`: refresh token issuance
100
+ # is the authorization server's business, not a resource requirement. `Authenticator` already drops it from the `scope` parameter of
101
+ # a `WWW-Authenticate` challenge, so dropping it here keeps the document and the challenges saying the same thing.
102
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
103
+ # Nothing left to advertise drops the member from the document instead of emitting `[]`, which RFC 9728 tells servers to omit,
104
+ # and a non-Array is refused because a bare String would be ambiguous between one scope and a list.
105
+ def advertisable_scopes(scopes_supported)
106
+ return if scopes_supported.nil?
107
+ raise ArgumentError, "scopes_supported must be an Array of scope strings" unless scopes_supported.is_a?(Array)
108
+
109
+ scopes = scopes_supported.map(&:to_s) - ["offline_access"]
110
+
111
+ scopes.empty? ? nil : scopes
112
+ end
113
+
114
+ def parse_resource_uri(resource)
115
+ uri = begin
116
+ URI.parse(resource.to_s)
117
+ rescue URI::InvalidURIError
118
+ raise ArgumentError, "resource must be a valid URI: #{resource.inspect}"
119
+ end
120
+
121
+ unless ["http", "https"].include?(uri.scheme.to_s.downcase) && uri.host && !uri.host.empty?
122
+ raise ArgumentError, "resource must be an absolute http(s) URL: #{resource.inspect}"
123
+ end
124
+
125
+ if uri.fragment
126
+ raise ArgumentError, "resource must not contain a fragment per RFC 8707: #{resource.inspect}"
127
+ end
128
+
129
+ unless Client::OAuth::Discovery.secure_url?(resource.to_s)
130
+ raise ArgumentError, "resource must use https (http is allowed only on loopback): #{resource.inspect}"
131
+ end
132
+
133
+ uri
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCP
6
+ class Server
7
+ module OAuth
8
+ # Rack middleware that serves a Protected Resource Metadata document (RFC 9728) at its well-known path,
9
+ # with the CORS headers browser-based MCP clients need, and passes every other request down the stack.
10
+ # The Rack shape of the Python SDK's `create_protected_resource_routes` and the TypeScript SDK's `mcpAuthMetadataRouter`.
11
+ #
12
+ # Use it at the top of the stack: the well-known path lives at the origin root, not under the MCP endpoint
13
+ # (`/.well-known/oauth-protected-resource/mcp` for a resource at `/mcp`), so inside a `map` block it would never see that path.
14
+ # The metadata is how unauthenticated clients bootstrap, so it belongs above any bearer enforcement.
15
+ #
16
+ # metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(...)
17
+ # use MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, metadata
18
+ # map("/mcp") { run(transport) }
19
+ class ProtectedResourceMetadataMiddleware
20
+ ALLOWED_METHODS = "GET, HEAD, OPTIONS"
21
+ private_constant :ALLOWED_METHODS
22
+
23
+ # @param app [#call] the rest of the Rack stack
24
+ # @param metadata [ProtectedResourceMetadata] the document to serve, which also names the path it is served at
25
+ def initialize(app, metadata)
26
+ # The document is published as `to_json`, so only the class whose serialization is a validated RFC 9728 document will do.
27
+ unless metadata.is_a?(ProtectedResourceMetadata)
28
+ raise ArgumentError, "metadata must be a ProtectedResourceMetadata (got #{metadata.class})"
29
+ end
30
+
31
+ @app = app
32
+ @well_known_path = metadata.well_known_path
33
+ @metadata_json = metadata.to_json
34
+ end
35
+
36
+ def call(env)
37
+ # An exact match only: a deeper well-known path describes another resource on this host (RFC 9728),
38
+ # and every other path is the application's.
39
+ return @app.call(env) unless env["PATH_INFO"] == @well_known_path
40
+
41
+ case env["REQUEST_METHOD"]
42
+ when "GET"
43
+ metadata_response
44
+ when "HEAD"
45
+ metadata_response(head: true)
46
+ when "OPTIONS"
47
+ preflight_response
48
+ else
49
+ method_not_allowed_response
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def metadata_response(head: false)
56
+ headers = {
57
+ "content-type" => "application/json",
58
+ "cache-control" => "public, max-age=3600",
59
+ "access-control-allow-origin" => "*",
60
+ }
61
+
62
+ [200, headers, head ? [] : [@metadata_json]]
63
+ end
64
+
65
+ def preflight_response
66
+ headers = {
67
+ "access-control-allow-origin" => "*",
68
+ "access-control-allow-methods" => ALLOWED_METHODS,
69
+ "access-control-allow-headers" => "*",
70
+ }
71
+
72
+ [204, headers, []]
73
+ end
74
+
75
+ def method_not_allowed_response
76
+ headers = {
77
+ "content-type" => "application/json",
78
+ "allow" => ALLOWED_METHODS,
79
+ }
80
+
81
+ [405, headers, [{ error: "method_not_allowed" }.to_json]]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -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