mcp 0.16.0 → 0.17.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 +107 -0
- data/lib/mcp/client/http.rb +202 -55
- data/lib/mcp/client/oauth/discovery.rb +423 -0
- data/lib/mcp/client/oauth/flow.rb +587 -0
- data/lib/mcp/client/oauth/in_memory_storage.rb +43 -0
- data/lib/mcp/client/oauth/pkce.rb +38 -0
- data/lib/mcp/client/oauth/provider.rb +103 -0
- data/lib/mcp/client/oauth.rb +18 -0
- data/lib/mcp/client.rb +1 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +127 -13
- data/lib/mcp/server.rb +9 -0
- data/lib/mcp/server_session.rb +13 -0
- data/lib/mcp/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 325fab0f82b31b6a2614ef626ebecb12a7bf22c199ddf189dcd70b4fcf9acb58
|
|
4
|
+
data.tar.gz: 7cb2b010e0808efce62e9d80f487c7cb6bff9604e13f6b2b68590ffa4e0fc784
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c675a999c460dd7a285203e949587ecf1faf43a0f80f67f6f00bbb06abaa31846382cf013a0590a22c237b341860bc9a880d2d6d5de2971c8f70cde8d9d78ee
|
|
7
|
+
data.tar.gz: 84f4ca2c9a3a745976059655e2ea6f89ff5bae6637b32ab151894f12b3aea96ce5f11d9e50b3c908769f97e9bc923fd68f0e0b83060104964710d6ca70f06cb0
|
data/README.md
CHANGED
|
@@ -1885,6 +1885,113 @@ client.tools # will make the call using Bearer auth
|
|
|
1885
1885
|
|
|
1886
1886
|
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
|
|
1887
1887
|
|
|
1888
|
+
#### OAuth 2.1 Authorization
|
|
1889
|
+
|
|
1890
|
+
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
|
|
1891
|
+
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
|
|
1892
|
+
|
|
1893
|
+
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
|
|
1894
|
+
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
|
1895
|
+
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
|
|
1896
|
+
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
|
1897
|
+
|
|
1898
|
+
```ruby
|
|
1899
|
+
require "mcp"
|
|
1900
|
+
|
|
1901
|
+
provider = MCP::Client::OAuth::Provider.new(
|
|
1902
|
+
client_metadata: {
|
|
1903
|
+
client_name: "My MCP App",
|
|
1904
|
+
redirect_uris: ["http://localhost:3030/callback"],
|
|
1905
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1906
|
+
response_types: ["code"],
|
|
1907
|
+
token_endpoint_auth_method: "none",
|
|
1908
|
+
},
|
|
1909
|
+
redirect_uri: "http://localhost:3030/callback",
|
|
1910
|
+
redirect_handler: ->(authorization_url) {
|
|
1911
|
+
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
|
|
1912
|
+
# or a manual `puts authorization_url` in CLI tools.
|
|
1913
|
+
},
|
|
1914
|
+
callback_handler: -> {
|
|
1915
|
+
# Capture the redirect (for example, by running a small HTTP listener on
|
|
1916
|
+
# `redirect_uri`) and return [code, state] from the query string.
|
|
1917
|
+
},
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
transport = MCP::Client::HTTP.new(
|
|
1921
|
+
url: "https://api.example.com/mcp",
|
|
1922
|
+
oauth: provider,
|
|
1923
|
+
)
|
|
1924
|
+
client = MCP::Client.new(transport: transport)
|
|
1925
|
+
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
|
|
1926
|
+
client.tools
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
Required keyword arguments to `Provider.new`:
|
|
1930
|
+
|
|
1931
|
+
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
|
|
1932
|
+
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
|
|
1933
|
+
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
|
|
1934
|
+
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
|
|
1935
|
+
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
|
|
1936
|
+
|
|
1937
|
+
Optional keyword arguments:
|
|
1938
|
+
|
|
1939
|
+
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
|
1940
|
+
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
|
|
1941
|
+
which keeps credentials in process memory only.
|
|
1942
|
+
|
|
1943
|
+
To persist credentials across restarts, supply your own storage:
|
|
1944
|
+
|
|
1945
|
+
```ruby
|
|
1946
|
+
class FileTokenStorage
|
|
1947
|
+
def initialize(path)
|
|
1948
|
+
@path = path
|
|
1949
|
+
end
|
|
1950
|
+
|
|
1951
|
+
def tokens
|
|
1952
|
+
read["tokens"]
|
|
1953
|
+
end
|
|
1954
|
+
|
|
1955
|
+
def save_tokens(value)
|
|
1956
|
+
write("tokens" => value)
|
|
1957
|
+
end
|
|
1958
|
+
|
|
1959
|
+
def client_information
|
|
1960
|
+
read["client"]
|
|
1961
|
+
end
|
|
1962
|
+
|
|
1963
|
+
def save_client_information(value)
|
|
1964
|
+
write("client" => value)
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
private
|
|
1968
|
+
|
|
1969
|
+
def read
|
|
1970
|
+
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
1971
|
+
end
|
|
1972
|
+
|
|
1973
|
+
def write(updates)
|
|
1974
|
+
File.write(@path, JSON.dump(read.merge(updates)))
|
|
1975
|
+
end
|
|
1976
|
+
end
|
|
1977
|
+
|
|
1978
|
+
provider = MCP::Client::OAuth::Provider.new(
|
|
1979
|
+
# ... required keywords ...
|
|
1980
|
+
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
|
|
1981
|
+
)
|
|
1982
|
+
```
|
|
1983
|
+
|
|
1984
|
+
##### Communication Security
|
|
1985
|
+
|
|
1986
|
+
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
|
1987
|
+
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
|
|
1988
|
+
|
|
1989
|
+
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
|
|
1990
|
+
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
|
|
1991
|
+
whether the swap was triggered by
|
|
1992
|
+
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
|
|
1993
|
+
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
|
|
1994
|
+
|
|
1888
1995
|
#### Customizing the Faraday Connection
|
|
1889
1996
|
|
|
1890
1997
|
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -17,12 +17,74 @@ module MCP
|
|
|
17
17
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
18
18
|
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
21
|
+
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
22
|
+
# is trivially observed and stolen.
|
|
23
|
+
class InsecureURLError < ArgumentError; end
|
|
24
|
+
|
|
25
|
+
# Faraday request middleware that compares the outgoing request URL
|
|
26
|
+
# against the URL snapshotted at `MCP::Client::HTTP#initialize` time.
|
|
27
|
+
# Registered after the user's customizer so it sees `env.url` *after*
|
|
28
|
+
# any custom middleware has had a chance to rewrite it - closing
|
|
29
|
+
# the `Faraday env.url = URI("https://attacker...")` bypass that a plain
|
|
30
|
+
# `client.url_prefix` check would miss. The comparison includes
|
|
31
|
+
# the query string, so a middleware that rewrites `env.url.query` to
|
|
32
|
+
# a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise
|
|
33
|
+
# the audience-binding check on the OAuth side could be bypassed at
|
|
34
|
+
# the send step.
|
|
35
|
+
class OAuthURLGuard
|
|
36
|
+
def initialize(app, expected_url:)
|
|
37
|
+
@app = app
|
|
38
|
+
@expected_url = expected_url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(env)
|
|
42
|
+
effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s)
|
|
43
|
+
unless effective == @expected_url
|
|
44
|
+
# Surface the *canonicalized* form (no userinfo, no fragment) so
|
|
45
|
+
# credentials like `user:pass@` cannot leak into logs, stack
|
|
46
|
+
# traces, or exception reporters.
|
|
47
|
+
raise InsecureURLError,
|
|
48
|
+
"Effective request URL #{effective.inspect} does not match the URL " \
|
|
49
|
+
"validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token."
|
|
50
|
+
end
|
|
51
|
+
@app.call(env)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
56
|
+
|
|
57
|
+
def initialize(url:, headers: {}, oauth: nil, &block)
|
|
58
|
+
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
|
59
|
+
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
|
60
|
+
# so they cannot leak into logs.
|
|
61
|
+
safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url)
|
|
62
|
+
raise InsecureURLError,
|
|
63
|
+
"MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \
|
|
64
|
+
"sending bearer tokens over plain http to a remote host would leak them on the wire."
|
|
65
|
+
end
|
|
21
66
|
|
|
22
|
-
def initialize(url:, headers: {}, &block)
|
|
23
67
|
@url = url
|
|
24
68
|
@headers = headers
|
|
25
69
|
@faraday_customizer = block
|
|
70
|
+
@oauth = oauth
|
|
71
|
+
# Snapshot the canonical URL at construction time. This single value
|
|
72
|
+
# serves two related roles, both of which need to see the query string:
|
|
73
|
+
#
|
|
74
|
+
# - As the RFC 8707 `resource` claim sent on the authorization and
|
|
75
|
+
# token requests (and as the base for PRM discovery URLs) -
|
|
76
|
+
# matching the TS / Python SDKs' `resourceUrlFromServerUrl` /
|
|
77
|
+
# `resource_url_from_server_url` so multi-tenant servers that scope
|
|
78
|
+
# by `?tenant=...` round-trip correctly.
|
|
79
|
+
# - As the comparison value for the URL guard middleware. Comparing
|
|
80
|
+
# query strings as well as origin + path is required so a Faraday
|
|
81
|
+
# middleware that rewrites `env.url.query` to a different tenant
|
|
82
|
+
# cannot send the bearer token to the wrong audience while
|
|
83
|
+
# the resource binding on the OAuth side stays correct.
|
|
84
|
+
#
|
|
85
|
+
# Saved only when `oauth:` is set so non-OAuth transports keep their
|
|
86
|
+
# existing behavior.
|
|
87
|
+
@oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil
|
|
26
88
|
@session_id = nil
|
|
27
89
|
@protocol_version = nil
|
|
28
90
|
@server_info = nil
|
|
@@ -30,8 +92,8 @@ module MCP
|
|
|
30
92
|
end
|
|
31
93
|
|
|
32
94
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
33
|
-
# followed by the required `notifications/initialized` notification.
|
|
34
|
-
# server's `InitializeResult` (protocol version, capabilities, server
|
|
95
|
+
# followed by the required `notifications/initialized` notification.
|
|
96
|
+
# The server's `InitializeResult` (protocol version, capabilities, server
|
|
35
97
|
# info, instructions) is cached on the transport and returned.
|
|
36
98
|
#
|
|
37
99
|
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
@@ -122,68 +184,80 @@ module MCP
|
|
|
122
184
|
def send_request(request:)
|
|
123
185
|
method = request[:method] || request["method"]
|
|
124
186
|
params = request[:params] || request["params"]
|
|
187
|
+
oauth_retried = false
|
|
125
188
|
|
|
126
|
-
|
|
127
|
-
|
|
189
|
+
begin
|
|
190
|
+
response = client.post("", request, session_headers)
|
|
191
|
+
body = parse_response_body(response, method, params)
|
|
128
192
|
|
|
129
|
-
|
|
193
|
+
capture_session_info(method, response, body)
|
|
130
194
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{ method: method, params: params },
|
|
136
|
-
error_type: :bad_request,
|
|
137
|
-
original_error: e,
|
|
138
|
-
)
|
|
139
|
-
rescue Faraday::UnauthorizedError => e
|
|
140
|
-
raise RequestHandlerError.new(
|
|
141
|
-
"You are unauthorized to make #{method} requests",
|
|
142
|
-
{ method: method, params: params },
|
|
143
|
-
error_type: :unauthorized,
|
|
144
|
-
original_error: e,
|
|
145
|
-
)
|
|
146
|
-
rescue Faraday::ForbiddenError => e
|
|
147
|
-
raise RequestHandlerError.new(
|
|
148
|
-
"You are forbidden to make #{method} requests",
|
|
149
|
-
{ method: method, params: params },
|
|
150
|
-
error_type: :forbidden,
|
|
151
|
-
original_error: e,
|
|
152
|
-
)
|
|
153
|
-
rescue Faraday::ResourceNotFound => e
|
|
154
|
-
# Per spec, 404 is the session-expired signal only when the request
|
|
155
|
-
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
|
156
|
-
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
|
157
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
158
|
-
if @session_id
|
|
159
|
-
clear_session
|
|
160
|
-
raise SessionExpiredError.new(
|
|
161
|
-
"The #{method} request is not found",
|
|
195
|
+
body
|
|
196
|
+
rescue Faraday::BadRequestError => e
|
|
197
|
+
raise RequestHandlerError.new(
|
|
198
|
+
"The #{method} request is invalid",
|
|
162
199
|
{ method: method, params: params },
|
|
200
|
+
error_type: :bad_request,
|
|
163
201
|
original_error: e,
|
|
164
202
|
)
|
|
165
|
-
|
|
203
|
+
rescue Faraday::UnauthorizedError => e
|
|
204
|
+
# Run the OAuth flow at most once per `send_request` invocation.
|
|
205
|
+
# The `oauth_retried` flag lives outside the `begin` so it survives `retry`,
|
|
206
|
+
# ensuring a server returning 401 indefinitely raises rather than loops.
|
|
207
|
+
if @oauth && !oauth_retried
|
|
208
|
+
oauth_retried = true
|
|
209
|
+
run_oauth_flow!(unauthorized_error: e)
|
|
210
|
+
retry
|
|
211
|
+
end
|
|
212
|
+
|
|
166
213
|
raise RequestHandlerError.new(
|
|
167
|
-
"
|
|
214
|
+
"You are unauthorized to make #{method} requests",
|
|
168
215
|
{ method: method, params: params },
|
|
169
|
-
error_type: :
|
|
216
|
+
error_type: :unauthorized,
|
|
217
|
+
original_error: e,
|
|
218
|
+
)
|
|
219
|
+
rescue Faraday::ForbiddenError => e
|
|
220
|
+
raise RequestHandlerError.new(
|
|
221
|
+
"You are forbidden to make #{method} requests",
|
|
222
|
+
{ method: method, params: params },
|
|
223
|
+
error_type: :forbidden,
|
|
224
|
+
original_error: e,
|
|
225
|
+
)
|
|
226
|
+
rescue Faraday::ResourceNotFound => e
|
|
227
|
+
# Per spec, 404 is the session-expired signal only when the request
|
|
228
|
+
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
|
229
|
+
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
|
230
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
231
|
+
if @session_id
|
|
232
|
+
clear_session
|
|
233
|
+
raise SessionExpiredError.new(
|
|
234
|
+
"The #{method} request is not found",
|
|
235
|
+
{ method: method, params: params },
|
|
236
|
+
original_error: e,
|
|
237
|
+
)
|
|
238
|
+
else
|
|
239
|
+
raise RequestHandlerError.new(
|
|
240
|
+
"The #{method} request is not found",
|
|
241
|
+
{ method: method, params: params },
|
|
242
|
+
error_type: :not_found,
|
|
243
|
+
original_error: e,
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
rescue Faraday::UnprocessableEntityError => e
|
|
247
|
+
raise RequestHandlerError.new(
|
|
248
|
+
"The #{method} request is unprocessable",
|
|
249
|
+
{ method: method, params: params },
|
|
250
|
+
error_type: :unprocessable_entity,
|
|
251
|
+
original_error: e,
|
|
252
|
+
)
|
|
253
|
+
rescue Faraday::Error => e
|
|
254
|
+
raise RequestHandlerError.new(
|
|
255
|
+
"Internal error handling #{method} request",
|
|
256
|
+
{ method: method, params: params },
|
|
257
|
+
error_type: :internal_error,
|
|
170
258
|
original_error: e,
|
|
171
259
|
)
|
|
172
260
|
end
|
|
173
|
-
rescue Faraday::UnprocessableEntityError => e
|
|
174
|
-
raise RequestHandlerError.new(
|
|
175
|
-
"The #{method} request is unprocessable",
|
|
176
|
-
{ method: method, params: params },
|
|
177
|
-
error_type: :unprocessable_entity,
|
|
178
|
-
original_error: e,
|
|
179
|
-
)
|
|
180
|
-
rescue Faraday::Error => e # Catch-all
|
|
181
|
-
raise RequestHandlerError.new(
|
|
182
|
-
"Internal error handling #{method} request",
|
|
183
|
-
{ method: method, params: params },
|
|
184
|
-
error_type: :internal_error,
|
|
185
|
-
original_error: e,
|
|
186
|
-
)
|
|
187
261
|
end
|
|
188
262
|
|
|
189
263
|
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
|
@@ -228,20 +302,93 @@ module MCP
|
|
|
228
302
|
end
|
|
229
303
|
|
|
230
304
|
@faraday_customizer&.call(faraday)
|
|
305
|
+
|
|
306
|
+
# Register the URL identity guard *after* the user's customizer
|
|
307
|
+
# so it sits closest to the adapter in the request stack. That way
|
|
308
|
+
# the guard sees `env.url` after any customizer-added middleware has had
|
|
309
|
+
# a chance to rewrite it, closing the `env.url = URI("https://...")`
|
|
310
|
+
# bypass that a `Faraday::Connection#url_prefix` check cannot detect.
|
|
311
|
+
faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url
|
|
231
312
|
end
|
|
232
313
|
end
|
|
233
314
|
|
|
234
315
|
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
|
|
235
316
|
# and `MCP-Protocol-Version` on all requests after `initialize`.
|
|
317
|
+
#
|
|
236
318
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
237
319
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
|
|
238
320
|
def session_headers
|
|
239
321
|
request_headers = {}
|
|
240
322
|
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
|
|
241
323
|
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
|
|
324
|
+
if @oauth && (token = @oauth.access_token)
|
|
325
|
+
request_headers["Authorization"] = "Bearer #{token}"
|
|
326
|
+
end
|
|
242
327
|
request_headers
|
|
243
328
|
end
|
|
244
329
|
|
|
330
|
+
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
|
331
|
+
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
|
332
|
+
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
|
333
|
+
#
|
|
334
|
+
# If the provider already holds a refresh token, we try to exchange it
|
|
335
|
+
# for a fresh access token first; only when that fails (e.g., the refresh token was revoked)
|
|
336
|
+
# do we fall back to the full Authorization Code + PKCE + DCR flow.
|
|
337
|
+
#
|
|
338
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
|
|
339
|
+
def run_oauth_flow!(unauthorized_error:)
|
|
340
|
+
response = unauthorized_error.response || {}
|
|
341
|
+
response_headers = response[:headers] || {}
|
|
342
|
+
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
|
343
|
+
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
|
|
344
|
+
|
|
345
|
+
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
|
346
|
+
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
|
347
|
+
return
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Use the URL snapshotted at `initialize` time so a post-construction
|
|
351
|
+
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
|
352
|
+
# URL to an attacker-controlled host.
|
|
353
|
+
flow.run!(
|
|
354
|
+
server_url: @oauth_server_url,
|
|
355
|
+
resource_metadata_url: params["resource_metadata"],
|
|
356
|
+
scope: params["scope"],
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
|
361
|
+
# on success and falsy on either "no refresh token available" or "refresh attempt failed"
|
|
362
|
+
# (in which case the caller should run the full interactive flow).
|
|
363
|
+
def attempt_refresh(flow:, resource_metadata_url:)
|
|
364
|
+
return false unless refresh_token_available?
|
|
365
|
+
|
|
366
|
+
# Use the snapshotted URL for the same reason as `run_oauth_flow!` above:
|
|
367
|
+
# post-construction `@url` mutation must not redirect token-refresh
|
|
368
|
+
# discovery to an attacker-controlled host. Use the query-bearing form
|
|
369
|
+
# so the refresh request's RFC 8707 `resource` claim matches
|
|
370
|
+
# the original authorization request.
|
|
371
|
+
flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url)
|
|
372
|
+
true
|
|
373
|
+
rescue MCP::Client::OAuth::Flow::InvalidGrantError
|
|
374
|
+
# The refresh token has been revoked or expired by the AS. Wipe it so
|
|
375
|
+
# the full interactive flow runs fresh on the retry.
|
|
376
|
+
@oauth.clear_tokens!
|
|
377
|
+
false
|
|
378
|
+
rescue MCP::Client::OAuth::Flow::AuthorizationError
|
|
379
|
+
# Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh
|
|
380
|
+
# token in place; the next attempt may succeed and we avoid forcing
|
|
381
|
+
# the user through an interactive reauth for a recoverable error.
|
|
382
|
+
false
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def refresh_token_available?
|
|
386
|
+
tokens = @oauth.tokens
|
|
387
|
+
return false unless tokens.is_a?(Hash)
|
|
388
|
+
|
|
389
|
+
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
|
390
|
+
end
|
|
391
|
+
|
|
245
392
|
def capture_session_info(method, response, body)
|
|
246
393
|
return unless method.to_s == Methods::INITIALIZE
|
|
247
394
|
|