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
|
@@ -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
|
-
#
|
|
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
|
|
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
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
|