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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +105 -18
- data/lib/mcp/client/oauth/flow.rb +276 -23
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +77 -6
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +712 -160
- data/lib/mcp/server.rb +63 -31
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -20,6 +20,8 @@ module MCP
|
|
|
20
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
21
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
22
22
|
# URL (a `URI`). Implementations typically open the user's browser.
|
|
23
|
+
#
|
|
24
|
+
# Optional keyword arguments:
|
|
23
25
|
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
|
24
26
|
# `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
|
|
25
27
|
# `state` is the `state` parameter received on the redirect URI, and `iss` is
|
|
@@ -28,16 +30,27 @@ module MCP
|
|
|
28
30
|
# must match the authorization server's issuer, and a nil `iss` is
|
|
29
31
|
# rejected when the AS advertises `authorization_response_iss_parameter_supported`.
|
|
30
32
|
# The 2-element form skips the check for backward compatibility.
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
+
# Omit it when the redirect arrives in a later request, as it does in a web application:
|
|
34
|
+
# the flow then stops after `redirect_handler` with a pending authorization saved in `storage`,
|
|
35
|
+
# and the request that receives the redirect finishes it with `Flow#finish!`.
|
|
36
|
+
# - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable, counted from the moment
|
|
37
|
+
# `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
33
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
34
39
|
# `WWW-Authenticate` does not specify one.
|
|
40
|
+
# - `scope_selector` - Callable receiving a read-only Array of PRM `scopes_supported` tokens when
|
|
41
|
+
# those defaults are selected without a challenged scope. Return an Array containing a subset;
|
|
42
|
+
# `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
|
|
43
|
+
# The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
|
|
35
44
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
36
45
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
37
46
|
# an `InMemoryStorage`. Persisted `client_information` is stamped with
|
|
38
47
|
# an `"issuer"` member binding it to the authorization server that
|
|
39
48
|
# issued it (SEP-2352); when the authorization server changes, the SDK discards
|
|
40
49
|
# the stale registration and tokens and re-registers.
|
|
50
|
+
# Without `callback_handler`, it must also respond to `save_pending_authorization(state, pending)`,
|
|
51
|
+
# `pending_authorization(state)`, and `delete_pending_authorization(state)`; the last must remove the entry
|
|
52
|
+
# and return it atomically, returning `nil` when there was none, and the storage should expire entries
|
|
53
|
+
# older than `pending_authorization_max_age`, as `InMemoryStorage` does.
|
|
41
54
|
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
42
55
|
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
43
56
|
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
@@ -78,25 +91,47 @@ module MCP
|
|
|
78
91
|
# applies and the value must unambiguously identify the document.
|
|
79
92
|
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
|
|
80
93
|
|
|
94
|
+
# Raised when `Provider#initialize` is called without `callback_handler` and with a `storage`
|
|
95
|
+
# that cannot hold a pending authorization between the request that sends the user to the authorization server
|
|
96
|
+
# and the request that receives the redirect.
|
|
97
|
+
class PendingAuthorizationStorageError < ArgumentError; end
|
|
98
|
+
|
|
99
|
+
# Seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when the provider has
|
|
100
|
+
# no `callback_handler`; long enough for a user to sign in and consent at the authorization server. It is also
|
|
101
|
+
# the age past which `InMemoryStorage` drops an authorization that was never finished, at its next save, so that
|
|
102
|
+
# an abandoned one does not keep its PKCE verifier in memory for the life of the process; a custom storage should
|
|
103
|
+
# expire entries at the same age.
|
|
104
|
+
DEFAULT_PENDING_AUTHORIZATION_MAX_AGE = 600
|
|
105
|
+
|
|
106
|
+
PENDING_AUTHORIZATION_STORAGE_METHODS = [
|
|
107
|
+
:save_pending_authorization,
|
|
108
|
+
:pending_authorization,
|
|
109
|
+
:delete_pending_authorization,
|
|
110
|
+
].freeze
|
|
111
|
+
|
|
81
112
|
attr_reader :client_metadata,
|
|
82
113
|
:redirect_uri,
|
|
83
114
|
:scope,
|
|
115
|
+
:scope_selector,
|
|
84
116
|
:storage,
|
|
85
117
|
:redirect_handler,
|
|
86
118
|
:callback_handler,
|
|
87
|
-
:client_id_metadata_document_url
|
|
119
|
+
:client_id_metadata_document_url,
|
|
120
|
+
:pending_authorization_max_age
|
|
88
121
|
|
|
89
122
|
def initialize(
|
|
90
123
|
client_metadata:,
|
|
91
124
|
redirect_uri:,
|
|
92
125
|
redirect_handler:,
|
|
93
|
-
callback_handler
|
|
126
|
+
callback_handler: nil,
|
|
94
127
|
scope: nil,
|
|
128
|
+
scope_selector: nil,
|
|
95
129
|
storage: nil,
|
|
96
130
|
client_id_metadata_document_url: nil,
|
|
97
131
|
authorization_request_validator: nil,
|
|
98
132
|
token_request_params: nil,
|
|
99
|
-
http_client_customizer: nil
|
|
133
|
+
http_client_customizer: nil,
|
|
134
|
+
pending_authorization_max_age: DEFAULT_PENDING_AUTHORIZATION_MAX_AGE
|
|
100
135
|
)
|
|
101
136
|
unless Discovery.secure_url?(redirect_uri)
|
|
102
137
|
raise InsecureRedirectURIError,
|
|
@@ -118,18 +153,40 @@ module MCP
|
|
|
118
153
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
119
154
|
end
|
|
120
155
|
|
|
156
|
+
unless scope_selector.nil? || scope_selector.respond_to?(:call)
|
|
157
|
+
raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
|
|
158
|
+
end
|
|
159
|
+
|
|
121
160
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
122
161
|
|
|
162
|
+
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
163
|
+
raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The default storage drops abandoned pending authorizations at the same age the flow stops redeeming them.
|
|
167
|
+
storage ||= InMemoryStorage.new(pending_authorization_max_age: pending_authorization_max_age)
|
|
168
|
+
|
|
169
|
+
if callback_handler.nil?
|
|
170
|
+
missing = PENDING_AUTHORIZATION_STORAGE_METHODS.reject { |method| storage.respond_to?(method) }
|
|
171
|
+
unless missing.empty?
|
|
172
|
+
raise PendingAuthorizationStorageError,
|
|
173
|
+
"Without a callback_handler the authorization finishes in a later request, so storage must also respond to " \
|
|
174
|
+
"#{missing.join(", ")} (#{storage.class} does not)."
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
123
178
|
@client_metadata = client_metadata
|
|
124
179
|
@redirect_uri = redirect_uri
|
|
125
180
|
@redirect_handler = redirect_handler
|
|
126
181
|
@callback_handler = callback_handler
|
|
127
182
|
@scope = scope
|
|
128
|
-
@
|
|
183
|
+
@scope_selector = scope_selector
|
|
184
|
+
@storage = storage
|
|
129
185
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
130
186
|
@authorization_request_validator = authorization_request_validator
|
|
131
187
|
@token_request_params = frozen_token_request_params(token_request_params)
|
|
132
188
|
@http_client_customizer = http_client_customizer
|
|
189
|
+
@pending_authorization_max_age = pending_authorization_max_age
|
|
133
190
|
end
|
|
134
191
|
|
|
135
192
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -138,6 +195,20 @@ module MCP
|
|
|
138
195
|
def authorization_flow
|
|
139
196
|
:authorization_code
|
|
140
197
|
end
|
|
198
|
+
|
|
199
|
+
def save_pending_authorization(state, pending)
|
|
200
|
+
@storage.save_pending_authorization(state, pending)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def pending_authorization(state)
|
|
204
|
+
@storage.pending_authorization(state)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Returns the entry the storage removed, or `nil`. `Flow#finish!` redeems the code only when it gets
|
|
208
|
+
# the entry back, so of two callbacks racing on the same `state`, only one can.
|
|
209
|
+
def delete_pending_authorization(state)
|
|
210
|
+
@storage.delete_pending_authorization(state)
|
|
211
|
+
end
|
|
141
212
|
end
|
|
142
213
|
end
|
|
143
214
|
end
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -442,17 +442,22 @@ module MCP
|
|
|
442
442
|
# other server-to-client requests over stdio stay unsupported as documented,
|
|
443
443
|
# and notifications carry no id.
|
|
444
444
|
if parsed.is_a?(Hash) && parsed.key?("method")
|
|
445
|
-
# A JSON-RPC id is a String or a Number; the reference SDKs reject
|
|
446
|
-
# schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
-
answer_ping(parsed) if
|
|
445
|
+
# A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
|
|
446
|
+
# other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
+
answer_ping(parsed) if answerable_ping?(parsed)
|
|
448
448
|
next
|
|
449
449
|
end
|
|
450
450
|
|
|
451
451
|
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
|
|
452
452
|
# the same way as a frame without an id.
|
|
453
453
|
next unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
454
|
+
next unless parsed["id"] == request_id
|
|
454
455
|
|
|
455
|
-
|
|
456
|
+
# Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
|
|
457
|
+
# instead of being skipped: without a `read_timeout`, skipping would wait forever.
|
|
458
|
+
raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
459
|
+
|
|
460
|
+
return parsed
|
|
456
461
|
end
|
|
457
462
|
rescue JSON::ParserError => e
|
|
458
463
|
raise RequestHandlerError.new(
|
|
@@ -475,6 +480,10 @@ module MCP
|
|
|
475
480
|
# surface as an error of the unrelated request whose response the loop is reading.
|
|
476
481
|
end
|
|
477
482
|
|
|
483
|
+
def answerable_ping?(parsed)
|
|
484
|
+
parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
485
|
+
end
|
|
486
|
+
|
|
478
487
|
def json_rpc_id?(id)
|
|
479
488
|
id.is_a?(String) || id.is_a?(Numeric)
|
|
480
489
|
end
|
|
@@ -532,6 +541,16 @@ module MCP
|
|
|
532
541
|
error_type: :internal_error,
|
|
533
542
|
)
|
|
534
543
|
end
|
|
544
|
+
|
|
545
|
+
# The line framing is intact, so the transport stays usable; the received value is left out of
|
|
546
|
+
# the message because it can be any JSON value.
|
|
547
|
+
def raise_invalid_response!(method, params)
|
|
548
|
+
raise RequestHandlerError.new(
|
|
549
|
+
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
|
|
550
|
+
{ method: method, params: params },
|
|
551
|
+
error_type: :internal_error,
|
|
552
|
+
)
|
|
553
|
+
end
|
|
535
554
|
end
|
|
536
555
|
end
|
|
537
556
|
end
|
data/lib/mcp/icon.rb
CHANGED
|
@@ -9,9 +9,70 @@ module MCP
|
|
|
9
9
|
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
|
|
10
10
|
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
|
|
11
11
|
# (the specification expects `WxH` or `"any"`).
|
|
12
|
+
#
|
|
13
|
+
# Wherever an `Icon` is accepted (`Server.new(icons:)` and the `icons` of a tool, prompt, resource, or resource template),
|
|
14
|
+
# a Hash is accepted too and converted through `Icon.from`, so it is checked the same way.
|
|
12
15
|
class Icon
|
|
13
16
|
SUPPORTED_THEMES = ["light", "dark"].freeze
|
|
14
17
|
|
|
18
|
+
# The keys `from` accepts in a Hash: the keyword names of `new` and the wire name `mimeType`,
|
|
19
|
+
# as Symbols or Strings.
|
|
20
|
+
HASH_KEYWORDS = {
|
|
21
|
+
"src" => :src,
|
|
22
|
+
"mimeType" => :mime_type,
|
|
23
|
+
"mime_type" => :mime_type,
|
|
24
|
+
"sizes" => :sizes,
|
|
25
|
+
"theme" => :theme,
|
|
26
|
+
}.freeze
|
|
27
|
+
private_constant :HASH_KEYWORDS
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Returns `value` when it is already an `Icon`, builds one from a Hash, and refuses anything else.
|
|
31
|
+
def from(value)
|
|
32
|
+
return value if value.is_a?(Icon)
|
|
33
|
+
|
|
34
|
+
unless value.is_a?(Hash)
|
|
35
|
+
raise ArgumentError, "An icon must be an MCP::Icon or a Hash (got #{value.class})."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
keywords = {}
|
|
39
|
+
value.each do |key, member|
|
|
40
|
+
unless key.is_a?(Symbol) || key.is_a?(String)
|
|
41
|
+
raise ArgumentError, "An icon Hash key must be a Symbol or a String (got #{key.class})."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
keyword = HASH_KEYWORDS[key.to_s]
|
|
45
|
+
unless keyword
|
|
46
|
+
raise ArgumentError, "An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got #{key.inspect})."
|
|
47
|
+
end
|
|
48
|
+
raise ArgumentError, "An icon Hash gives #{keyword} twice." if keywords.key?(keyword)
|
|
49
|
+
|
|
50
|
+
keywords[keyword] = member
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
new(**keywords)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Converts the `icons` argument of a server, tool, prompt, resource, or resource template: `nil` stays `nil`,
|
|
57
|
+
# each element of an Array goes through `from` into a frozen Array, and anything else is refused.
|
|
58
|
+
# The frozen Array keeps a later `<<` on a reader from adding an element these checks never saw.
|
|
59
|
+
def from_list(value)
|
|
60
|
+
return if value.nil?
|
|
61
|
+
|
|
62
|
+
unless value.is_a?(Array)
|
|
63
|
+
raise ArgumentError, "icons must be nil or an Array of MCP::Icon or Hash (got #{value.class})."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
icons = value.each_with_index.map do |icon, index|
|
|
67
|
+
from(icon)
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
raise ArgumentError, "icons[#{index}]: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
icons.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
15
76
|
attr_reader :mime_type, :sizes, :src, :theme
|
|
16
77
|
|
|
17
78
|
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
|
data/lib/mcp/prompt.rb
CHANGED
data/lib/mcp/resource.rb
CHANGED
|
@@ -88,7 +88,7 @@ module MCP
|
|
|
88
88
|
if value == NOT_SET
|
|
89
89
|
@icons_value
|
|
90
90
|
else
|
|
91
|
-
@icons_value = value
|
|
91
|
+
@icons_value = Icon.from_list(value)
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -147,7 +147,7 @@ module MCP
|
|
|
147
147
|
@name = name
|
|
148
148
|
@title = title
|
|
149
149
|
@description = description
|
|
150
|
-
@icons = icons
|
|
150
|
+
@icons = Icon.from_list(icons)
|
|
151
151
|
@mime_type = mime_type
|
|
152
152
|
@annotations = annotations
|
|
153
153
|
@size = size
|
|
@@ -89,7 +89,7 @@ module MCP
|
|
|
89
89
|
if value == NOT_SET
|
|
90
90
|
@icons_value
|
|
91
91
|
else
|
|
92
|
-
@icons_value = value
|
|
92
|
+
@icons_value = Icon.from_list(value)
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -158,7 +158,7 @@ module MCP
|
|
|
158
158
|
@name = name
|
|
159
159
|
@title = title
|
|
160
160
|
@description = description
|
|
161
|
-
@icons = icons
|
|
161
|
+
@icons = Icon.from_list(icons)
|
|
162
162
|
@mime_type = mime_type
|
|
163
163
|
@annotations = annotations
|
|
164
164
|
@meta = meta
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Server
|
|
5
|
+
module OAuth
|
|
6
|
+
# The result of a successful token verification: the resource-server-side view of an access token.
|
|
7
|
+
# Instances are produced by a `TokenVerifier` and reach tool/prompt/resource handlers as `server_context.auth_info`.
|
|
8
|
+
#
|
|
9
|
+
# The field set is the union of what the official SDKs converged on (Python `AccessToken` and TypeScript `AuthInfo`):
|
|
10
|
+
#
|
|
11
|
+
# - `token` - the raw bearer token as presented by the client
|
|
12
|
+
# - `client_id` - the OAuth client the token was issued to
|
|
13
|
+
# - `scopes` - granted scopes as an array of strings
|
|
14
|
+
# - `expires_at` - expiry as an Integer unix timestamp, or nil when the token does not expire
|
|
15
|
+
# - `subject` - the end user (`sub` claim), when known
|
|
16
|
+
# - `issuer` - the authorization server that issued the token (`iss` claim)
|
|
17
|
+
# - `audience` - the raw `aud` value (String or Array), when known
|
|
18
|
+
# - `resource` - the canonical RFC 8707 resource identifier the verifier matched the token against,
|
|
19
|
+
# useful for tenancy checks inside handlers
|
|
20
|
+
# - `claims` - the full claim/introspection-response Hash for anything not covered by the named fields
|
|
21
|
+
class AccessToken
|
|
22
|
+
attr_reader :token, :client_id, :scopes, :expires_at, :subject, :issuer, :audience, :resource, :claims
|
|
23
|
+
|
|
24
|
+
def initialize(token:, client_id: nil, scopes: [], expires_at: nil, subject: nil, issuer: nil, audience: nil, resource: nil, claims: {})
|
|
25
|
+
@token = token
|
|
26
|
+
@client_id = client_id
|
|
27
|
+
@scopes = scopes
|
|
28
|
+
@expires_at = expires_at
|
|
29
|
+
@subject = subject
|
|
30
|
+
@issuer = issuer
|
|
31
|
+
@audience = audience
|
|
32
|
+
@resource = resource
|
|
33
|
+
@claims = claims
|
|
34
|
+
@scope_matcher = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Per RFC 7519 Section 4.1.4, a token must not be accepted on or after its expiry time.
|
|
38
|
+
# Tokens without an expiry never count as expired here; verifiers that require an expiry must enforce that themselves
|
|
39
|
+
# (`JWTVerifier` does, the TypeScript SDK rejects such tokens outright, and Python accepts them like this class —
|
|
40
|
+
# the divergence is deliberate: opaque-token verifiers may have no expiry to report even though the authorization server enforces one).
|
|
41
|
+
def expired?(now: Time.now.to_i)
|
|
42
|
+
return false if expires_at.nil?
|
|
43
|
+
|
|
44
|
+
expires_at <= now
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the token grants `scope`: exact membership by default. When the authenticator attached its `scope_matcher:`
|
|
48
|
+
# (see `with_scope_matcher`), that callable decides instead, so a hierarchical scope scheme is honored the same way
|
|
49
|
+
# at the endpoint gate and inside handlers via `require_scopes!`.
|
|
50
|
+
def scope?(scope)
|
|
51
|
+
return scopes.include?(scope.to_s) if @scope_matcher.nil?
|
|
52
|
+
|
|
53
|
+
!!@scope_matcher.call(scope.to_s, scopes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A copy of this token whose `scope?` consults `matcher`, a `(required_scope, granted_scopes) -> Boolean` callable.
|
|
57
|
+
# The receiver is left untouched; a nil matcher returns the receiver itself.
|
|
58
|
+
def with_scope_matcher(matcher)
|
|
59
|
+
return self if matcher.nil?
|
|
60
|
+
|
|
61
|
+
dup.tap { |copy| copy.scope_matcher = matcher }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Mirrors `to_h`: the default `inspect` would print `@token`, leaking the credential into exception reports and debug output.
|
|
65
|
+
def inspect
|
|
66
|
+
"#<#{self.class.name} #{to_h.inspect}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Omits `token` so the result is safe to log without leaking the credential.
|
|
70
|
+
def to_h
|
|
71
|
+
{
|
|
72
|
+
client_id: client_id,
|
|
73
|
+
scopes: scopes,
|
|
74
|
+
expires_at: expires_at,
|
|
75
|
+
subject: subject,
|
|
76
|
+
issuer: issuer,
|
|
77
|
+
audience: audience,
|
|
78
|
+
resource: resource,
|
|
79
|
+
claims: claims,
|
|
80
|
+
}.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
attr_writer :scope_matcher
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -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
|