mcp 0.20.0 → 0.22.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: c0e0cdbc945ee9ec5d178aeaedb1c8af84eca0bae8db8e264f523b47b8d67c52
4
+ data.tar.gz: 6acb2d299d9ae215c26db733091ebbcbb3f6b1477c94d9b4047f9ab37c3a8f0c
5
5
  SHA512:
6
- metadata.gz: 76797292c1345c02d77b63b5d840bf9a39fe719e2df6a232a32a342d9e6974e296bc4e1a08939bba539ea56f9a00ab25cf225936ecd02cd1cee31fdfd7e39d48
7
- data.tar.gz: f1063ce3c1781e713f556489b0a70d58d102b42d6f220b63a138b97a0532dbfd521b7aea428ed2811d94923bff39ab6f02cad0504ad0597c53e48676cb27984f
6
+ metadata.gz: 8299a6cf0101d4b8b980a69ed3128636aae6f0497fb431d14fdc0f6b8b659fb18ab5d886cf1cb567c53f385a4f8c0cdaf5e91aff6d1a4fe4daefca25d17a7dc2
7
+ data.tar.gz: dff4075f2a0fdf27329af3455b1db6960e38f557ea2436388f1a271d6915172e318b9630ccc8312fab16e3e8ac42e0dc84ef20e974b313056d377f3798d4a83c
data/README.md CHANGED
@@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
41
41
  - Supports roots (server-to-client filesystem boundary queries)
42
42
  - Supports sampling (server-to-client LLM completion requests)
43
43
  - Supports cursor-based pagination for list operations
44
- - Supports server-side cancellation of in-flight requests (notifications/cancelled)
44
+ - Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
45
45
 
46
46
  ### Supported Methods
47
47
 
@@ -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:
@@ -669,8 +698,28 @@ class WeatherTool < MCP::Tool
669
698
  end
670
699
  ```
671
700
 
672
- Please note: in this case, you must provide `type: "array"`. The default type
673
- for output schemas is `object`.
701
+ Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`,
702
+ applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`).
703
+
704
+ Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
705
+ (`{ type: "string" }`) or a root-level composition:
706
+
707
+ ```ruby
708
+ class FlexibleTool < MCP::Tool
709
+ output_schema(
710
+ oneOf: [
711
+ { type: "string" },
712
+ { type: "array", items: { type: "number" } }
713
+ ]
714
+ )
715
+ end
716
+ ```
717
+
718
+ Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it
719
+ (`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to
720
+ all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are
721
+ capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects;
722
+ violations raise `ArgumentError` at construction time.
674
723
 
675
724
  MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:
676
725
 
@@ -700,6 +749,10 @@ Tools can return structured data alongside text content using the `structured_co
700
749
 
701
750
  The structured content will be included in the JSON-RPC response as the `structuredContent` field.
702
751
 
752
+ Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
753
+ without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients
754
+ that only read `content` still receive the data.
755
+
703
756
  ```ruby
704
757
  class WeatherTool < MCP::Tool
705
758
  description "Get current weather and return structured data"
@@ -1176,12 +1229,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
1176
1229
  the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
1177
1230
  is never cancellable per the spec.
1178
1231
 
1179
- > [!NOTE]
1180
- > Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1181
- > the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1182
- > from the client side can be done by constructing the notification payload and writing it
1183
- > directly through the transport, but the calling thread does not yet unwind automatically.
1184
- > This is tracked as a follow-up.
1232
+ Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
1185
1233
 
1186
1234
  #### Server-Side: Handlers that Check for Cancellation
1187
1235
 
@@ -1290,6 +1338,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
1290
1338
  the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
1291
1339
  via `server_context.cancelled?` between nested calls.
1292
1340
 
1341
+ #### Client-Side: Cancelling an In-Flight Request
1342
+
1343
+ `MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass
1344
+ an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
1345
+ `cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
1346
+ the server, and the calling thread is woken up with `MCP::CancelledError`:
1347
+
1348
+ ```ruby
1349
+ client = MCP::Client.new(transport: transport)
1350
+ cancellation = MCP::Cancellation.new
1351
+
1352
+ Thread.new do
1353
+ client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
1354
+ rescue MCP::CancelledError
1355
+ # cleanup
1356
+ end
1357
+
1358
+ # Later, from another thread:
1359
+ cancellation.cancel(reason: "user pressed cancel")
1360
+ ```
1361
+
1362
+ All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
1363
+ `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
1364
+ Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
1365
+
1366
+ > [!NOTE]
1367
+ > When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
1368
+ > it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
1369
+ > `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
1370
+ > the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
1371
+ > to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
1372
+ > (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
1373
+ > least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
1374
+
1375
+ ##### Wire-order guarantees
1376
+
1377
+ `Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
1378
+ so the server is guaranteed to read the request line before the cancel line.
1379
+
1380
+ `Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
1381
+ so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
1382
+ on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
1383
+ still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
1384
+ and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
1385
+ happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
1386
+
1387
+ ##### Custom transports
1388
+
1389
+ Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
1390
+ They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
1391
+ (under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
1392
+ The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
1393
+ the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
1394
+
1293
1395
  ### Ping
1294
1396
 
1295
1397
  The MCP Ruby SDK supports the
@@ -1635,6 +1737,11 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
1635
1737
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
1636
1738
  ```
1637
1739
 
1740
+ In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
1741
+ handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
1742
+ and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
1743
+ (there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
1744
+
1638
1745
  You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
1639
1746
  Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
1640
1747
 
@@ -1961,6 +2068,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
1961
2068
  - Send `Authorization: Bearer <access_token>` on every request when a token is available.
1962
2069
  - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1963
2070
  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.
2071
+ - 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
2072
+ 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),
2073
+ 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
2074
  - 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
2075
  - 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
2076
  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 +2114,8 @@ Required keyword arguments to `Provider.new`:
2004
2114
 
2005
2115
  - `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
2006
2116
  `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
2117
+ 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);
2118
+ an explicit value always wins.
2007
2119
  - `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
2008
2120
  - `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
2009
2121
  - `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
@@ -2,9 +2,16 @@
2
2
 
3
3
  module MCP
4
4
  class Annotations
5
+ SUPPORTED_AUDIENCES = ["user", "assistant"].freeze
6
+
5
7
  attr_reader :audience, :priority, :last_modified
6
8
 
7
9
  def initialize(audience: nil, priority: nil, last_modified: nil)
10
+ if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) })
11
+ raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".'
12
+ end
13
+ raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
14
+
8
15
  @audience = audience
9
16
  @priority = priority
10
17
  @last_modified = last_modified
@@ -16,6 +16,8 @@ module MCP
16
16
  ACCEPT_HEADER = "application/json, text/event-stream"
17
17
  SESSION_ID_HEADER = "Mcp-Session-Id"
18
18
  PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
19
+ METHOD_HEADER = "Mcp-Method"
20
+ NAME_HEADER = "Mcp-Name"
19
21
 
20
22
  # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
21
23
  # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
@@ -178,9 +180,18 @@ module MCP
178
180
  end
179
181
 
180
182
  # Sends a JSON-RPC request and returns the parsed response body.
181
- # After a successful `initialize` handshake, the session ID and protocol
182
- # version returned by the server are captured and automatically included
183
- # on subsequent requests.
183
+ # After a successful `initialize` handshake, the session ID and protocol version returned by
184
+ # the server are captured and automatically included on subsequent requests.
185
+ #
186
+ # If a block is given, it is invoked just before Faraday's `post` is called.
187
+ # Faraday's synchronous `post` does not expose a post-write / pre-response hook,
188
+ # so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
189
+ # inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release
190
+ # the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST
191
+ # that may overlap with the original request on the network. The spec covers this:
192
+ # the sender has issued the request and still believes it in-progress, and receivers MAY ignore
193
+ # a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
194
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
184
195
  def send_request(request:)
185
196
  method = request[:method] || request["method"]
186
197
  params = request[:params] || request["params"]
@@ -188,7 +199,8 @@ module MCP
188
199
  step_up_retried = false
189
200
 
190
201
  begin
191
- response = client.post("", request, session_headers)
202
+ yield if block_given?
203
+ response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
192
204
  body = parse_response_body(response, method, params)
193
205
 
194
206
  capture_session_info(method, response, body)
@@ -274,6 +286,23 @@ module MCP
274
286
  end
275
287
  end
276
288
 
289
+ # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
290
+ # `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted
291
+ # per the Streamable HTTP spec.
292
+ def send_notification(notification:)
293
+ method = notification[:method] || notification["method"]
294
+
295
+ client.post("", notification, session_headers)
296
+ nil
297
+ rescue Faraday::Error => e
298
+ raise RequestHandlerError.new(
299
+ "Failed to send #{method} notification",
300
+ { method: method },
301
+ error_type: :internal_error,
302
+ original_error: e,
303
+ )
304
+ end
305
+
277
306
  # Terminates the session by sending an HTTP DELETE to the MCP endpoint
278
307
  # with the current `Mcp-Session-Id` header, and clears locally tracked
279
308
  # session state afterward. No-op when no session has been established.
@@ -341,6 +370,39 @@ module MCP
341
370
  request_headers
342
371
  end
343
372
 
373
+ # Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
374
+ # can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
375
+ # (tools, prompts) or, when absent, `params.uri` (resources).
376
+ #
377
+ # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
378
+ def request_metadata_headers(method, params)
379
+ return {} unless method
380
+
381
+ metadata_headers = { METHOD_HEADER => method }
382
+ if params.is_a?(Hash)
383
+ name = params[:name] || params["name"]
384
+ name = params[:uri] || params["uri"] unless name.is_a?(String)
385
+ metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
386
+ end
387
+
388
+ metadata_headers
389
+ end
390
+
391
+ # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
392
+ # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
393
+ # `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
394
+ def encode_header_value(value)
395
+ return value if safe_header_value?(value)
396
+
397
+ "=?base64?#{[value].pack("m0")}?="
398
+ end
399
+
400
+ def safe_header_value?(value)
401
+ value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
402
+ (value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
403
+ !(value.start_with?("=?base64?") && value.end_with?("?="))
404
+ end
405
+
344
406
  # Drives the OAuth orchestrator on a 401 from the MCP endpoint.
345
407
  # The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
346
408
  # URL and an optional `scope` challenge per RFC 9728 Section 5.1.
@@ -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
@@ -34,6 +34,9 @@ module MCP
34
34
  @started = false
35
35
  @initialized = false
36
36
  @server_info = nil
37
+ # Serializes writes to `@stdin` so a request line and a notification line emitted from
38
+ # different threads (e.g. cancellation) cannot interleave on the wire.
39
+ @write_mutex = Mutex.new
37
40
  end
38
41
 
39
42
  # Performs the MCP `initialize` handshake: sends an `initialize` request
@@ -132,6 +135,10 @@ module MCP
132
135
  @initialized
133
136
  end
134
137
 
138
+ # Transports may yield once the request line has been written to `@stdin`.
139
+ # `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
140
+ # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
141
+ # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
135
142
  def send_request(request:)
136
143
  start unless @started
137
144
  unless @initialized
@@ -139,10 +146,23 @@ module MCP
139
146
  connect
140
147
  end
141
148
 
142
- write_message(request)
149
+ @write_mutex.synchronize do
150
+ write_message(request)
151
+ yield if block_given?
152
+ end
143
153
  read_response(request)
144
154
  end
145
155
 
156
+ # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
157
+ # `notifications/cancelled` for an in-flight request.
158
+ def send_notification(notification:)
159
+ start unless @started
160
+ connect unless @initialized
161
+
162
+ @write_mutex.synchronize { write_message(notification) }
163
+ nil
164
+ end
165
+
146
166
  def start
147
167
  raise "MCP::Client::Stdio already started" if @started
148
168