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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +94 -17
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +23 -4
- 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 +488 -120
- data/lib/mcp/server.rb +55 -28
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -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
|