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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1915f5e50dda558f3ba32d0c946dacafda7c0ee183300cd634632505ab206bd
4
- data.tar.gz: 9f702e675c56e191effefa6a7a7ac5b4d5f3c0c8bded9284484bf7260b551f3b
3
+ metadata.gz: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
4
+ data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
5
5
  SHA512:
6
- metadata.gz: 76797292c1345c02d77b63b5d840bf9a39fe719e2df6a232a32a342d9e6974e296bc4e1a08939bba539ea56f9a00ab25cf225936ecd02cd1cee31fdfd7e39d48
7
- data.tar.gz: f1063ce3c1781e713f556489b0a70d58d102b42d6f220b63a138b97a0532dbfd521b7aea428ed2811d94923bff39ab6f02cad0504ad0597c53e48676cb27984f
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`.
@@ -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 = fetch_protected_resource_metadata(
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["resource"])
50
+ resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
53
51
 
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)
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 = fetch_protected_resource_metadata(
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["resource"])
162
+ resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
169
163
 
170
- as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
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, @provider.client_metadata)
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 doesn't support notifications
89
- raise "Stateless mode does not support notifications" if @stateless
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 session_id && !@stateless
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
- unless @stateless
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 = if server_session
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 server_session && !server_session.initialized?
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
- unless @stateless
661
- if session_id
662
- error_response = validate_and_touch_session(session_id)
663
- return error_response if error_response
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
- @mutex.synchronize do
666
- session = @sessions[session_id]
667
- server_session = session[:server_session] if session
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
- @capabilities = capabilities || default_capabilities
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.20.0"
4
+ VERSION = "0.21.0"
5
5
  end
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.20.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.20.0
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