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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "access_token"
4
+ require_relative "challenge"
5
+ require_relative "errors"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # The verification core shared by `Middleware` and the streamable HTTP transport's built-in enforcement:
11
+ # extracts the bearer token from a Rack env, runs it through the verifier, checks scopes, and maps each failure
12
+ # to the RFC 6750 challenge response it deserves.
13
+ #
14
+ # - `token_verifier` - anything responding to `verify(token)`; see `TokenVerifier` for the contract.
15
+ # Built-ins: `JWTVerifier`, `IntrospectionVerifier`.
16
+ # - `required_scopes` - scopes the token must include, all of them; a shortfall produces 403 `insufficient_scope` (step-up).
17
+ # - `resource_metadata` - a `ProtectedResourceMetadata`; its well-known URL and `scopes_supported` feed the challenges.
18
+ # Pass `resource_metadata_url:` instead to point at a document served elsewhere.
19
+ # - `scope_matcher` - optional callable `(required_scope, granted_scopes) -> Boolean` for deployments
20
+ # whose scopes form a hierarchy; the MCP authorization specification requires a broader scope to satisfy
21
+ # the narrower scopes it implies, which exact membership (the default) cannot express. The matcher also rides
22
+ # on the returned `AccessToken`, so `require_scopes!` and `scope?` inside handlers apply the same semantics.
23
+ class Authenticator
24
+ # RFC 6750 Section 2.1: the scheme and the token are separated by spaces (`1*SP`), never by other whitespace.
25
+ BEARER_PATTERN = /\ABearer +(?<token>\S+)\z/i.freeze
26
+ # Whether the header names the Bearer scheme at all, well-formed or not. `Bearer` followed by anything other
27
+ # than a space names no Bearer credentials, the way the Python and TypeScript servers read it.
28
+ BEARER_SCHEME_PATTERN = /\ABearer(?: |\z)/i.freeze
29
+ private_constant :BEARER_PATTERN, :BEARER_SCHEME_PATTERN
30
+
31
+ # Upper bound on the presented token, applied before any verification so oversized garbage neither reaches
32
+ # cryptographic parsing nor gets forwarded to an introspection endpoint. Real-world access tokens stay far below this;
33
+ # web servers cap the whole header section around 16 KiB.
34
+ MAX_TOKEN_BYTES = 8192
35
+
36
+ def initialize(token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil)
37
+ @verifier = token_verifier
38
+ @required_scopes = Array(required_scopes)
39
+ @resource_metadata = resource_metadata
40
+ @resource_metadata_url = resource_metadata_url || resource_metadata&.well_known_url
41
+ @scope_matcher = scope_matcher
42
+ end
43
+
44
+ # @param env [Hash] the Rack env
45
+ # @return [AccessToken]
46
+ # @raise [MissingTokenError, InvalidRequestError, InvalidTokenError, InsufficientScopeError]
47
+ def authenticate(env)
48
+ token = extract_bearer_token(env)
49
+
50
+ access_token = @verifier.verify(token)
51
+ raise InvalidTokenError if access_token.nil?
52
+
53
+ access_token = attach_scope_matcher(access_token)
54
+ check_scopes!(access_token)
55
+ access_token
56
+ end
57
+
58
+ # Maps an `OAuth::Error` raised by `authenticate` (or by a handler's `require_scopes!`) to the Rack response carrying
59
+ # the matching challenge. An `Error` outside the four built-in classes, such as one a custom verifier defines,
60
+ # is a token rejection and answers 401 like an invalid token; anything else is a programming error.
61
+ def challenge_response(error)
62
+ case error
63
+ when MissingTokenError
64
+ Challenge.missing_token_response(scope: scope_hint, resource_metadata: @resource_metadata_url)
65
+ when InvalidRequestError
66
+ Challenge.invalid_request_response(error_description: error.message, resource_metadata: @resource_metadata_url)
67
+ when InsufficientScopeError
68
+ Challenge.insufficient_scope_response(
69
+ error_description: error.message,
70
+ scope: scope_hint(error.required_scopes),
71
+ resource_metadata: @resource_metadata_url,
72
+ )
73
+ when Error
74
+ Challenge.invalid_token_response(
75
+ error_description: error.message,
76
+ scope: scope_hint,
77
+ resource_metadata: @resource_metadata_url,
78
+ )
79
+ else
80
+ raise ArgumentError, "unexpected error class: #{error.class}"
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def extract_bearer_token(env)
87
+ header = env["HTTP_AUTHORIZATION"]
88
+ raise MissingTokenError if header.nil? || header.empty?
89
+
90
+ # Matched as bytes: a Rack server hands the header over as ASCII-8BIT, but a test request or a framework can tag it UTF-8,
91
+ # and a regexp raises on an invalid byte sequence in a UTF-8 string. As bytes, such a token simply fails verification.
92
+ bytes = header.b
93
+
94
+ # A header naming another scheme carries no Bearer credentials: RFC 6750 Section 3.1 counts a request that
95
+ # "attempted using an unsupported authentication method" among those lacking authentication information,
96
+ # which get the bare challenge, and the Python and TypeScript servers answer it 401 as well.
97
+ # A header that names Bearer but is malformed is a bad request (`invalid_request` in the same section).
98
+ raise MissingTokenError unless BEARER_SCHEME_PATTERN.match?(bytes)
99
+
100
+ match = BEARER_PATTERN.match(bytes)
101
+ raise InvalidRequestError, "Authorization header must carry a single Bearer token" if match.nil?
102
+
103
+ token = match[:token]
104
+ raise InvalidTokenError, "Token exceeds the maximum accepted length" if token.bytesize > MAX_TOKEN_BYTES
105
+
106
+ token
107
+ end
108
+
109
+ def check_scopes!(access_token)
110
+ missing_scopes = @required_scopes.reject { |scope| scope_satisfied?(scope, access_token) }
111
+ return if missing_scopes.empty?
112
+
113
+ raise InsufficientScopeError.new(
114
+ "Token is missing required scopes: #{missing_scopes.join(", ")}",
115
+ required_scopes: @required_scopes,
116
+ )
117
+ end
118
+
119
+ # A verifier result that is not an `AccessToken` (a duck-typed object from a custom verifier) keeps its own `scope?`;
120
+ # the endpoint gate below still applies the matcher to it directly.
121
+ def attach_scope_matcher(access_token)
122
+ return access_token if @scope_matcher.nil? || !access_token.respond_to?(:with_scope_matcher)
123
+
124
+ access_token.with_scope_matcher(@scope_matcher)
125
+ end
126
+
127
+ # A verifier result without a `scopes` reader (a duck-typed object offering only `scope?`) keeps its own judgement,
128
+ # since the matcher needs the granted list to reason about a hierarchy.
129
+ def scope_satisfied?(scope, access_token)
130
+ return access_token.scope?(scope) if @scope_matcher.nil? || !access_token.respond_to?(:scopes)
131
+
132
+ @scope_matcher.call(scope, access_token.scopes)
133
+ end
134
+
135
+ # The scope hint the spec recommends including in challenges: the scopes of the failing operation when known,
136
+ # the scopes this authenticator enforces otherwise, or the resource's advertised scopes as a fallback.
137
+ # `offline_access` is never advertised: refresh token issuance is not a resource requirement per
138
+ # the MCP authorization specification.
139
+ def scope_hint(operation_scopes = [])
140
+ scopes = operation_scopes
141
+ scopes = @required_scopes if scopes.empty?
142
+ scopes = Array(@resource_metadata&.scopes_supported) if scopes.empty?
143
+ scopes -= ["offline_access"]
144
+
145
+ scopes.empty? ? nil : scopes.join(" ")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCP
6
+ class Server
7
+ module OAuth
8
+ # Stateless builders for RFC 6750 Bearer challenges and the Rack error responses that carry them.
9
+ # `Authenticator` uses these for every rejection; they are also public so custom integrations can emit
10
+ # spec-shaped 401/403 responses without pulling in the middleware.
11
+ #
12
+ # The header output is the exact counterpart of `MCP::Client::OAuth::Discovery.parse_www_authenticate`:
13
+ # everything built here parses back into the same parameters on the client side.
14
+ # https://www.rfc-editor.org/rfc/rfc6750#section-3
15
+ module Challenge
16
+ CONTENT_TYPE_JSON = { "content-type" => "application/json" }.freeze
17
+ private_constant :CONTENT_TYPE_JSON
18
+
19
+ class << self
20
+ # Returns a `WWW-Authenticate` header value such as
21
+ # `Bearer error="invalid_token", error_description="...", scope="a b", resource_metadata="https://..."`.
22
+ # Parameters are quoted-string encoded per RFC 7235; nil parameters are omitted.
23
+ def build(error: nil, error_description: nil, scope: nil, resource_metadata: nil)
24
+ parameters = []
25
+ parameters << %(error="#{quote(error)}") if error
26
+ parameters << %(error_description="#{quote(error_description)}") if error_description
27
+ parameters << %(scope="#{quote(scope)}") if scope
28
+ parameters << %(resource_metadata="#{quote(resource_metadata)}") if resource_metadata
29
+
30
+ parameters.empty? ? "Bearer" : "Bearer #{parameters.join(", ")}"
31
+ end
32
+
33
+ # 401 with `error="invalid_token"`: the presented token is expired, revoked, or otherwise invalid.
34
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
35
+ def invalid_token_response(error_description: nil, scope: nil, resource_metadata: nil)
36
+ challenge_response(
37
+ 401,
38
+ error: "invalid_token",
39
+ error_description: error_description,
40
+ scope: scope,
41
+ resource_metadata: resource_metadata,
42
+ )
43
+ end
44
+
45
+ # 401 without an error code: RFC 6750 Section 3.1 says the challenge answering a request that
46
+ # carried no authentication information at all should not include an error code or other error information.
47
+ # A request that tried another scheme counts as one (the same section).
48
+ def missing_token_response(scope: nil, resource_metadata: nil)
49
+ challenge_response(401, scope: scope, resource_metadata: resource_metadata)
50
+ end
51
+
52
+ # 403 with `error="insufficient_scope"`: the token is valid but lacks a scope the current operation requires.
53
+ # Clients treat this as a step-up signal and re-authorize with the scopes advertised in `scope`.
54
+ def insufficient_scope_response(error_description: nil, scope: nil, resource_metadata: nil)
55
+ challenge_response(
56
+ 403,
57
+ error: "insufficient_scope",
58
+ error_description: error_description,
59
+ scope: scope,
60
+ resource_metadata: resource_metadata,
61
+ )
62
+ end
63
+
64
+ # 400 with `error="invalid_request"`: the request itself is malformed (e.g. a `Bearer` header without exactly one token).
65
+ def invalid_request_response(error_description: nil, resource_metadata: nil)
66
+ challenge_response(
67
+ 400,
68
+ error: "invalid_request",
69
+ error_description: error_description,
70
+ resource_metadata: resource_metadata,
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def challenge_response(status, error: nil, error_description: nil, scope: nil, resource_metadata: nil)
77
+ # The description reaches the header (quoted below) and the JSON body alike; normalized once here
78
+ # so a custom verifier's message with invalid bytes cannot make `to_json` raise either.
79
+ error_description = utf8(error_description) if error_description
80
+ www_authenticate = build(
81
+ error: error,
82
+ error_description: error_description,
83
+ scope: scope,
84
+ resource_metadata: resource_metadata,
85
+ )
86
+ return [status, { "www-authenticate" => www_authenticate }, []] if error.nil?
87
+
88
+ body = { error: error }
89
+ body[:error_description] = error_description if error_description
90
+
91
+ [status, CONTENT_TYPE_JSON.merge("www-authenticate" => www_authenticate), [body.to_json]]
92
+ end
93
+
94
+ # Encodes a parameter value as an RFC 7230 quoted-string interior: `\` and `"` become quoted-pairs.
95
+ # Control characters are replaced with spaces because they cannot appear in a header value at all;
96
+ # leaving CR/LF in would let attacker-influenced text (e.g. an error message quoting a bad token)
97
+ # split the response header, and other control characters make strict servers reject the response outright.
98
+ # Invalid byte sequences are replaced first: a custom verifier's message need not be valid UTF-8,
99
+ # and `gsub` raising on it would escape the rescue that builds the challenge, turning the 401 into a 500.
100
+ def quote(value)
101
+ utf8(value).gsub(/[[:cntrl:]]/, " ").gsub(/[\\"]/) { |character| "\\#{character}" }
102
+ end
103
+
104
+ # Normalizes a value to valid UTF-8. `scrub` alone only repairs strings tagged UTF-8:
105
+ # Rack hands header values to a custom verifier as ASCII-8BIT, where every byte counts as valid,
106
+ # so a message assembled from them would still make `to_json` raise.
107
+ def utf8(value)
108
+ value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module OAuth
6
+ # Error hierarchy for the OAuth 2.1 resource-server role. Each error carries the RFC 6750 Section 3.1
7
+ # registered error code that `Challenge` places in the `WWW-Authenticate` response header.
8
+ # https://www.rfc-editor.org/rfc/rfc6750#section-3.1
9
+ class Error < StandardError
10
+ attr_reader :error_code
11
+
12
+ # `error_code` defaults to the code a token rejection carries, so a custom verifier can subclass this
13
+ # and `raise MyError, "..."` without knowing about the keyword. `Authenticator#challenge_response`
14
+ # answers any such error with 401 `invalid_token`, which is what the default names; without a default,
15
+ # the missing keyword would raise `ArgumentError` inside `verify` and surface as HTTP 500 instead.
16
+ def initialize(message = nil, error_code: "invalid_token")
17
+ super(message)
18
+ @error_code = error_code
19
+ end
20
+ end
21
+
22
+ # The request is malformed (e.g. an `Authorization` header that does not carry a Bearer token). Maps to HTTP 400.
23
+ class InvalidRequestError < Error
24
+ def initialize(message = "The request is malformed")
25
+ super(message, error_code: "invalid_request")
26
+ end
27
+ end
28
+
29
+ # The access token is missing, expired, revoked, or otherwise invalid.
30
+ # Maps to HTTP 401. `TokenVerifier` implementations raise this to reject a token.
31
+ class InvalidTokenError < Error
32
+ def initialize(message = "The access token is invalid")
33
+ super(message, error_code: "invalid_token")
34
+ end
35
+ end
36
+
37
+ # The request carried no credentials at all. A subclass of `InvalidTokenError` so existing rescue clauses keep working,
38
+ # but distinguished because RFC 6750 Section 3.1 says the challenge answering a credential-less request should not include
39
+ # an error code.
40
+ class MissingTokenError < InvalidTokenError
41
+ def initialize(message = "Missing Authorization header")
42
+ super
43
+ end
44
+ end
45
+
46
+ # The token is valid but lacks a scope the resource requires. Maps to HTTP 403, whose `WWW-Authenticate` challenge signals
47
+ # step-up authorization (the scope challenge handling of the MCP authorization specification) to the client.
48
+ class InsufficientScopeError < Error
49
+ attr_reader :required_scopes
50
+
51
+ def initialize(message = "The request requires higher privileges", required_scopes: [])
52
+ super(message, error_code: "insufficient_scope")
53
+ @required_scopes = required_scopes
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "mcp/client/oauth/discovery"
7
+ require_relative "token_verifier"
8
+
9
+ module MCP
10
+ class Server
11
+ module OAuth
12
+ # Verifies access tokens by asking the authorization server via OAuth 2.0 Token Introspection (RFC 7662).
13
+ # Works with opaque tokens and needs no extra dependencies.
14
+ #
15
+ # verifier = MCP::Server::OAuth::IntrospectionVerifier.new(
16
+ # introspection_endpoint: "https://as.example.com/oauth/introspect",
17
+ # client_id: "mcp-resource-server",
18
+ # client_secret: ENV["INTROSPECTION_CLIENT_SECRET"],
19
+ # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes; its `resource` is the expected `aud`
20
+ # )
21
+ #
22
+ # Every `verify` call hits the endpoint; responses are intentionally not cached so token revocation takes
23
+ # effect within the authorization server's own latency, not ours.
24
+ #
25
+ # `audience` is required: the introspection response's `aud` (or `resource`) must cover it,
26
+ # which is the RFC 8707 audience check the MCP authorization specification demands of every resource server.
27
+ # It keeps a token issued for another resource from being replayed here. An authorization server that cannot
28
+ # audience-bind its tokens needs a custom verifier, and the deployment should understand what it is giving up.
29
+ class IntrospectionVerifier < TokenVerifier
30
+ # Raised when the introspection endpoint is unreachable or misbehaves. Deliberately not an `OAuth::Error`:
31
+ # an unavailable authorization server is an internal failure (HTTP 500), not a problem with the client's token.
32
+ class IntrospectionError < StandardError; end
33
+
34
+ # Named after the RFC 7591 `token_endpoint_auth_method` values, the same vocabulary the client side uses.
35
+ CLIENT_AUTH_METHODS = [:client_secret_basic, :client_secret_post, :none].freeze
36
+ private_constant :CLIENT_AUTH_METHODS
37
+
38
+ def initialize(
39
+ introspection_endpoint:,
40
+ resource_metadata:,
41
+ client_id: nil,
42
+ client_secret: nil,
43
+ client_auth_method: :client_secret_basic,
44
+ open_timeout: 5,
45
+ read_timeout: 5
46
+ )
47
+ super()
48
+
49
+ unless Client::OAuth::Discovery.secure_url?(introspection_endpoint)
50
+ raise ArgumentError, "introspection_endpoint must use https (http is allowed only on loopback): #{introspection_endpoint.inspect}"
51
+ end
52
+
53
+ # The RFC 8707 audience check keeps tokens issued for other resources from being replayed against this server;
54
+ # the expected value is the `resource` of the document this server publishes.
55
+ audience = resource_from(resource_metadata)
56
+ require_expected_claim!(:audience, audience)
57
+ require_positive_number!(:open_timeout, open_timeout)
58
+ require_positive_number!(:read_timeout, read_timeout)
59
+ unless CLIENT_AUTH_METHODS.include?(client_auth_method)
60
+ raise ArgumentError, "client_auth_method must be one of #{CLIENT_AUTH_METHODS.join(", ")}"
61
+ end
62
+ if client_auth_method != :none && client_id.nil?
63
+ raise ArgumentError, "client_id is required for client_auth_method #{client_auth_method.inspect}"
64
+ end
65
+
66
+ @introspection_endpoint = introspection_endpoint
67
+ @client_id = client_id
68
+ @client_secret = client_secret
69
+ @client_auth_method = client_auth_method
70
+ @audience = audience
71
+ @open_timeout = open_timeout
72
+ @read_timeout = read_timeout
73
+ end
74
+
75
+ def verify(token)
76
+ claims = introspect(token)
77
+
78
+ raise InvalidTokenError, "Token is not active" unless claims["active"] == true
79
+
80
+ validate_audience!(claims)
81
+
82
+ access_token = access_token_from_claims(token, claims, resource: @audience)
83
+ # An active-but-expired introspection response would be an authorization server bug, but expiry is this verifier's contractual duty,
84
+ # so it is enforced here rather than trusted.
85
+ raise InvalidTokenError, "Token expired" if access_token.expired?
86
+
87
+ access_token
88
+ end
89
+
90
+ private
91
+
92
+ def introspect(token)
93
+ uri = URI.parse(@introspection_endpoint)
94
+ request = Net::HTTP::Post.new(uri.request_uri)
95
+ request["Accept"] = "application/json"
96
+
97
+ form = { "token" => token }
98
+ case @client_auth_method
99
+ when :client_secret_basic
100
+ request["Authorization"] = "Basic #{basic_credentials}"
101
+ when :client_secret_post
102
+ form["client_id"] = @client_id
103
+ form["client_secret"] = @client_secret.to_s
104
+ end
105
+ request.set_form_data(form)
106
+
107
+ body = "".dup
108
+
109
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
110
+ http.request(request) do |response|
111
+ unless response.is_a?(Net::HTTPOK)
112
+ raise IntrospectionError, "Introspection endpoint responded with status #{response.code}"
113
+ end
114
+
115
+ response.read_body do |chunk|
116
+ body << chunk
117
+ if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES
118
+ raise IntrospectionError, "Introspection response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes"
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ parsed = JSON.parse(body)
125
+ unless parsed.is_a?(Hash)
126
+ raise IntrospectionError, "Introspection endpoint returned a non-object JSON document"
127
+ end
128
+
129
+ parsed
130
+ rescue JSON::ParserError
131
+ raise IntrospectionError, "Introspection endpoint returned invalid JSON"
132
+ end
133
+
134
+ # RFC 6749 Section 2.3.1: the client id and secret are form-urlencoded before being joined and base64-encoded,
135
+ # so a `:` or another reserved character in either survives the round trip; `Net::HTTP#basic_auth` skips that step.
136
+ def basic_credentials
137
+ credentials = "#{URI.encode_www_form_component(@client_id)}:#{URI.encode_www_form_component(@client_secret.to_s)}"
138
+
139
+ [credentials].pack("m0")
140
+ end
141
+
142
+ def validate_audience!(claims)
143
+ audience_values = Array(claims["aud"]) + Array(claims["resource"])
144
+ return if audience_values.include?(@audience)
145
+
146
+ raise InvalidTokenError, "Invalid audience"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end