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
|
@@ -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
|