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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b7c0c225e89be4cfc9c73a0707b8a6a61d0d76a8a21340b20b3dd276f36eec6
4
- data.tar.gz: fdd1b11cbe6ac733820bc604f72c95c6f0aa67162776c8778de7adb04aa7ca39
3
+ metadata.gz: 325fab0f82b31b6a2614ef626ebecb12a7bf22c199ddf189dcd70b4fcf9acb58
4
+ data.tar.gz: 7cb2b010e0808efce62e9d80f487c7cb6bff9604e13f6b2b68590ffa4e0fc784
5
5
  SHA512:
6
- metadata.gz: c6dce7fe76f5f3996c9f5caffe884237147c74840d4d68dce5ec6ec24be49e65e0eea39306c2c52353c5014c8247295491b3337e166cec662eca19742565d741
7
- data.tar.gz: b944ae1938952c5e4fd20fb4b5d1ade071cf5ffa193b6c0cd56ba25e79aa583e2de38804045ce0d038eb2e38b5ea5bed4fd6b6073a16fea8bdba02f7e1e8b148
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.
@@ -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
- attr_reader :url, :session_id, :protocol_version, :server_info
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. The
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
- response = client.post("", request, session_headers)
127
- body = parse_response_body(response, method, params)
189
+ begin
190
+ response = client.post("", request, session_headers)
191
+ body = parse_response_body(response, method, params)
128
192
 
129
- capture_session_info(method, response, body)
193
+ capture_session_info(method, response, body)
130
194
 
131
- body
132
- rescue Faraday::BadRequestError => e
133
- raise RequestHandlerError.new(
134
- "The #{method} request is invalid",
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
- else
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
- "The #{method} request is not found",
214
+ "You are unauthorized to make #{method} requests",
168
215
  { method: method, params: params },
169
- error_type: :not_found,
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