Skip to content

Commit 5f429f3

Browse files
authored
Merge pull request #560 from koic/let_a_provider_customize_the_oauth_http_client
Let a provider customize the HTTP client the OAuth flow uses
2 parents e1105c7 + c9f381a commit 5f429f3

14 files changed

Lines changed: 856 additions & 26 deletions

‎docs/_client/authorization.md‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ Optional keyword arguments:
9797
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
9898
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
9999
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
100+
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
101+
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
100102
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
101103
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
102104
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
@@ -219,7 +221,7 @@ Keyword arguments:
219221
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
220222
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
221223
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
222-
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
224+
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
223225
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
224226

225227
### Cross-App Access (JWT Bearer) Grant
@@ -258,7 +260,39 @@ Keyword arguments:
258260
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
259261
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
260262
Passing both through to `IDJAGTokenExchange.request` covers the common case.
261-
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
263+
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
264+
265+
### Customizing the OAuth HTTP Client
266+
267+
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
268+
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
269+
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
270+
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
271+
272+
```ruby
273+
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
274+
client_id: "my-service",
275+
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
276+
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
277+
)
278+
```
279+
280+
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
281+
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
282+
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
283+
today it runs once per authorization attempt, but that is not a promise.
284+
A few constraints follow from the checks described below:
285+
286+
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
287+
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
288+
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
289+
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
290+
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
291+
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
292+
and to turn a token endpoint error into `Flow::InvalidGrantError`.
293+
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
294+
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
295+
and the access tokens in token responses; redact them before they reach a log.
262296

263297
### Communication Security
264298

@@ -290,8 +324,11 @@ The range check compares IP literals and does not resolve hostnames, so it canno
290324
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
291325
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
292326

293-
If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against
294-
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
327+
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
328+
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
329+
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
330+
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
331+
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
295332

296333
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
297334
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.

‎docs/_client/transports.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
248248
end
249249
```
250250

251+
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
252+
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
253+
251254
{: .note }
252255
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
253256
> the SSE streaming callback of another response, re-entering the connection on the same thread.

‎lib/mcp/client/oauth/bounded_body.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def initialize(max_bytes: MAX_RESPONSE_BYTES)
2626
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
2727
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
2828
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
29-
# the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
29+
# the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
3030
def on_data
3131
proc do |chunk, _received_bytes, _env|
3232
@buffer << chunk

‎lib/mcp/client/oauth/client_credentials_provider.rb‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ module OAuth
4646
# server requires beyond the grant itself (Auth0's `audience`, for example).
4747
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
4848
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
49+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
50+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
4951
class ClientCredentialsProvider
5052
include StorageBackedProvider
5153

@@ -66,7 +68,8 @@ def initialize(
6668
scope: nil,
6769
storage: nil,
6870
authorization_request_validator: nil,
69-
token_request_params: nil
71+
token_request_params: nil,
72+
http_client_customizer: nil
7073
)
7174
if blank?(client_id)
7275
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@@ -104,13 +107,16 @@ def initialize(
104107
client_information["client_secret"] = client_secret
105108
end
106109

110+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
111+
107112
@client_id = client_id
108113
@private_key = private_key
109114
@signing_algorithm = signing_algorithm
110115
@scope = scope
111116
@storage = storage || InMemoryStorage.new
112117
@authorization_request_validator = authorization_request_validator
113118
@token_request_params = frozen_token_request_params(token_request_params)
119+
@http_client_customizer = http_client_customizer
114120
@storage.save_client_information(client_information)
115121
end
116122

‎lib/mcp/client/oauth/cross_app_access_provider.rb‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ module OAuth
2929
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
3030
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
3131
# See `StorageBackedProvider#token_request_params`.
32+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
33+
# see `StorageBackedProvider#http_client_customizer`.
3234
#
3335
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
3436
class CrossAppAccessProvider
@@ -46,7 +48,8 @@ def initialize(
4648
scope: nil,
4749
storage: nil,
4850
authorization_request_validator: nil,
49-
token_request_params: nil
51+
token_request_params: nil,
52+
http_client_customizer: nil
5053
)
5154
if blank?(client_id)
5255
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
@@ -60,11 +63,14 @@ def initialize(
6063
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
6164
end
6265

66+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
67+
6368
@assertion_provider = assertion_provider
6469
@scope = scope
6570
@storage = storage || InMemoryStorage.new
6671
@authorization_request_validator = authorization_request_validator
6772
@token_request_params = frozen_token_request_params(token_request_params)
73+
@http_client_customizer = http_client_customizer
6874
@storage.save_client_information(
6975
"client_id" => client_id,
7076
"client_secret" => client_secret,

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,71 @@ class AuthorizationRefusedError < AuthorizationError; end
6363
# a failed refresh as a reason to run the interactive flow.
6464
class InvalidTokenRequestParamsError < ArgumentError; end
6565

66+
# Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
67+
# would send a request to an origin other than the one the flow validated, or has dropped the record of
68+
# the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
69+
# and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
70+
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
71+
class DestinationMismatchError < ArgumentError; end
72+
73+
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
74+
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
75+
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
76+
# The guard covers what happens to a request after that record; middleware inserted ahead of it with
77+
# `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
78+
# The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
79+
# which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
80+
# on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
81+
# a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
82+
# the redirected URL.
83+
class RequestedURLStamp
84+
KEY = :mcp_oauth_requested_url
85+
86+
def initialize(app)
87+
@app = app
88+
end
89+
90+
def call(env)
91+
env[KEY] ||= env.url.to_s
92+
@app.call(env)
93+
end
94+
end
95+
96+
# Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
97+
# middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
98+
# for is refused before it reaches the adapter, since every destination check ran against the URL as
99+
# written; a same-origin change stays with the server those checks admitted. The record survives
100+
# the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
101+
# refused as well, so a middleware that rebuilds the environment fails closed rather than open.
102+
# The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
103+
# a redirect itself only within the origin; this flow follows none.
104+
class RequestedOriginGuard
105+
def initialize(app)
106+
@app = app
107+
end
108+
109+
def call(env)
110+
requested = env[RequestedURLStamp::KEY]
111+
unless requested
112+
raise DestinationMismatchError, <<~MESSAGE
113+
Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
114+
the URL the flow asked for; middleware that rebuilds the request environment is refused.
115+
MESSAGE
116+
end
117+
118+
unless Discovery.same_origin?(env.url.to_s, requested)
119+
raise DestinationMismatchError, <<~MESSAGE
120+
Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
121+
#{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
122+
middleware that follows redirects or rewrites URLs is refused.
123+
MESSAGE
124+
end
125+
126+
@app.call(env)
127+
end
128+
end
129+
private_constant :RequestedURLStamp, :RequestedOriginGuard
130+
66131
class << self
67132
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
68133
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
@@ -82,6 +147,33 @@ def token_request_params_problem(params)
82147

83148
nil
84149
end
150+
151+
# Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
152+
# then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
153+
# then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
154+
# after the stamp recorded it. Every request on the connection passes through both, so a caller using
155+
# it directly is held to the same origin rule.
156+
#
157+
# Deliberately built without redirect-following middleware. Every destination check in this class runs
158+
# against the URL as written, before the request goes out, so a connection that transparently followed
159+
# a `3xx` would let a server reach a host the checks just refused. The guard turns following at
160+
# the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
161+
# not enable it.
162+
#
163+
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
164+
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
165+
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
166+
# after the check.
167+
def build_http_client(customizer = nil)
168+
require "faraday"
169+
170+
Faraday.new do |faraday|
171+
faraday.headers["Accept"] = "application/json"
172+
faraday.use(RequestedURLStamp)
173+
customizer&.call(faraday)
174+
faraday.use(RequestedOriginGuard)
175+
end
176+
end
85177
end
86178

87179
def initialize(provider:, http_client_factory: nil)
@@ -1298,22 +1390,18 @@ def http_client
12981390
@http_client ||= @http_client_factory.call
12991391
end
13001392

1301-
# Deliberately built without redirect-following middleware. Every destination check in
1302-
# this class runs against the URL as written, before the request goes out, so a connection
1303-
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
1304-
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
1305-
# and the guards above only cover the first hop.
1306-
#
1307-
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
1308-
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
1309-
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
1310-
# after the check.
1393+
# A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
1394+
# `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
1395+
# `build_http_client`; `bounded_request` caps its responses all the same.
13111396
def default_http_client
1312-
require "faraday"
1397+
self.class.build_http_client(provider_http_client_customizer)
1398+
end
13131399

1314-
Faraday.new do |faraday|
1315-
faraday.headers["Accept"] = "application/json"
1316-
end
1400+
# `nil` for a provider that predates the hook or leaves it unset.
1401+
def provider_http_client_customizer
1402+
return unless @provider.respond_to?(:http_client_customizer)
1403+
1404+
@provider.http_client_customizer
13171405
end
13181406

13191407
def response_body_string(response)

0 commit comments

Comments
 (0)