mcp 1.7.0 → 1.8.1

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,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
@@ -49,10 +49,26 @@ module MCP
49
49
  end
50
50
  break if line.nil?
51
51
 
52
- line = line.strip
53
- parsed = parse_line(line)
54
- response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
55
- send_response(response) if response
52
+ line = strip_frame(line)
53
+
54
+ # Reset for each frame: the loop body shares one scope, so a frame whose parse raises would otherwise
55
+ # be answered with the previous frame's id.
56
+ parsed = nil
57
+
58
+ begin
59
+ parsed = parse_line(line)
60
+ response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
61
+
62
+ send_response(response) if response
63
+ rescue StandardError => e
64
+ # One frame must not end the connection: a reply that cannot be generated (a request string
65
+ # the JSON parser passed through without being valid UTF-8, echoed by the SDK or a handler) or
66
+ # any other failure past the server's own error handling answers that frame with Internal error,
67
+ # unless the frame is a notification, which gets no response.
68
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to handle stdio frame" })
69
+
70
+ send_response(internal_error_response(parsed)) unless parsed.is_a?(Hash) && !parsed.key?(:id)
71
+ end
56
72
  end
57
73
  rescue Interrupt
58
74
  warn("\nExiting...")
@@ -70,6 +86,16 @@ module MCP
70
86
  $stdout.flush
71
87
  end
72
88
 
89
+ # The error answering a frame whose handling failed. It carries the request id when the frame was
90
+ # a single request whose id can be written back, and no `id` otherwise, as for an id that could not be read.
91
+ def internal_error_response(parsed)
92
+ response = { jsonrpc: JsonRpcHandler::Version::V2_0 }
93
+ id = parsed[:id] if parsed.is_a?(Hash)
94
+ response[:id] = id if id.is_a?(Integer) || (id.is_a?(String) && id.valid_encoding?)
95
+ response[:error] = { code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, message: "Internal error" }
96
+ response
97
+ end
98
+
73
99
  def send_notification(method, params = nil)
74
100
  notification = {
75
101
  jsonrpc: "2.0",
@@ -111,8 +137,8 @@ module MCP
111
137
 
112
138
  while @open && (line = read_line($stdin))
113
139
  begin
114
- parsed = JSON.parse(line.strip, symbolize_names: true)
115
- rescue JSON::ParserError => e
140
+ parsed = JSON.parse(strip_frame(line), symbolize_names: true)
141
+ rescue JSON::ParserError, EncodingError => e
116
142
  MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
117
143
  raise
118
144
  end
@@ -152,13 +178,22 @@ module MCP
152
178
  line
153
179
  end
154
180
 
181
+ # Strips a frame as bytes. `$stdin` is read as UTF-8, and `String#strip` raises for a UTF-8 string holding
182
+ # an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, so one such
183
+ # frame would end the read loop instead of being answered. As bytes, it reaches the parser, which refuses
184
+ # what is not JSON with a parse error.
185
+ def strip_frame(line)
186
+ line.b.strip.force_encoding(Encoding::UTF_8)
187
+ end
188
+
155
189
  # Parses a frame once so era classification can inspect its method and `_meta`.
156
190
  # Returns `nil` for frames that are not JSON objects; those fall back to
157
191
  # `ServerSession#handle_json` so protocol-level error responses stay identical.
158
192
  def parse_line(line)
159
193
  parsed = JSON.parse(line, symbolize_names: true)
160
194
  parsed.is_a?(Hash) ? parsed : nil
161
- rescue JSON::ParserError
195
+ rescue JSON::ParserError, EncodingError
196
+ # `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8; `handle_json` answers the parse error.
162
197
  nil
163
198
  end
164
199
 
@@ -168,7 +203,9 @@ module MCP
168
203
  # (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock)
169
204
  # are rejected in-band by `Server#lift_request_envelope`.
170
205
  def dispatch_with_era(parsed)
171
- response = @session.handle(parsed)
206
+ # Passing `auth_info:` keeps Ruby 2.7 from taking the request Hash as keyword arguments,
207
+ # which works but warns that the conversion is deprecated.
208
+ response = @session.handle(parsed, auth_info: nil)
172
209
  lock_modern_era_on_success(parsed, response)
173
210
  response
174
211
  end