mcp 1.6.0 → 1.7.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 +11 -1
- data/lib/mcp/client/oauth/discovery.rb +52 -50
- data/lib/mcp/client/oauth/flow.rb +232 -12
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +66 -6
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +330 -92
- data/lib/mcp/server.rb +8 -3
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
|
|
4
|
+
data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
|
|
7
|
+
data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
|
data/README.md
CHANGED
|
@@ -125,7 +125,7 @@ see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transpor
|
|
|
125
125
|
|
|
126
126
|
## Examples
|
|
127
127
|
|
|
128
|
-
Runnable examples are available in [`examples
|
|
128
|
+
Runnable examples are available in [`examples`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples),
|
|
129
129
|
including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails).
|
|
130
130
|
|
|
131
131
|
## Documentation
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -902,15 +902,25 @@ module MCP
|
|
|
902
902
|
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
|
|
903
903
|
end
|
|
904
904
|
|
|
905
|
+
# A provider without a `callback_handler` finishes the authorization in the request that receives the redirect,
|
|
906
|
+
# not here, so there is nothing to retry with yet: the pending authorization surfaces as
|
|
907
|
+
# `Flow::AuthorizationPendingError`, and requests made after `Flow#finish!` pick up the stored tokens.
|
|
905
908
|
def run_full_authorization_flow!(flow:, params:)
|
|
906
909
|
# Use the URL snapshotted at `initialize` time so a post-construction
|
|
907
910
|
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
|
908
911
|
# URL to an attacker-controlled host.
|
|
909
|
-
flow.run!(
|
|
912
|
+
result = flow.run!(
|
|
910
913
|
server_url: @oauth_server_url,
|
|
911
914
|
resource_metadata_url: params["resource_metadata"],
|
|
912
915
|
scope: params["scope"],
|
|
913
916
|
)
|
|
917
|
+
return unless result == :redirect
|
|
918
|
+
|
|
919
|
+
raise MCP::Client::OAuth::Flow::AuthorizationPendingError.new(
|
|
920
|
+
"Authorization is pending: the user was sent to the authorization server, and the request can be retried " \
|
|
921
|
+
"once `MCP::Client::OAuth::Flow#finish!` completes the authorization with the redirect's query.",
|
|
922
|
+
authorization_url: flow.authorization_url,
|
|
923
|
+
)
|
|
914
924
|
end
|
|
915
925
|
|
|
916
926
|
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ipaddr"
|
|
4
|
+
require "strscan"
|
|
4
5
|
require "uri"
|
|
5
6
|
|
|
6
7
|
module MCP
|
|
@@ -43,6 +44,12 @@ module MCP
|
|
|
43
44
|
# or a bare token, per RFC 7235.
|
|
44
45
|
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
|
|
45
46
|
|
|
47
|
+
# The whitespace and optional comma between two `key=value` pairs, or before the first one.
|
|
48
|
+
WWW_AUTH_PARAM_SEPARATOR_PATTERN = /\s*,?\s*/.freeze
|
|
49
|
+
|
|
50
|
+
# The `Bearer` challenge: at the start of the header or after a comma.
|
|
51
|
+
WWW_AUTH_BEARER_PATTERN = /(?:\A|,)\s*Bearer(?:\s+|\z)/i.freeze
|
|
52
|
+
|
|
46
53
|
class << self
|
|
47
54
|
# Parses a `WWW-Authenticate` header and returns the parameters of
|
|
48
55
|
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
|
|
@@ -55,25 +62,21 @@ module MCP
|
|
|
55
62
|
def parse_www_authenticate(header)
|
|
56
63
|
return {} unless header
|
|
57
64
|
|
|
58
|
-
# Locate the Bearer challenge: at the start of the header or after a comma.
|
|
59
|
-
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
|
|
60
|
-
return {} unless bearer
|
|
61
|
-
|
|
62
65
|
# Walk key=value pairs starting where Bearer's parameters begin.
|
|
63
|
-
# The loop stops at the first token that is not a key=value pair,
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
prefix = prefix.sub(/\A\s*,?\s*/, "")
|
|
70
|
-
break if prefix.empty?
|
|
66
|
+
# The loop stops at the first token that is not a key=value pair, which marks the next challenge (e.g. `, DPoP algs="..."`).
|
|
67
|
+
# The scanner keeps the walk linear in the header's length: slicing off the consumed prefix instead copies the remainder
|
|
68
|
+
# for every pair, and the server chooses how many pairs it sends. The header is also the server's to fill: a byte sequence
|
|
69
|
+
# that is not valid in the string's encoding would make the patterns raise `ArgumentError`, so such bytes are replaced first.
|
|
70
|
+
scanner = StringScanner.new(header.scrub)
|
|
71
|
+
return {} unless scanner.skip_until(WWW_AUTH_BEARER_PATTERN)
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
params = {}
|
|
74
|
+
until scanner.eos?
|
|
75
|
+
scanner.skip(WWW_AUTH_PARAM_SEPARATOR_PATTERN)
|
|
76
|
+
break if scanner.eos?
|
|
77
|
+
break unless scanner.scan(WWW_AUTH_PARAM_PATTERN)
|
|
74
78
|
|
|
75
|
-
params[
|
|
76
|
-
cursor = header.length - prefix.length + match.end(0)
|
|
79
|
+
params[scanner[1].downcase] = scanner[2] ? unescape_quoted_pair(scanner[2]) : scanner[3]
|
|
77
80
|
end
|
|
78
81
|
params
|
|
79
82
|
end
|
|
@@ -576,47 +579,46 @@ module MCP
|
|
|
576
579
|
end.join("&")
|
|
577
580
|
end
|
|
578
581
|
|
|
579
|
-
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
#
|
|
582
|
+
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments` over the path's segments, so that `/api/../mcp` collapses to
|
|
583
|
+
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`. Each segment is visited once: the RFC's buffer rewriting,
|
|
584
|
+
# applied literally, copies the remaining input for every dot segment, and the path is the server's to choose.
|
|
585
|
+
#
|
|
586
|
+
# The output matches the RFC's algorithm for a relative path as well, including its quirk that a `..` popping
|
|
587
|
+
# the first segment leaves the result absolute (`a/../b` becomes `/b`), although `URI#path` never hands over a relative path.
|
|
588
|
+
# The rule letters below are the RFC's own: steps A through E of its loop.
|
|
583
589
|
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
|
|
584
590
|
def remove_dot_segments(path)
|
|
585
591
|
return path if path.nil? || path.empty?
|
|
586
592
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
593
|
+
absolute = path.start_with?("/")
|
|
594
|
+
segments = path.split("/", -1)
|
|
595
|
+
segments.shift if absolute
|
|
596
|
+
|
|
597
|
+
output = []
|
|
598
|
+
|
|
599
|
+
# True until a segment other than `.` or `..` is kept: a relative path's leading `./` and `../` are dropped together with
|
|
600
|
+
# the slash after them (Rule A), so the segment that follows is still the slash-less first one.
|
|
601
|
+
leading = !absolute
|
|
602
|
+
segments.each_with_index do |segment, index|
|
|
603
|
+
last = index == segments.length - 1
|
|
604
|
+
|
|
605
|
+
# Rule A, and Rule D for a path that is nothing but `.` or `..`.
|
|
606
|
+
next if leading && (segment == "." || segment == "..")
|
|
607
|
+
|
|
608
|
+
if segment == "."
|
|
609
|
+
# Rule B: `/./` disappears; a final `/.` leaves its slash behind.
|
|
610
|
+
output << "/" if last
|
|
611
|
+
elsif segment == ".."
|
|
612
|
+
# Rule C: `/../` removes the previous segment; a final `/..` leaves its slash behind.
|
|
613
|
+
output.pop
|
|
614
|
+
output << "/" if last
|
|
606
615
|
else
|
|
607
|
-
segment
|
|
608
|
-
output << segment
|
|
609
|
-
input = input[segment.length..]
|
|
616
|
+
# Rule E: the first segment of a relative path carries no slash.
|
|
617
|
+
output << (leading ? segment : "/#{segment}")
|
|
610
618
|
end
|
|
619
|
+
leading = false
|
|
611
620
|
end
|
|
612
|
-
output
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
def remove_last_segment(output)
|
|
616
|
-
idx = output.rindex("/")
|
|
617
|
-
return +"" if idx.nil?
|
|
618
|
-
|
|
619
|
-
output[0...idx]
|
|
621
|
+
output.join
|
|
620
622
|
end
|
|
621
623
|
end
|
|
622
624
|
end
|
|
@@ -83,6 +83,27 @@ module MCP
|
|
|
83
83
|
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
|
84
84
|
class DestinationMismatchError < ArgumentError; end
|
|
85
85
|
|
|
86
|
+
# Raised by `MCP::Client::HTTP` when a provider without a `callback_handler` has sent the user to the authorization server:
|
|
87
|
+
# the request cannot be retried until the application finishes the authorization with `finish!` in the request that
|
|
88
|
+
# receives the redirect. `authorization_url` is the URL handed to `redirect_handler`; it is kept out of the message,
|
|
89
|
+
# which may reach logs, because it carries the `state` that `finish!` looks the pending authorization up by.
|
|
90
|
+
# Deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on a failed refresh as a reason to run
|
|
91
|
+
# the interactive flow: this is the interactive flow waiting on the user, not a failure.
|
|
92
|
+
class AuthorizationPendingError < StandardError
|
|
93
|
+
attr_reader :authorization_url
|
|
94
|
+
|
|
95
|
+
def initialize(message = nil, authorization_url: nil)
|
|
96
|
+
super(message)
|
|
97
|
+
@authorization_url = authorization_url
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A `state` the SDK generates is 43 characters; a callback carrying a much longer one is refused before storage is asked.
|
|
102
|
+
CALLBACK_STATE_MAX_LENGTH = 128
|
|
103
|
+
|
|
104
|
+
UNKNOWN_PENDING_AUTHORIZATION_MESSAGE =
|
|
105
|
+
"Authorization callback `state` matches no pending authorization; it is unknown, already used, or expired."
|
|
106
|
+
|
|
86
107
|
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
|
87
108
|
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
|
88
109
|
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
|
@@ -189,13 +210,20 @@ module MCP
|
|
|
189
210
|
end
|
|
190
211
|
end
|
|
191
212
|
|
|
213
|
+
# The authorization URL the last `run!` handed to `redirect_handler` before returning `:redirect`, or `nil`.
|
|
214
|
+
attr_reader :authorization_url
|
|
215
|
+
|
|
192
216
|
def initialize(provider:, http_client_factory: nil)
|
|
193
217
|
@provider = provider
|
|
194
218
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
219
|
+
@authorization_url = nil
|
|
195
220
|
end
|
|
196
221
|
|
|
197
222
|
# Runs the full discovery, registration, authorization, and token exchange flow.
|
|
198
223
|
# On success, persists tokens via the provider and returns `:authorized`.
|
|
224
|
+
# A provider without a `callback_handler` stops after the redirect instead: the flow saves
|
|
225
|
+
# a pending authorization in the provider's storage, keyed by `state`, and returns `:redirect`, leaving the code
|
|
226
|
+
# exchange to `finish!` in the request that receives the redirect.
|
|
199
227
|
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
200
228
|
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
201
229
|
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
@@ -257,6 +285,20 @@ module MCP
|
|
|
257
285
|
resource: resource,
|
|
258
286
|
)
|
|
259
287
|
|
|
288
|
+
if @provider.callback_handler.nil?
|
|
289
|
+
save_pending_authorization(
|
|
290
|
+
state: state,
|
|
291
|
+
code_verifier: pkce[:code_verifier],
|
|
292
|
+
server_url: server_url,
|
|
293
|
+
resource: resource,
|
|
294
|
+
client_id: client_info_required_value(client_info, "client_id"),
|
|
295
|
+
as_metadata: as_metadata,
|
|
296
|
+
)
|
|
297
|
+
@authorization_url = authorization_url
|
|
298
|
+
@provider.redirect_handler.call(authorization_url)
|
|
299
|
+
return :redirect
|
|
300
|
+
end
|
|
301
|
+
|
|
260
302
|
@provider.redirect_handler.call(authorization_url)
|
|
261
303
|
callback_result = Array(@provider.callback_handler.call)
|
|
262
304
|
code, returned_state, returned_iss = callback_result
|
|
@@ -449,6 +491,87 @@ module MCP
|
|
|
449
491
|
:refreshed
|
|
450
492
|
end
|
|
451
493
|
|
|
494
|
+
# Finishes an authorization that `run!` left pending, in the request that receives the redirect to `redirect_uri`,
|
|
495
|
+
# which may run in another process. `callback_params` is that redirect's whole query as a Hash (`code`, `state`, and,
|
|
496
|
+
# when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is
|
|
497
|
+
# the caller's part of the `iss` check: an `iss` the caller drops reads as absent, and the flow cannot tell the difference.
|
|
498
|
+
#
|
|
499
|
+
# The pending authorization is looked up by `state` before any request is made, and it binds the rest of the exchange:
|
|
500
|
+
# the code is redeemed at the token endpoint recorded when the authorization began, with the client registration,
|
|
501
|
+
# `resource`, and `redirect_uri` used then, and without discovery running again, so the code reaches the authorization
|
|
502
|
+
# server the user was sent to (SEP-2352). The RFC 9207 `iss` is validated against the recorded issuer before the pending
|
|
503
|
+
# authorization is consumed, so a callback from another authorization server, as in a mix-up, is refused without
|
|
504
|
+
# consuming the entry the legitimate callback needs, and before the callback's `error` is read, since in a mix-up
|
|
505
|
+
# those parameters are the other server's. The check establishes only that a present `iss` matches the recorded issuer,
|
|
506
|
+
# not who sent the callback, and a callback without `iss` passes it unless the metadata advertises
|
|
507
|
+
# `authorization_response_iss_parameter_supported`: whoever holds the `state`, passes that check, and reaches
|
|
508
|
+
# the consume first takes the entry, and the legitimate callback then finds nothing.
|
|
509
|
+
#
|
|
510
|
+
# Past that check the pending authorization is consumed with `delete_pending_authorization`, which returns the entry
|
|
511
|
+
# it removed; only a callback that gets the entry back redeems the code, so of callbacks racing on the same `state`
|
|
512
|
+
# (a retried redirect, say), at most one does.
|
|
513
|
+
#
|
|
514
|
+
# Binding the callback to the user who started the authorization stays with the application: scoping `storage`
|
|
515
|
+
# to that user means a callback delivered to another user's session finds no pending authorization.
|
|
516
|
+
#
|
|
517
|
+
# On success, persists tokens via the provider and returns `:authorized`.
|
|
518
|
+
def finish!(server_url:, callback_params:)
|
|
519
|
+
unless provider_authorization_flow == :authorization_code && @provider.callback_handler.nil?
|
|
520
|
+
raise ArgumentError,
|
|
521
|
+
"finish! completes an authorization started by a provider without a callback_handler; " \
|
|
522
|
+
"this provider finishes its authorizations in `run!`."
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
state = callback_param(callback_params, "state")
|
|
526
|
+
raise AuthorizationError, "Authorization callback carried no `state`." unless state
|
|
527
|
+
|
|
528
|
+
pending = @provider.pending_authorization(state) if state.bytesize <= CALLBACK_STATE_MAX_LENGTH
|
|
529
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(pending)
|
|
530
|
+
|
|
531
|
+
if pending_authorization_expired?(pending)
|
|
532
|
+
@provider.delete_pending_authorization(state)
|
|
533
|
+
raise AuthorizationError, "The pending authorization has expired; start a new authorization."
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
unless safe_canonicalize_url(server_url, label: "MCP server URL") == pending["server_url"]
|
|
537
|
+
raise AuthorizationError, "The pending authorization was started for a different MCP server."
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
as_metadata = pending["authorization_server_metadata"]
|
|
541
|
+
# Checked when the authorization began, and checked again because the metadata has since made a round trip
|
|
542
|
+
# through the application's storage.
|
|
543
|
+
ensure_secure_endpoints!(as_metadata, server_url: pending["server_url"])
|
|
544
|
+
|
|
545
|
+
validate_authorization_response_issuer!(
|
|
546
|
+
as_metadata: as_metadata,
|
|
547
|
+
iss: callback_param(callback_params, "iss"),
|
|
548
|
+
iss_provided: true,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# The read above and this delete are separate calls, so the storage's atomic delete is what decides
|
|
552
|
+
# which of two concurrent callbacks carrying this `state` proceeds.
|
|
553
|
+
consumed = @provider.delete_pending_authorization(state)
|
|
554
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(consumed)
|
|
555
|
+
|
|
556
|
+
error = callback_param(callback_params, "error")
|
|
557
|
+
raise authorization_response_error(error, callback_param(callback_params, "error_description")) if error
|
|
558
|
+
|
|
559
|
+
code = callback_param(callback_params, "code")
|
|
560
|
+
raise AuthorizationError, "Authorization callback carried no authorization code." unless code
|
|
561
|
+
|
|
562
|
+
tokens = exchange_authorization_code(
|
|
563
|
+
as_metadata: as_metadata,
|
|
564
|
+
client_info: pending_client_information(pending, as_metadata: as_metadata),
|
|
565
|
+
code: code,
|
|
566
|
+
code_verifier: pending["code_verifier"],
|
|
567
|
+
resource: pending["resource"],
|
|
568
|
+
redirect_uri: pending["redirect_uri"],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
572
|
+
:authorized
|
|
573
|
+
end
|
|
574
|
+
|
|
452
575
|
private
|
|
453
576
|
|
|
454
577
|
def read_token(key)
|
|
@@ -1096,6 +1219,89 @@ module MCP
|
|
|
1096
1219
|
result.zero?
|
|
1097
1220
|
end
|
|
1098
1221
|
|
|
1222
|
+
def provider_pending_authorization_max_age
|
|
1223
|
+
return Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE unless @provider.respond_to?(:pending_authorization_max_age)
|
|
1224
|
+
|
|
1225
|
+
@provider.pending_authorization_max_age
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
# Records what `finish!` needs to redeem the code exactly as this authorization began: the PKCE verifier, the MCP server,
|
|
1229
|
+
# the `resource` and `redirect_uri` sent, the client identity used, and the authorization server metadata already validated.
|
|
1230
|
+
# The client secret is not copied: `finish!` reads the registration from storage and requires the same `client_id`.
|
|
1231
|
+
# Every value is JSON-compatible, so storage can serialize the entry as-is.
|
|
1232
|
+
def save_pending_authorization(state:, code_verifier:, server_url:, resource:, client_id:, as_metadata:)
|
|
1233
|
+
@provider.save_pending_authorization(
|
|
1234
|
+
state,
|
|
1235
|
+
{
|
|
1236
|
+
"code_verifier" => code_verifier,
|
|
1237
|
+
"server_url" => safe_canonicalize_url(server_url, label: "MCP server URL"),
|
|
1238
|
+
"resource" => resource,
|
|
1239
|
+
"redirect_uri" => @provider.redirect_uri,
|
|
1240
|
+
"client_id" => client_id,
|
|
1241
|
+
"authorization_server_metadata" => as_metadata,
|
|
1242
|
+
"created_at" => Time.now.to_i,
|
|
1243
|
+
},
|
|
1244
|
+
)
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def valid_pending_authorization?(pending)
|
|
1248
|
+
return false unless pending.is_a?(Hash)
|
|
1249
|
+
return false unless ["code_verifier", "server_url", "redirect_uri", "client_id"].all? { |key| non_empty_string?(pending[key]) }
|
|
1250
|
+
return false unless pending["resource"].nil? || non_empty_string?(pending["resource"])
|
|
1251
|
+
|
|
1252
|
+
pending["authorization_server_metadata"].is_a?(Hash) && pending["created_at"].is_a?(Integer)
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def pending_authorization_expired?(pending)
|
|
1256
|
+
Time.now.to_i - pending["created_at"] > provider_pending_authorization_max_age
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
# The registration the authorization began with. A stored registration is used only while it still carries
|
|
1260
|
+
# that `client_id` and is bound to the recorded authorization server; a Client ID Metadata Document URL,
|
|
1261
|
+
# which is never stored, is used again while the recorded metadata advertises support for it.
|
|
1262
|
+
def pending_client_information(pending, as_metadata:)
|
|
1263
|
+
client_id = pending["client_id"]
|
|
1264
|
+
|
|
1265
|
+
stored = @provider.client_information
|
|
1266
|
+
if stored.is_a?(Hash) &&
|
|
1267
|
+
client_info_required_value(stored, "client_id") == client_id &&
|
|
1268
|
+
client_info_required_value(stored, "issuer") == as_metadata["issuer"]
|
|
1269
|
+
return stored
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
if client_id == provider_client_id_metadata_document_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
1273
|
+
return { "client_id" => client_id }
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
raise AuthorizationError, "The client registration changed after the authorization began; start a new authorization."
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
# Reads one parameter of an authorization response. Only a non-empty String counts: a repeated parameter that
|
|
1280
|
+
# a framework delivers as an Array, or an empty value, is treated as absent.
|
|
1281
|
+
def callback_param(params, key)
|
|
1282
|
+
return unless params.is_a?(Hash)
|
|
1283
|
+
|
|
1284
|
+
value = params[key] || params[key.to_sym]
|
|
1285
|
+
non_empty_string?(value) ? value : nil
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def non_empty_string?(value)
|
|
1289
|
+
value.is_a?(String) && !value.empty?
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# An RFC 6749 Section 4.1.2.1 error response, reported with the bounds a token endpoint error gets.
|
|
1293
|
+
# Reached only after the `iss` check, so any `iss` beside the values matched the recorded issuer; that does not
|
|
1294
|
+
# prove who sent them, and they are text the sender chose, so they are cut to a bounded length and confined to
|
|
1295
|
+
# the printable ASCII the RFC permits.
|
|
1296
|
+
def authorization_response_error(error, description)
|
|
1297
|
+
error = bounded_diagnostic(error, limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
|
1298
|
+
description = bounded_diagnostic(description, limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
|
1299
|
+
message = "The authorization server returned an error to the authorization callback."
|
|
1300
|
+
message += " #{[error, description].compact.join(": ")}" if error || description
|
|
1301
|
+
|
|
1302
|
+
AuthorizationError.new(message, error: error, error_description: description)
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1099
1305
|
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
|
|
1100
1306
|
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
|
|
1101
1307
|
# from the Protected Resource Metadata, and falls back to a provider-supplied
|
|
@@ -1206,24 +1412,38 @@ module MCP
|
|
|
1206
1412
|
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
|
1207
1413
|
end
|
|
1208
1414
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1415
|
+
# A parameter the flow sets replaces any of the same name the endpoint URL already carries.
|
|
1416
|
+
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
|
|
1417
|
+
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
|
|
1418
|
+
# for the client's `client_id`, `redirect_uri`, `code_challenge`, or `resource`.
|
|
1419
|
+
# Other parameters in the URL are kept, as the TypeScript SDK's `searchParams.set` keeps them; that includes
|
|
1420
|
+
# a `scope` when the flow has none, since an authorization server may set a default scope there.
|
|
1421
|
+
# RFC 9101 `request` and `request_uri` are dropped as well, though the flow sets neither: a server takes
|
|
1422
|
+
# the whole authorization request from the object they carry, over every parameter in the query, and both are
|
|
1423
|
+
# the client's to send, never an endpoint URL's to supply.
|
|
1424
|
+
own_params = [
|
|
1425
|
+
["response_type", "code"],
|
|
1426
|
+
["client_id", client_id],
|
|
1427
|
+
["redirect_uri", @provider.redirect_uri],
|
|
1428
|
+
["code_challenge", code_challenge],
|
|
1429
|
+
["code_challenge_method", "S256"],
|
|
1430
|
+
["state", state],
|
|
1431
|
+
]
|
|
1432
|
+
own_params << ["scope", scope] if scope
|
|
1433
|
+
own_params << ["resource", resource] if resource
|
|
1434
|
+
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
|
|
1435
|
+
|
|
1436
|
+
params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
|
|
1437
|
+
uri.query = URI.encode_www_form(params + own_params)
|
|
1438
|
+
|
|
1219
1439
|
uri
|
|
1220
1440
|
end
|
|
1221
1441
|
|
|
1222
|
-
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
|
1442
|
+
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
|
|
1223
1443
|
form = {
|
|
1224
1444
|
"grant_type" => "authorization_code",
|
|
1225
1445
|
"code" => code,
|
|
1226
|
-
"redirect_uri" =>
|
|
1446
|
+
"redirect_uri" => redirect_uri,
|
|
1227
1447
|
"code_verifier" => code_verifier,
|
|
1228
1448
|
}
|
|
1229
1449
|
form["resource"] = resource if resource
|
|
@@ -16,6 +16,17 @@ module MCP
|
|
|
16
16
|
# binding the credentials to the authorization server that issued them (SEP-2352);
|
|
17
17
|
# custom storages should treat the hash as opaque and persist it as-is.
|
|
18
18
|
#
|
|
19
|
+
# A provider without a `callback_handler` also keeps each pending authorization here, keyed by its `state`,
|
|
20
|
+
# between the request that sends the user to the authorization server and the request that receives
|
|
21
|
+
# the redirect (`save_pending_authorization(state, pending)`, `pending_authorization(state)`,
|
|
22
|
+
# `delete_pending_authorization(state)`). A pending authorization holds the PKCE verifier, so custom storages
|
|
23
|
+
# should treat it as a secret and persist it as-is. `Flow#finish!` touches only the entry its callback's `state`
|
|
24
|
+
# names, so an authorization the user never finished stays until the storage drops it: this class drops entries
|
|
25
|
+
# older than `pending_authorization_max_age` the next time one is saved, and a custom storage should expire them,
|
|
26
|
+
# with a TTL of that age. `delete_pending_authorization` must remove the entry and return it in one atomic step
|
|
27
|
+
# (`Hash#delete` under a mutex here; `GETDEL` in Redis, `DELETE ... RETURNING` in SQL), returning `nil` when
|
|
28
|
+
# there was none: the flow redeems the code only when it gets the entry back.
|
|
29
|
+
#
|
|
19
30
|
# This class keeps everything in process memory, so the credentials live
|
|
20
31
|
# only for the lifetime of the Ruby process. Applications that need
|
|
21
32
|
# persistence across restarts should supply a custom object responding to
|
|
@@ -24,12 +35,25 @@ module MCP
|
|
|
24
35
|
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
|
25
36
|
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
|
26
37
|
# the same responsibilities into a single object.
|
|
38
|
+
# A web application may receive the redirect in another process, so pending authorizations
|
|
39
|
+
# need shared storage.
|
|
27
40
|
class InMemoryStorage
|
|
28
41
|
attr_accessor :tokens, :client_information
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
# @param pending_authorization_max_age [Integer] seconds after which a pending authorization that was
|
|
44
|
+
# never finished is dropped from this storage, at its next save. `Provider.new` passes its own
|
|
45
|
+
# `pending_authorization_max_age` when it builds the default storage, so the two ages agree; a storage built
|
|
46
|
+
# by hand defaults to `Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
47
|
+
def initialize(pending_authorization_max_age: Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE)
|
|
48
|
+
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
49
|
+
raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
|
|
50
|
+
end
|
|
51
|
+
|
|
31
52
|
@tokens = nil
|
|
32
53
|
@client_information = nil
|
|
54
|
+
@pending_authorizations = {}
|
|
55
|
+
@pending_authorization_max_age = pending_authorization_max_age
|
|
56
|
+
@pending_authorizations_mutex = Mutex.new
|
|
33
57
|
end
|
|
34
58
|
|
|
35
59
|
def save_tokens(tokens)
|
|
@@ -39,6 +63,55 @@ module MCP
|
|
|
39
63
|
def save_client_information(info)
|
|
40
64
|
@client_information = info
|
|
41
65
|
end
|
|
66
|
+
|
|
67
|
+
# Drops every pending authorization older than `pending_authorization_max_age` before saving the new one.
|
|
68
|
+
# The flow refuses such an entry when it is looked up, but only a callback carrying its `state` looks it up,
|
|
69
|
+
# and an authorization the user abandoned never gets one; every `401` a provider without a `callback_handler` meets
|
|
70
|
+
# starts another, so without this the entries, each holding the verifier and the authorization server metadata,
|
|
71
|
+
# would accumulate for the life of the process.
|
|
72
|
+
def save_pending_authorization(state, pending)
|
|
73
|
+
@pending_authorizations_mutex.synchronize do
|
|
74
|
+
drop_expired_pending_authorizations
|
|
75
|
+
@pending_authorizations[state] = pending
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pending_authorization(state)
|
|
80
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations[state] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The mutex is what makes the removal and the return one step on every Ruby implementation: `Hash#delete`
|
|
84
|
+
# alone is atomic only where a global interpreter lock serializes it, and `Flow#finish!` relies on exactly
|
|
85
|
+
# one of two callbacks racing on the same `state` getting the entry back.
|
|
86
|
+
def delete_pending_authorization(state)
|
|
87
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations.delete(state) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every hash this storage holds may carry a secret (an access token, a client secret, a PKCE verifier),
|
|
91
|
+
# and `inspect` is what error reporters and consoles print an object with, so it shows only whether each is present.
|
|
92
|
+
def inspect
|
|
93
|
+
pending_count = @pending_authorizations_mutex.synchronize { @pending_authorizations.size }
|
|
94
|
+
|
|
95
|
+
"#<#{self.class.name} tokens=#{present_or_nil(@tokens)} client_information=#{present_or_nil(@client_information)} pending_authorizations=#{pending_count}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# An entry's age is read from the Integer `created_at` the flow records when it saves the entry; an entry without one,
|
|
101
|
+
# which the flow never writes, is left alone.
|
|
102
|
+
def drop_expired_pending_authorizations
|
|
103
|
+
now = Time.now.to_i
|
|
104
|
+
|
|
105
|
+
@pending_authorizations.delete_if do |_state, pending|
|
|
106
|
+
created_at = pending.is_a?(Hash) ? pending["created_at"] : nil
|
|
107
|
+
|
|
108
|
+
created_at.is_a?(Integer) && now - created_at > @pending_authorization_max_age
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def present_or_nil(value)
|
|
113
|
+
value.nil? ? "nil" : "[present]"
|
|
114
|
+
end
|
|
42
115
|
end
|
|
43
116
|
end
|
|
44
117
|
end
|