mcp 0.19.0 → 0.21.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 +117 -3
- data/lib/json_rpc_handler.rb +7 -2
- data/lib/mcp/annotations.rb +2 -0
- data/lib/mcp/client/http.rb +68 -7
- data/lib/mcp/client/oauth/client_credentials_provider.rb +89 -0
- data/lib/mcp/client/oauth/discovery.rb +42 -1
- data/lib/mcp/client/oauth/flow.rb +160 -25
- data/lib/mcp/client/oauth/provider.rb +15 -25
- data/lib/mcp/client/oauth/storage_backed_provider.rb +43 -0
- data/lib/mcp/client/oauth.rb +3 -1
- data/lib/mcp/client.rb +49 -20
- data/lib/mcp/configuration.rb +1 -0
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +78 -50
- data/lib/mcp/server.rb +38 -2
- data/lib/mcp/tool/input_schema.rb +2 -2
- data/lib/mcp/tool/schema.rb +38 -19
- data/lib/mcp/trace_context.rb +23 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -5
|
@@ -38,27 +38,28 @@ module MCP
|
|
|
38
38
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
prm =
|
|
41
|
+
prm, authorization_server = locate_authorization_server(
|
|
42
42
|
server_url: server_url,
|
|
43
43
|
resource_metadata_url: resource_metadata_url,
|
|
44
44
|
)
|
|
45
|
-
authorization_server = first_authorization_server(prm)
|
|
46
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
47
45
|
|
|
48
46
|
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
|
49
47
|
# both the authorization and token requests. When PRM advertises a `resource`,
|
|
50
48
|
# it MUST identify the same MCP server we are talking to; otherwise we are
|
|
51
49
|
# being redirected to credentials minted for a different audience.
|
|
52
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
50
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
51
|
+
|
|
52
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
53
|
+
|
|
54
|
+
if provider_authorization_flow == :client_credentials
|
|
55
|
+
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
56
|
+
end
|
|
53
57
|
|
|
54
|
-
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
55
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
56
|
-
ensure_secure_endpoints!(as_metadata)
|
|
57
58
|
ensure_pkce_supported!(as_metadata)
|
|
58
59
|
|
|
59
60
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
60
61
|
|
|
61
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
62
63
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
63
64
|
pkce = PKCE.generate
|
|
64
65
|
state = SecureRandom.urlsafe_base64(32)
|
|
@@ -92,14 +93,46 @@ module MCP
|
|
|
92
93
|
:authorized
|
|
93
94
|
end
|
|
94
95
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# token endpoint
|
|
98
|
-
#
|
|
96
|
+
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
|
97
|
+
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
|
98
|
+
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
|
99
|
+
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
|
|
100
|
+
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
|
101
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
102
|
+
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
|
103
|
+
client_info = client_credentials_client_info
|
|
104
|
+
|
|
105
|
+
form = { "grant_type" => "client_credentials" }
|
|
106
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
107
|
+
form["scope"] = effective_scope if effective_scope
|
|
108
|
+
form["resource"] = resource if resource
|
|
109
|
+
|
|
110
|
+
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
111
|
+
@provider.save_tokens(tokens)
|
|
112
|
+
:authorized
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
|
|
116
|
+
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
|
|
117
|
+
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
|
|
118
|
+
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
|
|
119
|
+
# to dynamic registration.
|
|
120
|
+
def client_credentials_client_info
|
|
121
|
+
info = @provider.client_information
|
|
122
|
+
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
|
123
|
+
raise AuthorizationError,
|
|
124
|
+
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
info
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
|
131
|
+
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
|
132
|
+
# checks before talking to it.
|
|
99
133
|
#
|
|
100
|
-
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
|
101
|
-
#
|
|
102
|
-
# the token endpoint refuses the refresh request.
|
|
134
|
+
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
|
135
|
+
# or when the token endpoint refuses the refresh request.
|
|
103
136
|
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
|
104
137
|
def refresh!(server_url:, resource_metadata_url: nil)
|
|
105
138
|
refresh_token = read_token("refresh_token")
|
|
@@ -110,8 +143,7 @@ module MCP
|
|
|
110
143
|
|
|
111
144
|
# A CIMD-configured provider stores no `client_information` on purpose
|
|
112
145
|
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
|
113
|
-
# Allow refresh to proceed in that case so the `refresh_token` obtained via
|
|
114
|
-
# the CIMD flow remains usable.
|
|
146
|
+
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
|
115
147
|
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
|
116
148
|
|
|
117
149
|
unless have_stored_client_info || have_cimd_url
|
|
@@ -122,18 +154,14 @@ module MCP
|
|
|
122
154
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
123
155
|
end
|
|
124
156
|
|
|
125
|
-
prm =
|
|
157
|
+
prm, authorization_server = locate_authorization_server(
|
|
126
158
|
server_url: server_url,
|
|
127
159
|
resource_metadata_url: resource_metadata_url,
|
|
128
160
|
)
|
|
129
|
-
authorization_server = first_authorization_server(prm)
|
|
130
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
131
161
|
|
|
132
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
162
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
133
163
|
|
|
134
|
-
as_metadata =
|
|
135
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
136
|
-
ensure_secure_endpoints!(as_metadata)
|
|
164
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
137
165
|
|
|
138
166
|
client_info = if have_stored_client_info
|
|
139
167
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
@@ -185,6 +213,87 @@ module MCP
|
|
|
185
213
|
fetch_metadata_json(urls, label: "protected resource metadata")
|
|
186
214
|
end
|
|
187
215
|
|
|
216
|
+
# Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
|
|
217
|
+
#
|
|
218
|
+
# Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
|
|
219
|
+
# `authorization_servers`.
|
|
220
|
+
#
|
|
221
|
+
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
|
222
|
+
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
|
223
|
+
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
|
|
224
|
+
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
|
225
|
+
def locate_authorization_server(server_url:, resource_metadata_url:)
|
|
226
|
+
prm = begin
|
|
227
|
+
fetch_protected_resource_metadata(
|
|
228
|
+
server_url: server_url,
|
|
229
|
+
resource_metadata_url: resource_metadata_url,
|
|
230
|
+
)
|
|
231
|
+
rescue AuthorizationError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if prm
|
|
236
|
+
authorization_server = first_authorization_server(prm)
|
|
237
|
+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
238
|
+
[prm, authorization_server]
|
|
239
|
+
else
|
|
240
|
+
authorization_base = server_origin!(server_url)
|
|
241
|
+
ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
|
|
242
|
+
[nil, authorization_base]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fetches and validates the authorization server's RFC 8414 metadata.
|
|
247
|
+
#
|
|
248
|
+
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
|
|
249
|
+
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
|
|
250
|
+
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
|
|
251
|
+
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
|
|
252
|
+
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
253
|
+
def authorization_server_metadata(authorization_server:, legacy:)
|
|
254
|
+
metadata = if legacy
|
|
255
|
+
begin
|
|
256
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
257
|
+
rescue AuthorizationError
|
|
258
|
+
default_legacy_metadata(authorization_server)
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
|
|
262
|
+
ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ensure_secure_endpoints!(metadata)
|
|
267
|
+
metadata
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
|
|
271
|
+
# relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
|
|
272
|
+
# to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
|
|
273
|
+
def default_legacy_metadata(authorization_base)
|
|
274
|
+
{
|
|
275
|
+
"issuer" => authorization_base,
|
|
276
|
+
"authorization_endpoint" => "#{authorization_base}/authorize",
|
|
277
|
+
"token_endpoint" => "#{authorization_base}/token",
|
|
278
|
+
"registration_endpoint" => "#{authorization_base}/register",
|
|
279
|
+
"code_challenge_methods_supported" => ["S256"],
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
|
|
284
|
+
def server_origin!(server_url)
|
|
285
|
+
uri = URI.parse(server_url.to_s)
|
|
286
|
+
unless uri.is_a?(URI::HTTP) && uri.host
|
|
287
|
+
raise AuthorizationError,
|
|
288
|
+
"Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
292
|
+
"#{uri.scheme}://#{uri.host}#{port_part}"
|
|
293
|
+
rescue URI::InvalidURIError => e
|
|
294
|
+
raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
|
|
295
|
+
end
|
|
296
|
+
|
|
188
297
|
def fetch_authorization_server_metadata(issuer_url:)
|
|
189
298
|
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
|
190
299
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
@@ -331,7 +440,7 @@ module MCP
|
|
|
331
440
|
end
|
|
332
441
|
|
|
333
442
|
response = begin
|
|
334
|
-
http_post_json(registration_endpoint,
|
|
443
|
+
http_post_json(registration_endpoint, registration_client_metadata)
|
|
335
444
|
rescue Faraday::Error => e
|
|
336
445
|
raise AuthorizationError,
|
|
337
446
|
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
|
@@ -357,6 +466,20 @@ module MCP
|
|
|
357
466
|
info
|
|
358
467
|
end
|
|
359
468
|
|
|
469
|
+
# Returns the client metadata to submit on Dynamic Client Registration.
|
|
470
|
+
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
|
|
471
|
+
# so the authorization server can apply the matching redirect URI policy.
|
|
472
|
+
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
|
|
473
|
+
# the registered `redirect_uris`; an explicit value always wins.
|
|
474
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
|
|
475
|
+
def registration_client_metadata
|
|
476
|
+
metadata = @provider.client_metadata
|
|
477
|
+
return metadata if metadata[:application_type] || metadata["application_type"]
|
|
478
|
+
|
|
479
|
+
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
|
|
480
|
+
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
|
|
481
|
+
end
|
|
482
|
+
|
|
360
483
|
# Reads `key` from a `client_information` hash that may use either string or
|
|
361
484
|
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
362
485
|
# `{ client_id:, client_secret: }` and have both work.
|
|
@@ -484,6 +607,18 @@ module MCP
|
|
|
484
607
|
Array(grant_types).include?("refresh_token")
|
|
485
608
|
end
|
|
486
609
|
|
|
610
|
+
# The OAuth flow the provider drives. Dispatching on the provider's
|
|
611
|
+
# declared flow keeps `Flow` from second-guessing intent by parsing
|
|
612
|
+
# `client_metadata[:grant_types]` (which is protocol metadata for the
|
|
613
|
+
# authorization server, not an SDK control signal). A provider that
|
|
614
|
+
# predates this method is treated as the interactive authorization-code
|
|
615
|
+
# flow it was the only option for.
|
|
616
|
+
def provider_authorization_flow
|
|
617
|
+
return :authorization_code unless @provider.respond_to?(:authorization_flow)
|
|
618
|
+
|
|
619
|
+
@provider.authorization_flow
|
|
620
|
+
end
|
|
621
|
+
|
|
487
622
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
488
623
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
489
624
|
unless authorization_endpoint
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
module MCP
|
|
4
4
|
class Client
|
|
5
5
|
module OAuth
|
|
6
|
-
# Pluggable OAuth client configuration
|
|
7
|
-
# the `oauth:` keyword.
|
|
8
|
-
#
|
|
6
|
+
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
|
|
7
|
+
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
|
|
8
|
+
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
|
|
9
|
+
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
|
|
10
|
+
# use `ClientCredentialsProvider` instead.
|
|
9
11
|
#
|
|
10
12
|
# Required keyword arguments:
|
|
11
13
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
12
14
|
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
13
15
|
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
|
16
|
+
# When `application_type` is omitted, the SDK infers `"native"` or `"web"`
|
|
17
|
+
# from `redirect_uris` per SEP-837 before registering; an explicit value
|
|
18
|
+
# always wins.
|
|
14
19
|
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
15
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
16
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
@@ -36,6 +41,8 @@ module MCP
|
|
|
36
41
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
37
42
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
38
43
|
class Provider
|
|
44
|
+
include StorageBackedProvider
|
|
45
|
+
|
|
39
46
|
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
|
40
47
|
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
|
41
48
|
# authorization spec's Communication Security requirement.
|
|
@@ -102,28 +109,11 @@ module MCP
|
|
|
102
109
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
103
110
|
end
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@storage.tokens
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def save_tokens(tokens)
|
|
114
|
-
@storage.save_tokens(tokens)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def client_information
|
|
118
|
-
@storage.client_information
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def save_client_information(info)
|
|
122
|
-
@storage.save_client_information(info)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def clear_tokens!
|
|
126
|
-
@storage.save_tokens(nil)
|
|
112
|
+
# Identifies the OAuth flow this provider drives.
|
|
113
|
+
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
|
|
114
|
+
# which is protocol metadata for the authorization server, not an SDK control signal.
|
|
115
|
+
def authorization_flow
|
|
116
|
+
:authorization_code
|
|
127
117
|
end
|
|
128
118
|
end
|
|
129
119
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# Shared token/credential persistence for the OAuth provider classes
|
|
7
|
+
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
|
|
8
|
+
# for the client_credentials flow). The two grants differ in how they authenticate,
|
|
9
|
+
# but both read and write the same two pieces of state through a `storage` object:
|
|
10
|
+
# the token response and the client information. This module supplies that delegation
|
|
11
|
+
# so the `Flow` orchestrator can treat any provider uniformly.
|
|
12
|
+
#
|
|
13
|
+
# Including classes must set `@storage` to an object responding to `tokens`,
|
|
14
|
+
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
15
|
+
# (see `InMemoryStorage`).
|
|
16
|
+
module StorageBackedProvider
|
|
17
|
+
def access_token
|
|
18
|
+
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tokens
|
|
22
|
+
@storage.tokens
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def save_tokens(tokens)
|
|
26
|
+
@storage.save_tokens(tokens)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client_information
|
|
30
|
+
@storage.client_information
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save_client_information(info)
|
|
34
|
+
@storage.save_client_information(info)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear_tokens!
|
|
38
|
+
@storage.save_tokens(nil)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -4,13 +4,15 @@ require_relative "oauth/discovery"
|
|
|
4
4
|
require_relative "oauth/flow"
|
|
5
5
|
require_relative "oauth/in_memory_storage"
|
|
6
6
|
require_relative "oauth/pkce"
|
|
7
|
+
require_relative "oauth/storage_backed_provider"
|
|
7
8
|
require_relative "oauth/provider"
|
|
9
|
+
require_relative "oauth/client_credentials_provider"
|
|
8
10
|
|
|
9
11
|
module MCP
|
|
10
12
|
class Client
|
|
11
13
|
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
|
12
14
|
# Authorization Server metadata discovery, Dynamic Client Registration,
|
|
13
|
-
# OAuth 2.1 Authorization Code + PKCE).
|
|
15
|
+
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
|
|
14
16
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
15
17
|
module OAuth
|
|
16
18
|
end
|
data/lib/mcp/client.rb
CHANGED
|
@@ -77,7 +77,9 @@ module MCP
|
|
|
77
77
|
#
|
|
78
78
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
79
79
|
# @param protocol_version [String, nil] Protocol version to offer.
|
|
80
|
-
# @param capabilities [Hash] Capabilities advertised by the client.
|
|
80
|
+
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
|
81
|
+
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
|
82
|
+
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
|
81
83
|
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
|
82
84
|
# does not expose an explicit handshake.
|
|
83
85
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
@@ -103,6 +105,8 @@ module MCP
|
|
|
103
105
|
# Returns a single page of tools from the server.
|
|
104
106
|
#
|
|
105
107
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
108
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
109
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
106
110
|
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
|
107
111
|
# and `next_cursor` (String or nil).
|
|
108
112
|
#
|
|
@@ -114,9 +118,9 @@ module MCP
|
|
|
114
118
|
# cursor = page.next_cursor
|
|
115
119
|
# break unless cursor
|
|
116
120
|
# end
|
|
117
|
-
def list_tools(cursor: nil)
|
|
121
|
+
def list_tools(cursor: nil, meta: nil)
|
|
118
122
|
params = cursor ? { cursor: cursor } : nil
|
|
119
|
-
response = request(method: "tools/list", params: params)
|
|
123
|
+
response = request(method: "tools/list", params: params, meta: meta)
|
|
120
124
|
result = response["result"] || {}
|
|
121
125
|
|
|
122
126
|
tools = (result["tools"] || []).map do |tool|
|
|
@@ -152,11 +156,13 @@ module MCP
|
|
|
152
156
|
# Returns a single page of resources from the server.
|
|
153
157
|
#
|
|
154
158
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
159
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
160
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
155
161
|
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
|
156
162
|
# and `next_cursor` (String or nil).
|
|
157
|
-
def list_resources(cursor: nil)
|
|
163
|
+
def list_resources(cursor: nil, meta: nil)
|
|
158
164
|
params = cursor ? { cursor: cursor } : nil
|
|
159
|
-
response = request(method: "resources/list", params: params)
|
|
165
|
+
response = request(method: "resources/list", params: params, meta: meta)
|
|
160
166
|
result = response["result"] || {}
|
|
161
167
|
|
|
162
168
|
ListResourcesResult.new(
|
|
@@ -181,11 +187,13 @@ module MCP
|
|
|
181
187
|
# Returns a single page of resource templates from the server.
|
|
182
188
|
#
|
|
183
189
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
190
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
191
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
184
192
|
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
|
185
193
|
# (Array<Hash>) and `next_cursor` (String or nil).
|
|
186
|
-
def list_resource_templates(cursor: nil)
|
|
194
|
+
def list_resource_templates(cursor: nil, meta: nil)
|
|
187
195
|
params = cursor ? { cursor: cursor } : nil
|
|
188
|
-
response = request(method: "resources/templates/list", params: params)
|
|
196
|
+
response = request(method: "resources/templates/list", params: params, meta: meta)
|
|
189
197
|
result = response["result"] || {}
|
|
190
198
|
|
|
191
199
|
ListResourceTemplatesResult.new(
|
|
@@ -210,11 +218,13 @@ module MCP
|
|
|
210
218
|
# Returns a single page of prompts from the server.
|
|
211
219
|
#
|
|
212
220
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
221
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
222
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
213
223
|
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
|
214
224
|
# and `next_cursor` (String or nil).
|
|
215
|
-
def list_prompts(cursor: nil)
|
|
225
|
+
def list_prompts(cursor: nil, meta: nil)
|
|
216
226
|
params = cursor ? { cursor: cursor } : nil
|
|
217
|
-
response = request(method: "prompts/list", params: params)
|
|
227
|
+
response = request(method: "prompts/list", params: params, meta: meta)
|
|
218
228
|
result = response["result"] || {}
|
|
219
229
|
|
|
220
230
|
ListPromptsResult.new(
|
|
@@ -242,6 +252,10 @@ module MCP
|
|
|
242
252
|
# @param tool [MCP::Client::Tool] The tool to be called.
|
|
243
253
|
# @param arguments [Object, nil] The arguments to pass to the tool.
|
|
244
254
|
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
|
|
255
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
256
|
+
# e.g. the W3C Trace Context keys reserved by SEP-414
|
|
257
|
+
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
|
|
258
|
+
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
|
|
245
259
|
# @return [Hash] The full JSON-RPC response from the transport.
|
|
246
260
|
#
|
|
247
261
|
# @example Call by name
|
|
@@ -256,14 +270,17 @@ module MCP
|
|
|
256
270
|
# @note
|
|
257
271
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
258
272
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
259
|
-
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
|
|
273
|
+
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
|
|
260
274
|
tool_name = name || tool&.name
|
|
261
275
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
262
276
|
|
|
263
277
|
params = { name: tool_name, arguments: arguments }
|
|
278
|
+
meta_entries = meta ? meta.dup : {}
|
|
264
279
|
if progress_token
|
|
265
|
-
|
|
280
|
+
meta_entries.delete("progressToken")
|
|
281
|
+
meta_entries[:progressToken] = progress_token
|
|
266
282
|
end
|
|
283
|
+
params[:_meta] = meta_entries unless meta_entries.empty?
|
|
267
284
|
|
|
268
285
|
request(method: "tools/call", params: params)
|
|
269
286
|
end
|
|
@@ -271,9 +288,11 @@ module MCP
|
|
|
271
288
|
# Reads a resource from the server by URI and returns the contents.
|
|
272
289
|
#
|
|
273
290
|
# @param uri [String] The URI of the resource to read.
|
|
291
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
292
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
274
293
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
275
|
-
def read_resource(uri:)
|
|
276
|
-
response = request(method: "resources/read", params: { uri: uri })
|
|
294
|
+
def read_resource(uri:, meta: nil)
|
|
295
|
+
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
|
|
277
296
|
|
|
278
297
|
response.dig("result", "contents") || []
|
|
279
298
|
end
|
|
@@ -281,9 +300,11 @@ module MCP
|
|
|
281
300
|
# Gets a prompt from the server by name and returns its details.
|
|
282
301
|
#
|
|
283
302
|
# @param name [String] The name of the prompt to get.
|
|
303
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
304
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
284
305
|
# @return [Hash] A hash containing the prompt details.
|
|
285
|
-
def get_prompt(name:)
|
|
286
|
-
response = request(method: "prompts/get", params: { name: name })
|
|
306
|
+
def get_prompt(name:, meta: nil)
|
|
307
|
+
response = request(method: "prompts/get", params: { name: name }, meta: meta)
|
|
287
308
|
|
|
288
309
|
response.fetch("result", {})
|
|
289
310
|
end
|
|
@@ -294,12 +315,14 @@ module MCP
|
|
|
294
315
|
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
|
|
295
316
|
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
|
|
296
317
|
# @param context [Hash, nil] Optional context with previously resolved arguments.
|
|
318
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
319
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
297
320
|
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
|
|
298
|
-
def complete(ref:, argument:, context: nil)
|
|
321
|
+
def complete(ref:, argument:, context: nil, meta: nil)
|
|
299
322
|
params = { ref: ref, argument: argument }
|
|
300
323
|
params[:context] = context if context
|
|
301
324
|
|
|
302
|
-
response = request(method: "completion/complete", params: params)
|
|
325
|
+
response = request(method: "completion/complete", params: params, meta: meta)
|
|
303
326
|
|
|
304
327
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
305
328
|
end
|
|
@@ -315,8 +338,8 @@ module MCP
|
|
|
315
338
|
# client.ping # => {}
|
|
316
339
|
#
|
|
317
340
|
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
|
318
|
-
def ping
|
|
319
|
-
result = request(method: Methods::PING)["result"]
|
|
341
|
+
def ping(meta: nil)
|
|
342
|
+
result = request(method: Methods::PING, meta: meta)["result"]
|
|
320
343
|
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
321
344
|
|
|
322
345
|
result
|
|
@@ -345,7 +368,13 @@ module MCP
|
|
|
345
368
|
pages
|
|
346
369
|
end
|
|
347
370
|
|
|
348
|
-
|
|
371
|
+
# Merges caller-supplied `meta` entries into the request params as `_meta`,
|
|
372
|
+
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
373
|
+
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
374
|
+
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
375
|
+
def request(method:, params: nil, meta: nil)
|
|
376
|
+
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
377
|
+
|
|
349
378
|
request_body = {
|
|
350
379
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
351
380
|
id: request_id,
|
data/lib/mcp/configuration.rb
CHANGED
data/lib/mcp/resource.rb
CHANGED
|
@@ -5,15 +5,16 @@ require_relative "resource/embedded"
|
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Resource
|
|
8
|
-
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta
|
|
8
|
+
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
|
|
9
9
|
|
|
10
|
-
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil)
|
|
10
|
+
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
|
|
11
11
|
@uri = uri
|
|
12
12
|
@name = name
|
|
13
13
|
@title = title
|
|
14
14
|
@description = description
|
|
15
15
|
@icons = icons
|
|
16
16
|
@mime_type = mime_type
|
|
17
|
+
@annotations = annotations
|
|
17
18
|
@size = size
|
|
18
19
|
@meta = meta
|
|
19
20
|
end
|
|
@@ -26,6 +27,7 @@ module MCP
|
|
|
26
27
|
description: description,
|
|
27
28
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
28
29
|
mimeType: mime_type,
|
|
30
|
+
annotations: annotations&.to_h,
|
|
29
31
|
size: size,
|
|
30
32
|
_meta: meta,
|
|
31
33
|
}.compact
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class ResourceTemplate
|
|
5
|
-
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta
|
|
5
|
+
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
|
|
6
6
|
|
|
7
|
-
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
|
|
7
|
+
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
|
|
8
8
|
@uri_template = uri_template
|
|
9
9
|
@name = name
|
|
10
10
|
@title = title
|
|
11
11
|
@description = description
|
|
12
12
|
@icons = icons
|
|
13
13
|
@mime_type = mime_type
|
|
14
|
+
@annotations = annotations
|
|
14
15
|
@meta = meta
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ module MCP
|
|
|
22
23
|
description: description,
|
|
23
24
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
24
25
|
mimeType: mime_type,
|
|
26
|
+
annotations: annotations&.to_h,
|
|
25
27
|
_meta: meta,
|
|
26
28
|
}.compact
|
|
27
29
|
end
|