mcp 0.20.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 +39 -0
- data/lib/mcp/annotations.rb +2 -0
- data/lib/mcp/client/oauth/discovery.rb +31 -0
- data/lib/mcp/client/oauth/flow.rb +103 -16
- data/lib/mcp/client/oauth/provider.rb +3 -0
- data/lib/mcp/client.rb +3 -1
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +27 -19
- data/lib/mcp/server.rb +11 -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: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
|
|
4
|
+
data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c2ef2a03fb1b989e691aea55d350f06b9f1f357b0acfcda4e227ca359c3a12990d06fe280ced39b1e6d620181aa9c7b8d355d5985c70d090512636da7c34f3a
|
|
7
|
+
data.tar.gz: a75f884057bc318a98943fec8967c4725c63dd767552dc52591c8e08143474f8a28907e929cdbe0efb645426766b75314449b9df8c3c86aa90bb0a68247004ff
|
data/README.md
CHANGED
|
@@ -231,6 +231,31 @@ server = MCP::Server.new(
|
|
|
231
231
|
)
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
+
### Capability Extensions
|
|
235
|
+
|
|
236
|
+
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
|
|
237
|
+
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
|
238
|
+
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
|
|
239
|
+
|
|
240
|
+
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
capabilities = MCP::Server::Capabilities.new
|
|
244
|
+
capabilities.support_tools
|
|
245
|
+
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
|
246
|
+
|
|
247
|
+
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
|
|
251
|
+
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
|
|
252
|
+
|
|
253
|
+
On the client, pass extensions through `connect`:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
|
|
257
|
+
```
|
|
258
|
+
|
|
234
259
|
### Server Context and Configuration Block Data
|
|
235
260
|
|
|
236
261
|
#### `server_context`
|
|
@@ -549,6 +574,10 @@ end
|
|
|
549
574
|
The server_context parameter is the server_context passed into the server and can be used to pass per request information,
|
|
550
575
|
e.g. around authentication state.
|
|
551
576
|
|
|
577
|
+
Tool arguments arrive as a `Hash` with symbol keys at every nesting level, because the transports parse JSON with `symbolize_names: true`.
|
|
578
|
+
Read nested objects with symbol keys (`payload[:subject]`, not `payload["subject"]`).
|
|
579
|
+
See [Tool argument keys](docs/building-servers.md#tool-argument-keys) for details and a testing tip.
|
|
580
|
+
|
|
552
581
|
### Tool Annotations
|
|
553
582
|
|
|
554
583
|
Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:
|
|
@@ -1635,6 +1664,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
|
|
|
1635
1664
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
1636
1665
|
```
|
|
1637
1666
|
|
|
1667
|
+
In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
|
|
1668
|
+
handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
|
|
1669
|
+
and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
|
|
1670
|
+
(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
|
|
1671
|
+
|
|
1638
1672
|
You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
|
|
1639
1673
|
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
|
|
1640
1674
|
|
|
@@ -1961,6 +1995,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
|
|
1961
1995
|
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
|
|
1962
1996
|
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
|
1963
1997
|
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.
|
|
1998
|
+
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
|
|
1999
|
+
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
|
|
2000
|
+
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
|
1964
2001
|
- 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).
|
|
1965
2002
|
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
|
1966
2003
|
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
|
@@ -2004,6 +2041,8 @@ Required keyword arguments to `Provider.new`:
|
|
|
2004
2041
|
|
|
2005
2042
|
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
|
|
2006
2043
|
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
|
|
2044
|
+
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
|
|
2045
|
+
an explicit value always wins.
|
|
2007
2046
|
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
|
|
2008
2047
|
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
|
|
2009
2048
|
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
|
data/lib/mcp/annotations.rb
CHANGED
|
@@ -5,6 +5,8 @@ module MCP
|
|
|
5
5
|
attr_reader :audience, :priority, :last_modified
|
|
6
6
|
|
|
7
7
|
def initialize(audience: nil, priority: nil, last_modified: nil)
|
|
8
|
+
raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
|
|
9
|
+
|
|
8
10
|
@audience = audience
|
|
9
11
|
@priority = priority
|
|
10
12
|
@last_modified = last_modified
|
|
@@ -237,6 +237,23 @@ module MCP
|
|
|
237
237
|
false
|
|
238
238
|
end
|
|
239
239
|
|
|
240
|
+
# Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`.
|
|
241
|
+
# Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
|
|
242
|
+
# so the authorization server can apply the matching redirect URI policy.
|
|
243
|
+
#
|
|
244
|
+
# Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
|
|
245
|
+
# or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3).
|
|
246
|
+
# Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI.
|
|
247
|
+
#
|
|
248
|
+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
|
|
249
|
+
# - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
|
|
250
|
+
def infer_application_type(redirect_uris)
|
|
251
|
+
uris = Array(redirect_uris)
|
|
252
|
+
return "web" if uris.empty?
|
|
253
|
+
|
|
254
|
+
uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web"
|
|
255
|
+
end
|
|
256
|
+
|
|
240
257
|
# Like `canonicalize_url` but also strips query string, fragment, and
|
|
241
258
|
# userinfo. This variant is used for identity comparison against
|
|
242
259
|
# the request URL Faraday actually sends, which differs from the value
|
|
@@ -345,6 +362,20 @@ module MCP
|
|
|
345
362
|
nil
|
|
346
363
|
end
|
|
347
364
|
|
|
365
|
+
# A redirect URI counts as native when it uses a custom non-http(s) scheme
|
|
366
|
+
# (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
|
|
367
|
+
# a loopback address. A URI without a scheme or one that fails to parse is not native.
|
|
368
|
+
def native_redirect_uri?(url)
|
|
369
|
+
uri = URI.parse(url.to_s)
|
|
370
|
+
scheme = uri.scheme&.downcase
|
|
371
|
+
return false if scheme.nil?
|
|
372
|
+
return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
|
|
373
|
+
|
|
374
|
+
true
|
|
375
|
+
rescue URI::InvalidURIError
|
|
376
|
+
false
|
|
377
|
+
end
|
|
378
|
+
|
|
348
379
|
def base_url(uri)
|
|
349
380
|
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
|
350
381
|
"#{uri.scheme}://#{uri.host}#{port_part}"
|
|
@@ -38,22 +38,18 @@ 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"))
|
|
53
51
|
|
|
54
|
-
as_metadata =
|
|
55
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
56
|
-
ensure_secure_endpoints!(as_metadata)
|
|
52
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
57
53
|
|
|
58
54
|
if provider_authorization_flow == :client_credentials
|
|
59
55
|
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
@@ -63,7 +59,7 @@ module MCP
|
|
|
63
59
|
|
|
64
60
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
65
61
|
|
|
66
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
67
63
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
68
64
|
pkce = PKCE.generate
|
|
69
65
|
state = SecureRandom.urlsafe_base64(32)
|
|
@@ -158,18 +154,14 @@ module MCP
|
|
|
158
154
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
159
155
|
end
|
|
160
156
|
|
|
161
|
-
prm =
|
|
157
|
+
prm, authorization_server = locate_authorization_server(
|
|
162
158
|
server_url: server_url,
|
|
163
159
|
resource_metadata_url: resource_metadata_url,
|
|
164
160
|
)
|
|
165
|
-
authorization_server = first_authorization_server(prm)
|
|
166
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
167
161
|
|
|
168
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
162
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
169
163
|
|
|
170
|
-
as_metadata =
|
|
171
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
172
|
-
ensure_secure_endpoints!(as_metadata)
|
|
164
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
173
165
|
|
|
174
166
|
client_info = if have_stored_client_info
|
|
175
167
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
@@ -221,6 +213,87 @@ module MCP
|
|
|
221
213
|
fetch_metadata_json(urls, label: "protected resource metadata")
|
|
222
214
|
end
|
|
223
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
|
+
|
|
224
297
|
def fetch_authorization_server_metadata(issuer_url:)
|
|
225
298
|
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
|
226
299
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
@@ -367,7 +440,7 @@ module MCP
|
|
|
367
440
|
end
|
|
368
441
|
|
|
369
442
|
response = begin
|
|
370
|
-
http_post_json(registration_endpoint,
|
|
443
|
+
http_post_json(registration_endpoint, registration_client_metadata)
|
|
371
444
|
rescue Faraday::Error => e
|
|
372
445
|
raise AuthorizationError,
|
|
373
446
|
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
|
@@ -393,6 +466,20 @@ module MCP
|
|
|
393
466
|
info
|
|
394
467
|
end
|
|
395
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
|
+
|
|
396
483
|
# Reads `key` from a `client_information` hash that may use either string or
|
|
397
484
|
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
398
485
|
# `{ client_id:, client_secret: }` and have both work.
|
|
@@ -13,6 +13,9 @@ module MCP
|
|
|
13
13
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
14
14
|
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
15
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.
|
|
16
19
|
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
17
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
18
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
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
|
|
@@ -6,6 +6,7 @@ module MCP
|
|
|
6
6
|
def initialize(capabilities_hash = nil)
|
|
7
7
|
@completions = nil
|
|
8
8
|
@experimental = nil
|
|
9
|
+
@extensions = nil
|
|
9
10
|
@logging = nil
|
|
10
11
|
@prompts = nil
|
|
11
12
|
@resources = nil
|
|
@@ -14,6 +15,7 @@ module MCP
|
|
|
14
15
|
if capabilities_hash
|
|
15
16
|
support_completions if capabilities_hash.key?(:completions)
|
|
16
17
|
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
|
18
|
+
support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions)
|
|
17
19
|
support_logging if capabilities_hash.key?(:logging)
|
|
18
20
|
|
|
19
21
|
if capabilities_hash.key?(:prompts)
|
|
@@ -45,6 +47,17 @@ module MCP
|
|
|
45
47
|
@experimental = config || {}
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Declares support for capability extensions per SEP-2133. Keys are
|
|
51
|
+
# extension identifiers using the reverse-DNS prefix convention
|
|
52
|
+
# (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
|
53
|
+
# values are arbitrary extension-defined configuration objects,
|
|
54
|
+
# with an empty hash meaning "supported with no settings".
|
|
55
|
+
# Repeated calls merge, so several extensions can be declared independently.
|
|
56
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
|
|
57
|
+
def support_extensions(extensions = {})
|
|
58
|
+
@extensions = (@extensions || {}).merge(extensions || {})
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def support_logging
|
|
49
62
|
@logging ||= {}
|
|
50
63
|
end
|
|
@@ -85,6 +98,7 @@ module MCP
|
|
|
85
98
|
{
|
|
86
99
|
completions: @completions,
|
|
87
100
|
experimental: @experimental,
|
|
101
|
+
extensions: @extensions,
|
|
88
102
|
logging: @logging,
|
|
89
103
|
prompts: @prompts,
|
|
90
104
|
resources: @resources,
|
|
@@ -85,8 +85,10 @@ module MCP
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
88
|
-
# Stateless mode
|
|
89
|
-
|
|
88
|
+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
|
89
|
+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
|
90
|
+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
|
91
|
+
return false if @stateless
|
|
90
92
|
|
|
91
93
|
notification = {
|
|
92
94
|
jsonrpc: "2.0",
|
|
@@ -575,7 +577,9 @@ module MCP
|
|
|
575
577
|
# `notifications/initialized`) through the server so it can update session state.
|
|
576
578
|
def dispatch_notification(body_string, session_id)
|
|
577
579
|
server_session = nil
|
|
578
|
-
if
|
|
580
|
+
if @stateless
|
|
581
|
+
server_session = ephemeral_session
|
|
582
|
+
elsif session_id
|
|
579
583
|
@mutex.synchronize do
|
|
580
584
|
session = @sessions[session_id]
|
|
581
585
|
server_session = session[:server_session] if session
|
|
@@ -611,9 +615,10 @@ module MCP
|
|
|
611
615
|
|
|
612
616
|
def handle_initialization(body_string, body)
|
|
613
617
|
session_id = nil
|
|
614
|
-
server_session = nil
|
|
615
618
|
|
|
616
|
-
|
|
619
|
+
if @stateless
|
|
620
|
+
server_session = ephemeral_session
|
|
621
|
+
else
|
|
617
622
|
session_id = SecureRandom.uuid
|
|
618
623
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
619
624
|
|
|
@@ -626,17 +631,13 @@ module MCP
|
|
|
626
631
|
end
|
|
627
632
|
end
|
|
628
633
|
|
|
629
|
-
response =
|
|
630
|
-
server_session.handle_json(body_string)
|
|
631
|
-
else
|
|
632
|
-
@server.handle_json(body_string)
|
|
633
|
-
end
|
|
634
|
+
response = server_session.handle_json(body_string)
|
|
634
635
|
|
|
635
636
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
636
637
|
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
637
638
|
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
638
639
|
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
|
639
|
-
if
|
|
640
|
+
if session_id && !server_session.initialized?
|
|
640
641
|
cleanup_session(session_id)
|
|
641
642
|
session_id = nil
|
|
642
643
|
end
|
|
@@ -657,15 +658,15 @@ module MCP
|
|
|
657
658
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
658
659
|
server_session = nil
|
|
659
660
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
if @stateless
|
|
662
|
+
server_session = ephemeral_session
|
|
663
|
+
elsif session_id
|
|
664
|
+
error_response = validate_and_touch_session(session_id)
|
|
665
|
+
return error_response if error_response
|
|
664
666
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
end
|
|
667
|
+
@mutex.synchronize do
|
|
668
|
+
session = @sessions[session_id]
|
|
669
|
+
server_session = session[:server_session] if session
|
|
669
670
|
end
|
|
670
671
|
end
|
|
671
672
|
|
|
@@ -775,6 +776,13 @@ module MCP
|
|
|
775
776
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
776
777
|
end
|
|
777
778
|
|
|
779
|
+
# Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession`
|
|
780
|
+
# so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests.
|
|
781
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
|
|
782
|
+
def ephemeral_session
|
|
783
|
+
ServerSession.new(server: @server, transport: self, session_id: nil)
|
|
784
|
+
end
|
|
785
|
+
|
|
778
786
|
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
|
779
787
|
# are evicted as a side effect so a live request never observes a zombie session that
|
|
780
788
|
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
data/lib/mcp/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "methods"
|
|
|
8
8
|
require_relative "logging_message_notification"
|
|
9
9
|
require_relative "progress"
|
|
10
10
|
require_relative "server_context"
|
|
11
|
+
require_relative "server/capabilities"
|
|
11
12
|
require_relative "server/pagination"
|
|
12
13
|
require_relative "server/transports"
|
|
13
14
|
|
|
@@ -142,7 +143,12 @@ module MCP
|
|
|
142
143
|
|
|
143
144
|
validate!
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
|
|
147
|
+
@capabilities = if capabilities.is_a?(Capabilities)
|
|
148
|
+
capabilities.to_h
|
|
149
|
+
else
|
|
150
|
+
capabilities || default_capabilities
|
|
151
|
+
end
|
|
146
152
|
@client_capabilities = nil
|
|
147
153
|
@logging_message_notification = nil
|
|
148
154
|
|
|
@@ -810,6 +816,10 @@ module MCP
|
|
|
810
816
|
end
|
|
811
817
|
|
|
812
818
|
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
|
819
|
+
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
|
820
|
+
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
|
821
|
+
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
|
822
|
+
# See docs/building-servers.md ("Tool argument keys").
|
|
813
823
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
814
824
|
|
|
815
825
|
if accepts_server_context?(tool.method(:call))
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -90,7 +90,7 @@ licenses:
|
|
|
90
90
|
- Apache-2.0
|
|
91
91
|
metadata:
|
|
92
92
|
allowed_push_host: https://rubygems.org
|
|
93
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
93
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.21.0
|
|
94
94
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
95
95
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
96
96
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|