mcp 0.19.0 → 0.20.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: 959e38a26541c9681c11e8bc227e84c42d20e3d98de1568c0afd45c80f3b4474
4
- data.tar.gz: e2bf6b0c7926e1803f9dff1279c1f5a35d96f1cf8a1e3c6141b22a97871c8ec2
3
+ metadata.gz: f1915f5e50dda558f3ba32d0c946dacafda7c0ee183300cd634632505ab206bd
4
+ data.tar.gz: 9f702e675c56e191effefa6a7a7ac5b4d5f3c0c8bded9284484bf7260b551f3b
5
5
  SHA512:
6
- metadata.gz: 5f347a7259f4e728df5edad217d6963cb3428188e46244efd9be9908e84bf4a7134600c6f453249eedc87e33f998f3f51811922659f116811251a64d78f1bbee
7
- data.tar.gz: 3d8d25f65c590d07157db74d87423bbde82250b5d99cf2e3182c8adbc198f534f96809265c8615160f49f963a3b2dc2a07a85db2f6f2cc83e11b068583f405de
6
+ metadata.gz: 76797292c1345c02d77b63b5d840bf9a39fe719e2df6a232a32a342d9e6974e296bc4e1a08939bba539ea56f9a00ab25cf225936ecd02cd1cee31fdfd7e39d48
7
+ data.tar.gz: f1063ce3c1781e713f556489b0a70d58d102b42d6f220b63a138b97a0532dbfd521b7aea428ed2811d94923bff39ab6f02cad0504ad0597c53e48676cb27984f
data/README.md CHANGED
@@ -47,6 +47,7 @@ It implements the Model Context Protocol specification, handling model context r
47
47
 
48
48
  - `initialize` - Initializes the protocol and returns server capabilities
49
49
  - `ping` - Simple health check
50
+ - `logging/setLevel` - Configures the minimum log level for the server
50
51
  - `tools/list` - Lists all registered tools and their schemas
51
52
  - `tools/call` - Invokes a specific tool with provided arguments
52
53
  - `prompts/list` - Lists all registered prompts and their schemas
@@ -260,6 +261,11 @@ See the relevant sections below for the arguments they receive.
260
261
 
261
262
  The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`.
262
263
 
264
+ > [!NOTE]
265
+ > `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized).
266
+ > If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it
267
+ > under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`.
268
+
263
269
  **Access Pattern:**
264
270
 
265
271
  When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`:
@@ -300,6 +306,36 @@ end
300
306
  }
301
307
  ```
302
308
 
309
+ **Distributed Tracing (W3C Trace Context):**
310
+
311
+ Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating
312
+ [W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through
313
+ incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`,
314
+ `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values
315
+ to your tracing system yourself:
316
+
317
+ ```ruby
318
+ class TracedTool < MCP::Tool
319
+ def self.call(message:, server_context:)
320
+ traceparent = server_context.dig(:_meta, :traceparent)
321
+ # Hand traceparent/tracestate/baggage to your tracing library
322
+ # (e.g. the opentelemetry-ruby gems) to continue the caller's trace.
323
+
324
+ MCP::Tool::Response.new([{ type: "text", text: "ok" }])
325
+ end
326
+ end
327
+ ```
328
+
329
+ On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods)
330
+ accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request:
331
+
332
+ ```ruby
333
+ meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
334
+
335
+ client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
336
+ client.read_resource(uri: "file:///report.txt", meta: meta)
337
+ ```
338
+
303
339
  #### Configuration Block Data
304
340
 
305
341
  ##### Exception Reporter
@@ -423,10 +459,10 @@ The exception reporter receives two arguments:
423
459
  - `exception`: The Ruby exception object that was raised
424
460
  - `server_context`: A hash containing contextual information about where the error occurred
425
461
 
426
- The server_context hash includes:
462
+ The `server_context` hash includes:
427
463
 
428
- - For tool calls: `{ tool_name: "name", arguments: { ... } }`
429
- - For general request handling: `{ request: { ... } }`
464
+ - For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash)
465
+ - For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name)
430
466
 
431
467
  When an exception occurs:
432
468
 
@@ -891,6 +927,19 @@ end
891
927
 
892
928
  otherwise `resources/read` requests will be a no-op.
893
929
 
930
+ For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler.
931
+ Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`)
932
+ carrying the requested URI in the error `data` member:
933
+
934
+ ```ruby
935
+ server.resources_read_handler do |params|
936
+ resource = lookup(params[:uri])
937
+ raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource
938
+
939
+ [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }]
940
+ end
941
+ ```
942
+
894
943
  ### Resource Templates
895
944
 
896
945
  The `MCP::ResourceTemplate` class provides a way to register resource templates with the server.
@@ -1913,6 +1962,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
1913
1962
  - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1914
1963
  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.
1915
1964
  - 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
+ - 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
+ 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.
1967
+ The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
1916
1968
  - Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1917
1969
  `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
1918
1970
  `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
@@ -2014,6 +2066,29 @@ provider = MCP::Client::OAuth::Provider.new(
2014
2066
  )
2015
2067
  ```
2016
2068
 
2069
+ ##### Client Credentials Grant
2070
+
2071
+ For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
2072
+ The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
2073
+ the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
2074
+
2075
+ ```ruby
2076
+ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
2077
+ client_id: "my-service",
2078
+ client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
2079
+ # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
2080
+ # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
2081
+ )
2082
+
2083
+ transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
2084
+ ```
2085
+
2086
+ Keyword arguments:
2087
+
2088
+ - `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
2089
+ - `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
2090
+ - `scope`, `storage`: Optional, same meaning as on `Provider`.
2091
+
2017
2092
  ##### Communication Security
2018
2093
 
2019
2094
  When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
@@ -73,13 +73,18 @@ module JsonRpcHandler
73
73
 
74
74
  error = if !valid_version?(request[:jsonrpc])
75
75
  "JSON-RPC version must be 2.0"
76
- elsif !valid_id?(request[:id], id_validation_pattern)
76
+ elsif !valid_id?(id, id_validation_pattern)
77
77
  "Request ID must match validation pattern, or be an integer or null"
78
78
  elsif !valid_method_name?(request[:method])
79
79
  'Method name must be a string and not start with "rpc."'
80
80
  end
81
81
 
82
- return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
82
+ # Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
83
+ # the same id as the request when the id could be detected; null is only
84
+ # for requests whose id could not be determined. `error_response` nils out
85
+ # ids that fail validation, and the `:unknown_id` sentinel keeps a response
86
+ # (with a null id) being emitted when the request carried no id at all.
87
+ return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
83
88
  code: ErrorCode::INVALID_REQUEST,
84
89
  message: "Invalid Request",
85
90
  data: error,
@@ -185,6 +185,7 @@ module MCP
185
185
  method = request[:method] || request["method"]
186
186
  params = request[:params] || request["params"]
187
187
  oauth_retried = false
188
+ step_up_retried = false
188
189
 
189
190
  begin
190
191
  response = client.post("", request, session_headers)
@@ -217,6 +218,19 @@ module MCP
217
218
  original_error: e,
218
219
  )
219
220
  rescue Faraday::ForbiddenError => e
221
+ # OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in
222
+ # the Bearer challenge means the existing access token is valid
223
+ # but lacks scopes the server now requires for this operation.
224
+ # Re-run the full authorization flow with the escalated scope from
225
+ # the challenge and retry once. A plain 403 without the challenge is
226
+ # surfaced unchanged.
227
+ if @oauth && !step_up_retried && insufficient_scope_challenge?(e)
228
+ step_up_retried = true
229
+ run_step_up_flow!(forbidden_error: e)
230
+
231
+ retry
232
+ end
233
+
220
234
  raise RequestHandlerError.new(
221
235
  "You are forbidden to make #{method} requests",
222
236
  { method: method, params: params },
@@ -337,16 +351,63 @@ module MCP
337
351
  #
338
352
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
339
353
  def run_oauth_flow!(unauthorized_error:)
340
- response = unauthorized_error.response || {}
341
- response_headers = response[:headers] || {}
342
- www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
343
- params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
354
+ params = parse_www_authenticate_from_error(unauthorized_error)
355
+ flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
356
+ return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
344
357
 
358
+ run_full_authorization_flow!(flow: flow, params: params)
359
+ end
360
+
361
+ # Drives a full Authorization Code + PKCE flow without first attempting
362
+ # to refresh the access token. Used for the MCP scope-selection-strategy
363
+ # step-up path: the provider already holds a valid access token,
364
+ # but the server returned a 403 with
365
+ # `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
366
+ # per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
367
+ # the same scope set the server already rejected, so the SDK must run
368
+ # a fresh authorization request. The request asks for the union of
369
+ # the currently granted scope and the newly demanded scope; otherwise
370
+ # the caller would lose previously held scopes and trigger another step-up
371
+ # on the next operation that needs them.
372
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
373
+ def run_step_up_flow!(forbidden_error:)
374
+ params = parse_www_authenticate_from_error(forbidden_error)
345
375
  flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
346
- if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
347
- return
348
- end
376
+ params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
377
+
378
+ run_full_authorization_flow!(flow: flow, params: params)
379
+ end
380
+
381
+ # Returns the space-separated union of the currently granted scope (read
382
+ # from the stored token response per RFC 6749 Section 5.1) and the scope
383
+ # demanded by the step-up challenge. Duplicates are collapsed; order
384
+ # follows first appearance so existing scopes precede the newly added
385
+ # ones. Returns `nil` when neither side carries a scope so
386
+ # `build_authorization_url` omits the `scope` parameter entirely.
387
+ def escalated_step_up_scope(challenge_scope)
388
+ tokens = @oauth.tokens
389
+ granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
390
+ scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
391
+
392
+ scopes.empty? ? nil : scopes.join(" ")
393
+ end
394
+
395
+ # True when the response on `forbidden_error` carries a Bearer challenge
396
+ # with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
397
+ # scope-selection-strategy section. A 403 without that signal is not a
398
+ # step-up challenge and must not trigger re-authorization.
399
+ def insufficient_scope_challenge?(forbidden_error)
400
+ parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
401
+ end
402
+
403
+ def parse_www_authenticate_from_error(error)
404
+ response = error.response || {}
405
+ response_headers = response[:headers] || {}
406
+ header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
407
+ MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
408
+ end
349
409
 
410
+ def run_full_authorization_flow!(flow:, params:)
350
411
  # Use the URL snapshotted at `initialize` time so a post-construction
351
412
  # mutation of `@url` cannot redirect PRM/AS discovery and the authorize
352
413
  # URL to an attacker-controlled host.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # OAuth client configuration for the OAuth 2.1 `client_credentials` grant
7
+ # (machine-to-machine, no user and no browser redirect). Handed to
8
+ # `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
9
+ # The interactive Authorization Code flow lives in `Provider`;
10
+ # this class exists so a credentials-only client never has to supply
11
+ # the redirect arguments that grant has no use for, mirroring the dedicated
12
+ # `ClientCredentialsProvider` in the TypeScript SDK and
13
+ # `ClientCredentialsOAuthProvider` in the Python SDK.
14
+ #
15
+ # Required keyword arguments:
16
+ #
17
+ # - `client_id` - String identifying the pre-registered confidential client.
18
+ # - `client_secret` - String shared secret. The `client_credentials` grant
19
+ # is for confidential clients, so a credential is mandatory.
20
+ #
21
+ # Optional keyword arguments:
22
+ #
23
+ # - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or
24
+ # `"client_secret_post"`. `"none"` is rejected: an unauthenticated
25
+ # `client_credentials` request is meaningless.
26
+ # - `scope` - String of space-separated scopes to request when the server's
27
+ # `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
28
+ # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
29
+ # `client_information`, and `save_client_information(info)`. Defaults to
30
+ # an `InMemoryStorage`. The `client_id` / `client_secret` are written
31
+ # into it so the token exchange reads them through the same path as
32
+ # a pre-registered authorization-code client.
33
+ class ClientCredentialsProvider
34
+ include StorageBackedProvider
35
+
36
+ # Raised when the credentials required for the `client_credentials` grant are
37
+ # missing or the requested client authentication method cannot carry them.
38
+ class InvalidCredentialsError < ArgumentError; end
39
+
40
+ SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze
41
+
42
+ attr_reader :scope, :storage
43
+
44
+ def initialize(
45
+ client_id:,
46
+ client_secret:,
47
+ token_endpoint_auth_method: "client_secret_basic",
48
+ scope: nil,
49
+ storage: nil
50
+ )
51
+ if blank?(client_id)
52
+ raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
53
+ end
54
+
55
+ unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method)
56
+ raise InvalidCredentialsError,
57
+ "token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \
58
+ "client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \
59
+ "client_credentials request is not allowed."
60
+ end
61
+
62
+ if blank?(client_secret)
63
+ raise InvalidCredentialsError,
64
+ "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
65
+ end
66
+
67
+ @scope = scope
68
+ @storage = storage || InMemoryStorage.new
69
+ @storage.save_client_information(
70
+ "client_id" => client_id,
71
+ "client_secret" => client_secret,
72
+ "token_endpoint_auth_method" => token_endpoint_auth_method,
73
+ )
74
+ end
75
+
76
+ # See `Provider#authorization_flow`.
77
+ def authorization_flow
78
+ :client_credentials
79
+ end
80
+
81
+ private
82
+
83
+ def blank?(value)
84
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -90,7 +90,17 @@ module MCP
90
90
  end
91
91
 
92
92
  # Returns the candidate Authorization Server metadata URLs to probe, in priority order.
93
- # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
93
+ #
94
+ # Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix
95
+ # registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own.
96
+ # The OAuth candidates below therefore use only that default suffix
97
+ # (plus the `openid-configuration` suffix from OpenID Connect Discovery),
98
+ # both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component
99
+ # and in the root form for issuers without one.
100
+ #
101
+ # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
102
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351
103
+ # - https://www.rfc-editor.org/rfc/rfc8414#section-3.1
94
104
  def authorization_server_metadata_urls(issuer_url)
95
105
  uri = URI.parse(issuer_url)
96
106
  path = uri.path == "/" ? "" : uri.path.to_s
@@ -54,6 +54,11 @@ module MCP
54
54
  as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
55
55
  ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
56
56
  ensure_secure_endpoints!(as_metadata)
57
+
58
+ if provider_authorization_flow == :client_credentials
59
+ return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
60
+ end
61
+
57
62
  ensure_pkce_supported!(as_metadata)
58
63
 
59
64
  client_info = ensure_client_registered(as_metadata: as_metadata)
@@ -92,14 +97,46 @@ module MCP
92
97
  :authorized
93
98
  end
94
99
 
95
- # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
96
- # Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
97
- # token endpoint, and re-runs the audience / issuer / security checks
98
- # before talking to it.
100
+ # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
101
+ # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
102
+ # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
103
+ # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
104
+ # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
105
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
106
+ def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
107
+ client_info = client_credentials_client_info
108
+
109
+ form = { "grant_type" => "client_credentials" }
110
+ effective_scope = resolve_scope(scope: scope, prm: prm)
111
+ form["scope"] = effective_scope if effective_scope
112
+ form["resource"] = resource if resource
113
+
114
+ tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
115
+ @provider.save_tokens(tokens)
116
+ :authorized
117
+ end
118
+
119
+ # Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
120
+ # `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
121
+ # flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
122
+ # The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
123
+ # to dynamic registration.
124
+ def client_credentials_client_info
125
+ info = @provider.client_information
126
+ unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
127
+ raise AuthorizationError,
128
+ "Cannot run the client_credentials grant: the provider has no stored `client_id`."
129
+ end
130
+
131
+ info
132
+ end
133
+
134
+ # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
135
+ # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
136
+ # checks before talking to it.
99
137
  #
100
- # Returns `:refreshed` on success. Raises `AuthorizationError` when
101
- # the provider has no refresh token, no client information, or when
102
- # the token endpoint refuses the refresh request.
138
+ # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
139
+ # or when the token endpoint refuses the refresh request.
103
140
  # https://www.rfc-editor.org/rfc/rfc6749#section-6
104
141
  def refresh!(server_url:, resource_metadata_url: nil)
105
142
  refresh_token = read_token("refresh_token")
@@ -110,8 +147,7 @@ module MCP
110
147
 
111
148
  # A CIMD-configured provider stores no `client_information` on purpose
112
149
  # (the CIMD URL is re-resolved against the live AS metadata on every flow).
113
- # Allow refresh to proceed in that case so the `refresh_token` obtained via
114
- # the CIMD flow remains usable.
150
+ # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
115
151
  have_cimd_url = !@provider.client_id_metadata_document_url.nil?
116
152
 
117
153
  unless have_stored_client_info || have_cimd_url
@@ -484,6 +520,18 @@ module MCP
484
520
  Array(grant_types).include?("refresh_token")
485
521
  end
486
522
 
523
+ # The OAuth flow the provider drives. Dispatching on the provider's
524
+ # declared flow keeps `Flow` from second-guessing intent by parsing
525
+ # `client_metadata[:grant_types]` (which is protocol metadata for the
526
+ # authorization server, not an SDK control signal). A provider that
527
+ # predates this method is treated as the interactive authorization-code
528
+ # flow it was the only option for.
529
+ def provider_authorization_flow
530
+ return :authorization_code unless @provider.respond_to?(:authorization_flow)
531
+
532
+ @provider.authorization_flow
533
+ end
534
+
487
535
  def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
488
536
  authorization_endpoint = as_metadata["authorization_endpoint"]
489
537
  unless authorization_endpoint
@@ -3,9 +3,11 @@
3
3
  module MCP
4
4
  class Client
5
5
  module OAuth
6
- # Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
7
- # the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
8
- # and httpx.Auth-based provider in the Python SDK.
6
+ # Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
7
+ # handed to `MCP::Client::HTTP` via the `oauth:` keyword.
8
+ # Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
9
+ # in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
10
+ # use `ClientCredentialsProvider` instead.
9
11
  #
10
12
  # Required keyword arguments:
11
13
  # - `client_metadata` - Hash sent to the authorization server's Dynamic Client
@@ -36,6 +38,8 @@ module MCP
36
38
  # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
37
39
  # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
38
40
  class Provider
41
+ include StorageBackedProvider
42
+
39
43
  # Raised when `Provider#initialize` is called with a `redirect_uri` that
40
44
  # is neither HTTPS nor a loopback `http://` URL, per the MCP
41
45
  # authorization spec's Communication Security requirement.
@@ -102,28 +106,11 @@ module MCP
102
106
  @client_id_metadata_document_url = client_id_metadata_document_url
103
107
  end
104
108
 
105
- def access_token
106
- tokens&.dig("access_token") || tokens&.dig(:access_token)
107
- end
108
-
109
- def tokens
110
- @storage.tokens
111
- end
112
-
113
- def save_tokens(tokens)
114
- @storage.save_tokens(tokens)
115
- end
116
-
117
- def client_information
118
- @storage.client_information
119
- end
120
-
121
- def save_client_information(info)
122
- @storage.save_client_information(info)
123
- end
124
-
125
- def clear_tokens!
126
- @storage.save_tokens(nil)
109
+ # Identifies the OAuth flow this provider drives.
110
+ # `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
111
+ # which is protocol metadata for the authorization server, not an SDK control signal.
112
+ def authorization_flow
113
+ :authorization_code
127
114
  end
128
115
  end
129
116
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Shared token/credential persistence for the OAuth provider classes
7
+ # (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
8
+ # for the client_credentials flow). The two grants differ in how they authenticate,
9
+ # but both read and write the same two pieces of state through a `storage` object:
10
+ # the token response and the client information. This module supplies that delegation
11
+ # so the `Flow` orchestrator can treat any provider uniformly.
12
+ #
13
+ # Including classes must set `@storage` to an object responding to `tokens`,
14
+ # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
15
+ # (see `InMemoryStorage`).
16
+ module StorageBackedProvider
17
+ def access_token
18
+ tokens&.dig("access_token") || tokens&.dig(:access_token)
19
+ end
20
+
21
+ def tokens
22
+ @storage.tokens
23
+ end
24
+
25
+ def save_tokens(tokens)
26
+ @storage.save_tokens(tokens)
27
+ end
28
+
29
+ def client_information
30
+ @storage.client_information
31
+ end
32
+
33
+ def save_client_information(info)
34
+ @storage.save_client_information(info)
35
+ end
36
+
37
+ def clear_tokens!
38
+ @storage.save_tokens(nil)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,13 +4,15 @@ require_relative "oauth/discovery"
4
4
  require_relative "oauth/flow"
5
5
  require_relative "oauth/in_memory_storage"
6
6
  require_relative "oauth/pkce"
7
+ require_relative "oauth/storage_backed_provider"
7
8
  require_relative "oauth/provider"
9
+ require_relative "oauth/client_credentials_provider"
8
10
 
9
11
  module MCP
10
12
  class Client
11
13
  # OAuth client support for the MCP Authorization spec (PRM discovery,
12
14
  # Authorization Server metadata discovery, Dynamic Client Registration,
13
- # OAuth 2.1 Authorization Code + PKCE).
15
+ # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
14
16
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
15
17
  module OAuth
16
18
  end
data/lib/mcp/client.rb CHANGED
@@ -103,6 +103,8 @@ module MCP
103
103
  # Returns a single page of tools from the server.
104
104
  #
105
105
  # @param cursor [String, nil] Cursor from a previous page response.
106
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
107
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
106
108
  # @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
107
109
  # and `next_cursor` (String or nil).
108
110
  #
@@ -114,9 +116,9 @@ module MCP
114
116
  # cursor = page.next_cursor
115
117
  # break unless cursor
116
118
  # end
117
- def list_tools(cursor: nil)
119
+ def list_tools(cursor: nil, meta: nil)
118
120
  params = cursor ? { cursor: cursor } : nil
119
- response = request(method: "tools/list", params: params)
121
+ response = request(method: "tools/list", params: params, meta: meta)
120
122
  result = response["result"] || {}
121
123
 
122
124
  tools = (result["tools"] || []).map do |tool|
@@ -152,11 +154,13 @@ module MCP
152
154
  # Returns a single page of resources from the server.
153
155
  #
154
156
  # @param cursor [String, nil] Cursor from a previous page response.
157
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
158
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
155
159
  # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
156
160
  # and `next_cursor` (String or nil).
157
- def list_resources(cursor: nil)
161
+ def list_resources(cursor: nil, meta: nil)
158
162
  params = cursor ? { cursor: cursor } : nil
159
- response = request(method: "resources/list", params: params)
163
+ response = request(method: "resources/list", params: params, meta: meta)
160
164
  result = response["result"] || {}
161
165
 
162
166
  ListResourcesResult.new(
@@ -181,11 +185,13 @@ module MCP
181
185
  # Returns a single page of resource templates from the server.
182
186
  #
183
187
  # @param cursor [String, nil] Cursor from a previous page response.
188
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
189
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
184
190
  # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
185
191
  # (Array<Hash>) and `next_cursor` (String or nil).
186
- def list_resource_templates(cursor: nil)
192
+ def list_resource_templates(cursor: nil, meta: nil)
187
193
  params = cursor ? { cursor: cursor } : nil
188
- response = request(method: "resources/templates/list", params: params)
194
+ response = request(method: "resources/templates/list", params: params, meta: meta)
189
195
  result = response["result"] || {}
190
196
 
191
197
  ListResourceTemplatesResult.new(
@@ -210,11 +216,13 @@ module MCP
210
216
  # Returns a single page of prompts from the server.
211
217
  #
212
218
  # @param cursor [String, nil] Cursor from a previous page response.
219
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
220
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
213
221
  # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
214
222
  # and `next_cursor` (String or nil).
215
- def list_prompts(cursor: nil)
223
+ def list_prompts(cursor: nil, meta: nil)
216
224
  params = cursor ? { cursor: cursor } : nil
217
- response = request(method: "prompts/list", params: params)
225
+ response = request(method: "prompts/list", params: params, meta: meta)
218
226
  result = response["result"] || {}
219
227
 
220
228
  ListPromptsResult.new(
@@ -242,6 +250,10 @@ module MCP
242
250
  # @param tool [MCP::Client::Tool] The tool to be called.
243
251
  # @param arguments [Object, nil] The arguments to pass to the tool.
244
252
  # @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
253
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
254
+ # e.g. the W3C Trace Context keys reserved by SEP-414
255
+ # (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
256
+ # `progress_token` takes precedence over a `progressToken` entry in `meta`.
245
257
  # @return [Hash] The full JSON-RPC response from the transport.
246
258
  #
247
259
  # @example Call by name
@@ -256,14 +268,17 @@ module MCP
256
268
  # @note
257
269
  # The exact requirements for `arguments` are determined by the transport layer in use.
258
270
  # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
259
- def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
271
+ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
260
272
  tool_name = name || tool&.name
261
273
  raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
262
274
 
263
275
  params = { name: tool_name, arguments: arguments }
276
+ meta_entries = meta ? meta.dup : {}
264
277
  if progress_token
265
- params[:_meta] = { progressToken: progress_token }
278
+ meta_entries.delete("progressToken")
279
+ meta_entries[:progressToken] = progress_token
266
280
  end
281
+ params[:_meta] = meta_entries unless meta_entries.empty?
267
282
 
268
283
  request(method: "tools/call", params: params)
269
284
  end
@@ -271,9 +286,11 @@ module MCP
271
286
  # Reads a resource from the server by URI and returns the contents.
272
287
  #
273
288
  # @param uri [String] The URI of the resource to read.
289
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
290
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
274
291
  # @return [Array<Hash>] An array of resource contents (text or blob).
275
- def read_resource(uri:)
276
- response = request(method: "resources/read", params: { uri: uri })
292
+ def read_resource(uri:, meta: nil)
293
+ response = request(method: "resources/read", params: { uri: uri }, meta: meta)
277
294
 
278
295
  response.dig("result", "contents") || []
279
296
  end
@@ -281,9 +298,11 @@ module MCP
281
298
  # Gets a prompt from the server by name and returns its details.
282
299
  #
283
300
  # @param name [String] The name of the prompt to get.
301
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
302
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
284
303
  # @return [Hash] A hash containing the prompt details.
285
- def get_prompt(name:)
286
- response = request(method: "prompts/get", params: { name: name })
304
+ def get_prompt(name:, meta: nil)
305
+ response = request(method: "prompts/get", params: { name: name }, meta: meta)
287
306
 
288
307
  response.fetch("result", {})
289
308
  end
@@ -294,12 +313,14 @@ module MCP
294
313
  # or `{ type: "ref/resource", uri: "file:///{path}" }`.
295
314
  # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
296
315
  # @param context [Hash, nil] Optional context with previously resolved arguments.
316
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
317
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
297
318
  # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
298
- def complete(ref:, argument:, context: nil)
319
+ def complete(ref:, argument:, context: nil, meta: nil)
299
320
  params = { ref: ref, argument: argument }
300
321
  params[:context] = context if context
301
322
 
302
- response = request(method: "completion/complete", params: params)
323
+ response = request(method: "completion/complete", params: params, meta: meta)
303
324
 
304
325
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
305
326
  end
@@ -315,8 +336,8 @@ module MCP
315
336
  # client.ping # => {}
316
337
  #
317
338
  # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
318
- def ping
319
- result = request(method: Methods::PING)["result"]
339
+ def ping(meta: nil)
340
+ result = request(method: Methods::PING, meta: meta)["result"]
320
341
  raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
321
342
 
322
343
  result
@@ -345,7 +366,13 @@ module MCP
345
366
  pages
346
367
  end
347
368
 
348
- def request(method:, params: nil)
369
+ # Merges caller-supplied `meta` entries into the request params as `_meta`,
370
+ # without mutating the caller's hashes. Per SEP-414, `_meta` carries
371
+ # request-specific metadata such as W3C trace context (`traceparent`,
372
+ # `tracestate`, `baggage`); see {MCP::TraceContext}.
373
+ def request(method:, params: nil, meta: nil)
374
+ params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
375
+
349
376
  request_body = {
350
377
  jsonrpc: JsonRpcHandler::Version::V2_0,
351
378
  id: request_id,
@@ -6,6 +6,7 @@ module MCP
6
6
  SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
7
7
  LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
8
  ]
9
+ DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
9
10
 
10
11
  attr_writer :exception_reporter, :around_request
11
12
 
data/lib/mcp/resource.rb CHANGED
@@ -5,15 +5,16 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
- attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta
8
+ attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
9
9
 
10
- def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil)
10
+ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
11
11
  @uri = uri
12
12
  @name = name
13
13
  @title = title
14
14
  @description = description
15
15
  @icons = icons
16
16
  @mime_type = mime_type
17
+ @annotations = annotations
17
18
  @size = size
18
19
  @meta = meta
19
20
  end
@@ -26,6 +27,7 @@ module MCP
26
27
  description: description,
27
28
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
28
29
  mimeType: mime_type,
30
+ annotations: annotations&.to_h,
29
31
  size: size,
30
32
  _meta: meta,
31
33
  }.compact
@@ -2,15 +2,16 @@
2
2
 
3
3
  module MCP
4
4
  class ResourceTemplate
5
- attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta
5
+ attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
6
6
 
7
- def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
7
+ def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
8
8
  @uri_template = uri_template
9
9
  @name = name
10
10
  @title = title
11
11
  @description = description
12
12
  @icons = icons
13
13
  @mime_type = mime_type
14
+ @annotations = annotations
14
15
  @meta = meta
15
16
  end
16
17
 
@@ -22,6 +23,7 @@ module MCP
22
23
  description: description,
23
24
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
24
25
  mimeType: mime_type,
26
+ annotations: annotations&.to_h,
25
27
  _meta: meta,
26
28
  }.compact
27
29
  end
@@ -389,7 +389,11 @@ module MCP
389
389
  end
390
390
  rescue StandardError => e
391
391
  MCP.configuration.exception_reporter.call(e, { request: body_string })
392
- [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
392
+ json_rpc_error_response(
393
+ status: 500,
394
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
395
+ message: "Internal server error",
396
+ )
393
397
  end
394
398
 
395
399
  def handle_get(request)
@@ -513,19 +517,19 @@ module MCP
513
517
  media_type = content_type&.split(";")&.first&.strip&.downcase
514
518
  return if media_type == "application/json"
515
519
 
516
- [
517
- 415,
518
- { "Content-Type" => "application/json" },
519
- [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
520
- ]
520
+ json_rpc_error_response(
521
+ status: 415,
522
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
523
+ message: "Unsupported Media Type: Content-Type must be application/json",
524
+ )
521
525
  end
522
526
 
523
527
  def not_acceptable_response(required_types)
524
- [
525
- 406,
526
- { "Content-Type" => "application/json" },
527
- [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
528
- ]
528
+ json_rpc_error_response(
529
+ status: 406,
530
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
531
+ message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}",
532
+ )
529
533
  end
530
534
 
531
535
  def parse_request_body(body_string)
@@ -535,7 +539,11 @@ module MCP
535
539
  end
536
540
 
537
541
  def invalid_json_response
538
- [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
542
+ json_rpc_error_response(
543
+ status: 400,
544
+ code: JsonRpcHandler::ErrorCode::PARSE_ERROR,
545
+ message: "Parse error: Invalid JSON",
546
+ )
539
547
  end
540
548
 
541
549
  def initialize_request?(body)
@@ -543,20 +551,20 @@ module MCP
543
551
  end
544
552
 
545
553
  def validate_protocol_version_header(request)
546
- header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
547
- return if header_value.nil?
554
+ header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
548
555
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
549
556
 
550
557
  supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
551
- body = {
552
- jsonrpc: "2.0",
553
- id: nil,
554
- error: {
555
- code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
556
- message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
557
- },
558
- }
559
- [400, { "Content-Type" => "application/json" }, [body.to_json]]
558
+ json_rpc_error_response(
559
+ status: 400,
560
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
561
+ message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
562
+ )
563
+ end
564
+
565
+ def json_rpc_error_response(status:, code:, message:)
566
+ body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
567
+ [status, { "Content-Type" => "application/json" }, [body.to_json]]
560
568
  end
561
569
 
562
570
  def notification?(body)
@@ -793,15 +801,27 @@ module MCP
793
801
  end
794
802
 
795
803
  def method_not_allowed_response
796
- [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
804
+ json_rpc_error_response(
805
+ status: 405,
806
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
807
+ message: "Method not allowed",
808
+ )
797
809
  end
798
810
 
799
811
  def missing_session_id_response
800
- [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
812
+ json_rpc_error_response(
813
+ status: 400,
814
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
815
+ message: "Missing session ID",
816
+ )
801
817
  end
802
818
 
803
819
  def session_not_found_response
804
- [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
820
+ json_rpc_error_response(
821
+ status: 404,
822
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
823
+ message: "Session not found",
824
+ )
805
825
  end
806
826
 
807
827
  def already_initialized_response(request_id)
@@ -821,11 +841,11 @@ module MCP
821
841
  end
822
842
 
823
843
  def session_already_connected_response
824
- [
825
- 409,
826
- { "Content-Type" => "application/json" },
827
- [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
828
- ]
844
+ json_rpc_error_response(
845
+ status: 409,
846
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
847
+ message: "Conflict: Only one SSE stream is allowed per session",
848
+ )
829
849
  end
830
850
 
831
851
  def setup_sse_stream(session_id)
data/lib/mcp/server.rb CHANGED
@@ -58,6 +58,32 @@ module MCP
58
58
  end
59
59
  end
60
60
 
61
+ # Raised when a requested resource URI does not exist. Per SEP-2164,
62
+ # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602)
63
+ # with the requested URI in the error `data` member. Raise this from
64
+ # a `resources_read_handler` block for unknown URIs:
65
+ #
66
+ # server.resources_read_handler do |params|
67
+ # raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless known?(params[:uri])
68
+ # do_something(params[:uri])
69
+ # end
70
+ #
71
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164
72
+ class ResourceNotFoundError < RequestHandlerError
73
+ def initialize(uri, request = nil)
74
+ # The explicit `error_code` keeps the descriptive message in the JSON-RPC
75
+ # error response; `error_type: :invalid_params` alone would replace it
76
+ # with the generic "Invalid params" string.
77
+ super(
78
+ "Resource not found: #{uri}",
79
+ request,
80
+ error_type: :invalid_params,
81
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
82
+ error_data: { uri: uri },
83
+ )
84
+ end
85
+ end
86
+
61
87
  class MethodAlreadyDefinedError < StandardError
62
88
  attr_reader :method_name
63
89
 
@@ -846,7 +872,7 @@ module MCP
846
872
  uri = ref[:uri]
847
873
  found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
848
874
  unless found
849
- raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
875
+ raise ResourceNotFoundError.new(uri, params)
850
876
  end
851
877
  else
852
878
  raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
@@ -12,9 +12,9 @@ module MCP
12
12
  end
13
13
 
14
14
  def missing_required_arguments(arguments)
15
- return [] unless schema[:required].is_a?(Array)
15
+ return [] unless @schema[:required].is_a?(Array)
16
16
 
17
- (schema[:required] - arguments.keys.map(&:to_s))
17
+ (@schema[:required] - arguments.keys.map(&:to_s))
18
18
  end
19
19
 
20
20
  def validate_arguments(arguments)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
- require "json-schema"
4
+ require "json_schemer"
5
5
 
6
6
  module MCP
7
7
  class Tool
@@ -38,11 +38,10 @@ module MCP
38
38
 
39
39
  # JSON Schema 2020-12 is the default dialect for MCP schema definitions
40
40
  # per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
41
- # is still performed against the JSON Schema draft-04 metaschema because
42
- # the `json-schema` gem does not yet support 2020-12.
41
+ # is still performed against the JSON Schema draft-04 metaschema.
43
42
  JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"
44
43
 
45
- attr_reader :schema
44
+ DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#"
46
45
 
47
46
  def initialize(schema = {})
48
47
  @schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
@@ -51,7 +50,7 @@ module MCP
51
50
  end
52
51
 
53
52
  def ==(other)
54
- other.is_a?(self.class) && schema == other.schema
53
+ other.is_a?(self.class) && @schema == other.instance_variable_get(:@schema)
55
54
  end
56
55
 
57
56
  def to_h
@@ -62,8 +61,38 @@ module MCP
62
61
 
63
62
  private
64
63
 
64
+ def stringify(obj)
65
+ case obj
66
+ when Hash
67
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
68
+ when Array
69
+ obj.map { |v| stringify(v) }
70
+ when Symbol
71
+ obj.to_s
72
+ else
73
+ obj
74
+ end
75
+ end
76
+
77
+ # Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost.
78
+ # Memoized per Schema instance because schema content is fixed at construction,
79
+ # so the compiled schemer is reusable across many `fully_validate` calls.
80
+ #
81
+ # `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
82
+ # which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
83
+ # `ArgumentError` so callers see the same exception class they used to.
84
+ def schemer
85
+ @schemer ||= JSONSchemer.schema(
86
+ stringify(schema_for_validation),
87
+ meta_schema: DRAFT4_META_SCHEMA_URI,
88
+ format: false,
89
+ )
90
+ rescue RegexpError => e
91
+ raise ArgumentError, "Invalid JSON Schema: #{e.message}"
92
+ end
93
+
65
94
  def fully_validate(data)
66
- JSON::Validator.fully_validate(schema_for_validation, data)
95
+ schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") }
67
96
  end
68
97
 
69
98
  def validate_schema!
@@ -75,16 +104,7 @@ module MCP
75
104
  key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false))
76
105
  return if VALIDATION_CACHE.validated?(key)
77
106
 
78
- gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
79
- schema_reader = JSON::Schema::Reader.new(
80
- accept_uri: false,
81
- accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
82
- )
83
- metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
84
- # Converts metaschema to a file URI for cross-platform compatibility
85
- metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
86
- metaschema = metaschema_uri.to_s
87
- errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader)
107
+ errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") }
88
108
  if errors.any?
89
109
  raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
90
110
  end
@@ -92,9 +112,8 @@ module MCP
92
112
  VALIDATION_CACHE.store(key)
93
113
  end
94
114
 
95
- # The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`
96
- # dialect URIs. Strip the top-level `$schema` before validation so a dialect URI
97
- # (whether SDK-injected by `to_h` or user-supplied) does not break the validator.
115
+ # `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation:
116
+ # this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas.
98
117
  def schema_for_validation
99
118
  return @schema unless @schema.key?(:"$schema")
100
119
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # Reserved `_meta` keys for W3C Trace Context propagation, per SEP-414.
5
+ #
6
+ # The MCP spec reserves the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage`
7
+ # (an explicit exception to the reverse-DNS prefix rule for `_meta` keys) so that clients and
8
+ # servers can propagate distributed-tracing context across MCP requests.
9
+ # The SDK guarantees these keys pass through incoming request `_meta` untouched; tool, prompt,
10
+ # and resource handlers can read them from `server_context[:_meta]` and bridge them to a tracing
11
+ # system such as the `opentelemetry-ruby` gems. The SDK itself does not depend on OpenTelemetry.
12
+ #
13
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414
14
+ # - https://www.w3.org/TR/trace-context/
15
+ # - https://www.w3.org/TR/baggage/
16
+ module TraceContext
17
+ TRACEPARENT_META_KEY = "traceparent"
18
+ TRACESTATE_META_KEY = "tracestate"
19
+ BAGGAGE_META_KEY = "baggage"
20
+
21
+ META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY].freeze
22
+ end
23
+ end
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.19.0"
4
+ VERSION = "0.20.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -19,6 +19,7 @@ module MCP
19
19
  autoload :Server, "mcp/server"
20
20
  autoload :ServerSession, "mcp/server_session"
21
21
  autoload :Tool, "mcp/tool"
22
+ autoload :TraceContext, "mcp/trace_context"
22
23
 
23
24
  class << self
24
25
  def configure
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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -10,19 +10,19 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: json-schema
13
+ name: json_schemer
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '4.1'
18
+ version: '2.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '4.1'
25
+ version: '2.4'
26
26
  description: The official Ruby SDK for Model Context Protocol servers and clients
27
27
  email:
28
28
  - mcp-support@anthropic.com
@@ -42,11 +42,13 @@ files:
42
42
  - lib/mcp/client.rb
43
43
  - lib/mcp/client/http.rb
44
44
  - lib/mcp/client/oauth.rb
45
+ - lib/mcp/client/oauth/client_credentials_provider.rb
45
46
  - lib/mcp/client/oauth/discovery.rb
46
47
  - lib/mcp/client/oauth/flow.rb
47
48
  - lib/mcp/client/oauth/in_memory_storage.rb
48
49
  - lib/mcp/client/oauth/pkce.rb
49
50
  - lib/mcp/client/oauth/provider.rb
51
+ - lib/mcp/client/oauth/storage_backed_provider.rb
50
52
  - lib/mcp/client/paginated_result.rb
51
53
  - lib/mcp/client/stdio.rb
52
54
  - lib/mcp/client/tool.rb
@@ -80,6 +82,7 @@ files:
80
82
  - lib/mcp/tool/output_schema.rb
81
83
  - lib/mcp/tool/response.rb
82
84
  - lib/mcp/tool/schema.rb
85
+ - lib/mcp/trace_context.rb
83
86
  - lib/mcp/transport.rb
84
87
  - lib/mcp/version.rb
85
88
  homepage: https://ruby.sdk.modelcontextprotocol.io
@@ -87,7 +90,7 @@ licenses:
87
90
  - Apache-2.0
88
91
  metadata:
89
92
  allowed_push_host: https://rubygems.org
90
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.19.0
93
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.20.0
91
94
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
92
95
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
93
96
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues