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.
@@ -20,6 +20,8 @@ module MCP
20
20
  # request. Must be one of `redirect_uris` in `client_metadata`.
21
21
  # - `redirect_handler` - Callable invoked with the fully-built authorization
22
22
  # URL (a `URI`). Implementations typically open the user's browser.
23
+ #
24
+ # Optional keyword arguments:
23
25
  # - `callback_handler` - Callable invoked after `redirect_handler`. Returns
24
26
  # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
25
27
  # `state` is the `state` parameter received on the redirect URI, and `iss` is
@@ -28,8 +30,11 @@ module MCP
28
30
  # must match the authorization server's issuer, and a nil `iss` is
29
31
  # rejected when the AS advertises `authorization_response_iss_parameter_supported`.
30
32
  # The 2-element form skips the check for backward compatibility.
31
- #
32
- # Optional keyword arguments:
33
+ # Omit it when the redirect arrives in a later request, as it does in a web application:
34
+ # the flow then stops after `redirect_handler` with a pending authorization saved in `storage`,
35
+ # and the request that receives the redirect finishes it with `Flow#finish!`.
36
+ # - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable, counted from the moment
37
+ # `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
33
38
  # - `scope` - String of space-separated scopes to request when the server's
34
39
  # `WWW-Authenticate` does not specify one.
35
40
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
@@ -38,6 +43,10 @@ module MCP
38
43
  # an `"issuer"` member binding it to the authorization server that
39
44
  # issued it (SEP-2352); when the authorization server changes, the SDK discards
40
45
  # the stale registration and tokens and re-registers.
46
+ # Without `callback_handler`, it must also respond to `save_pending_authorization(state, pending)`,
47
+ # `pending_authorization(state)`, and `delete_pending_authorization(state)`; the last must remove the entry
48
+ # and return it atomically, returning `nil` when there was none, and the storage should expire entries
49
+ # older than `pending_authorization_max_age`, as `InMemoryStorage` does.
41
50
  # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
42
51
  # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
43
52
  # When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -78,25 +87,45 @@ module MCP
78
87
  # applies and the value must unambiguously identify the document.
79
88
  class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
80
89
 
90
+ # Raised when `Provider#initialize` is called without `callback_handler` and with a `storage`
91
+ # that cannot hold a pending authorization between the request that sends the user to the authorization server
92
+ # and the request that receives the redirect.
93
+ class PendingAuthorizationStorageError < ArgumentError; end
94
+
95
+ # Seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when the provider has
96
+ # no `callback_handler`; long enough for a user to sign in and consent at the authorization server. It is also
97
+ # the age past which `InMemoryStorage` drops an authorization that was never finished, at its next save, so that
98
+ # an abandoned one does not keep its PKCE verifier in memory for the life of the process; a custom storage should
99
+ # expire entries at the same age.
100
+ DEFAULT_PENDING_AUTHORIZATION_MAX_AGE = 600
101
+
102
+ PENDING_AUTHORIZATION_STORAGE_METHODS = [
103
+ :save_pending_authorization,
104
+ :pending_authorization,
105
+ :delete_pending_authorization,
106
+ ].freeze
107
+
81
108
  attr_reader :client_metadata,
82
109
  :redirect_uri,
83
110
  :scope,
84
111
  :storage,
85
112
  :redirect_handler,
86
113
  :callback_handler,
87
- :client_id_metadata_document_url
114
+ :client_id_metadata_document_url,
115
+ :pending_authorization_max_age
88
116
 
89
117
  def initialize(
90
118
  client_metadata:,
91
119
  redirect_uri:,
92
120
  redirect_handler:,
93
- callback_handler:,
121
+ callback_handler: nil,
94
122
  scope: nil,
95
123
  storage: nil,
96
124
  client_id_metadata_document_url: nil,
97
125
  authorization_request_validator: nil,
98
126
  token_request_params: nil,
99
- http_client_customizer: nil
127
+ http_client_customizer: nil,
128
+ pending_authorization_max_age: DEFAULT_PENDING_AUTHORIZATION_MAX_AGE
100
129
  )
101
130
  unless Discovery.secure_url?(redirect_uri)
102
131
  raise InsecureRedirectURIError,
@@ -120,16 +149,33 @@ module MCP
120
149
 
121
150
  http_client_customizer = validated_http_client_customizer(http_client_customizer)
122
151
 
152
+ unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
153
+ raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
154
+ end
155
+
156
+ # The default storage drops abandoned pending authorizations at the same age the flow stops redeeming them.
157
+ storage ||= InMemoryStorage.new(pending_authorization_max_age: pending_authorization_max_age)
158
+
159
+ if callback_handler.nil?
160
+ missing = PENDING_AUTHORIZATION_STORAGE_METHODS.reject { |method| storage.respond_to?(method) }
161
+ unless missing.empty?
162
+ raise PendingAuthorizationStorageError,
163
+ "Without a callback_handler the authorization finishes in a later request, so storage must also respond to " \
164
+ "#{missing.join(", ")} (#{storage.class} does not)."
165
+ end
166
+ end
167
+
123
168
  @client_metadata = client_metadata
124
169
  @redirect_uri = redirect_uri
125
170
  @redirect_handler = redirect_handler
126
171
  @callback_handler = callback_handler
127
172
  @scope = scope
128
- @storage = storage || InMemoryStorage.new
173
+ @storage = storage
129
174
  @client_id_metadata_document_url = client_id_metadata_document_url
130
175
  @authorization_request_validator = authorization_request_validator
131
176
  @token_request_params = frozen_token_request_params(token_request_params)
132
177
  @http_client_customizer = http_client_customizer
178
+ @pending_authorization_max_age = pending_authorization_max_age
133
179
  end
134
180
 
135
181
  # Identifies the OAuth flow this provider drives.
@@ -138,6 +184,20 @@ module MCP
138
184
  def authorization_flow
139
185
  :authorization_code
140
186
  end
187
+
188
+ def save_pending_authorization(state, pending)
189
+ @storage.save_pending_authorization(state, pending)
190
+ end
191
+
192
+ def pending_authorization(state)
193
+ @storage.pending_authorization(state)
194
+ end
195
+
196
+ # Returns the entry the storage removed, or `nil`. `Flow#finish!` redeems the code only when it gets
197
+ # the entry back, so of two callbacks racing on the same `state`, only one can.
198
+ def delete_pending_authorization(state)
199
+ @storage.delete_pending_authorization(state)
200
+ end
141
201
  end
142
202
  end
143
203
  end
data/lib/mcp/icon.rb CHANGED
@@ -9,9 +9,70 @@ module MCP
9
9
  # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
10
10
  # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
11
11
  # (the specification expects `WxH` or `"any"`).
12
+ #
13
+ # Wherever an `Icon` is accepted (`Server.new(icons:)` and the `icons` of a tool, prompt, resource, or resource template),
14
+ # a Hash is accepted too and converted through `Icon.from`, so it is checked the same way.
12
15
  class Icon
13
16
  SUPPORTED_THEMES = ["light", "dark"].freeze
14
17
 
18
+ # The keys `from` accepts in a Hash: the keyword names of `new` and the wire name `mimeType`,
19
+ # as Symbols or Strings.
20
+ HASH_KEYWORDS = {
21
+ "src" => :src,
22
+ "mimeType" => :mime_type,
23
+ "mime_type" => :mime_type,
24
+ "sizes" => :sizes,
25
+ "theme" => :theme,
26
+ }.freeze
27
+ private_constant :HASH_KEYWORDS
28
+
29
+ class << self
30
+ # Returns `value` when it is already an `Icon`, builds one from a Hash, and refuses anything else.
31
+ def from(value)
32
+ return value if value.is_a?(Icon)
33
+
34
+ unless value.is_a?(Hash)
35
+ raise ArgumentError, "An icon must be an MCP::Icon or a Hash (got #{value.class})."
36
+ end
37
+
38
+ keywords = {}
39
+ value.each do |key, member|
40
+ unless key.is_a?(Symbol) || key.is_a?(String)
41
+ raise ArgumentError, "An icon Hash key must be a Symbol or a String (got #{key.class})."
42
+ end
43
+
44
+ keyword = HASH_KEYWORDS[key.to_s]
45
+ unless keyword
46
+ raise ArgumentError, "An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got #{key.inspect})."
47
+ end
48
+ raise ArgumentError, "An icon Hash gives #{keyword} twice." if keywords.key?(keyword)
49
+
50
+ keywords[keyword] = member
51
+ end
52
+
53
+ new(**keywords)
54
+ end
55
+
56
+ # Converts the `icons` argument of a server, tool, prompt, resource, or resource template: `nil` stays `nil`,
57
+ # each element of an Array goes through `from` into a frozen Array, and anything else is refused.
58
+ # The frozen Array keeps a later `<<` on a reader from adding an element these checks never saw.
59
+ def from_list(value)
60
+ return if value.nil?
61
+
62
+ unless value.is_a?(Array)
63
+ raise ArgumentError, "icons must be nil or an Array of MCP::Icon or Hash (got #{value.class})."
64
+ end
65
+
66
+ icons = value.each_with_index.map do |icon, index|
67
+ from(icon)
68
+ rescue ArgumentError => e
69
+ raise ArgumentError, "icons[#{index}]: #{e.message}"
70
+ end
71
+
72
+ icons.freeze
73
+ end
74
+ end
75
+
15
76
  attr_reader :mime_type, :sizes, :src, :theme
16
77
 
17
78
  def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
data/lib/mcp/prompt.rb CHANGED
@@ -72,7 +72,7 @@ module MCP
72
72
  if value == NOT_SET
73
73
  @icons_value
74
74
  else
75
- @icons_value = value
75
+ @icons_value = Icon.from_list(value)
76
76
  end
77
77
  end
78
78
 
data/lib/mcp/resource.rb CHANGED
@@ -88,7 +88,7 @@ module MCP
88
88
  if value == NOT_SET
89
89
  @icons_value
90
90
  else
91
- @icons_value = value
91
+ @icons_value = Icon.from_list(value)
92
92
  end
93
93
  end
94
94
 
@@ -147,7 +147,7 @@ module MCP
147
147
  @name = name
148
148
  @title = title
149
149
  @description = description
150
- @icons = icons
150
+ @icons = Icon.from_list(icons)
151
151
  @mime_type = mime_type
152
152
  @annotations = annotations
153
153
  @size = size
@@ -89,7 +89,7 @@ module MCP
89
89
  if value == NOT_SET
90
90
  @icons_value
91
91
  else
92
- @icons_value = value
92
+ @icons_value = Icon.from_list(value)
93
93
  end
94
94
  end
95
95
 
@@ -158,7 +158,7 @@ module MCP
158
158
  @name = name
159
159
  @title = title
160
160
  @description = description
161
- @icons = icons
161
+ @icons = Icon.from_list(icons)
162
162
  @mime_type = mime_type
163
163
  @annotations = annotations
164
164
  @meta = meta