mcp 0.19.0 → 0.21.0

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