mcp 0.18.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.
@@ -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
@@ -25,7 +27,19 @@ module MCP
25
27
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
26
28
  # `client_information`, and `save_client_information(info)`. Defaults to
27
29
  # an `InMemoryStorage`.
30
+ # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
31
+ # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
32
+ # When the authorization server advertises `client_id_metadata_document_supported: true`,
33
+ # the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
34
+ # Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
35
+ # The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
36
+ # but different encodings of the same query would yield different `client_id` strings for the same document).
37
+ # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
38
+ # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
39
+ # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
28
40
  class Provider
41
+ include StorageBackedProvider
42
+
29
43
  # Raised when `Provider#initialize` is called with a `redirect_uri` that
30
44
  # is neither HTTPS nor a loopback `http://` URL, per the MCP
31
45
  # authorization spec's Communication Security requirement.
@@ -38,12 +52,21 @@ module MCP
38
52
  # runtime; failing at construction surfaces the bug earlier.
39
53
  class UnregisteredRedirectURIError < ArgumentError; end
40
54
 
55
+ # Raised when `client_id_metadata_document_url` is provided but does not meet
56
+ # the structural requirements for a Client ID Metadata Document URL:
57
+ # HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
58
+ # The CIMD URL is sent to the authorization server as the OAuth `client_id`,
59
+ # so the same Communication Security guarantee that protects the redirect URI
60
+ # applies and the value must unambiguously identify the document.
61
+ class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
62
+
41
63
  attr_reader :client_metadata,
42
64
  :redirect_uri,
43
65
  :scope,
44
66
  :storage,
45
67
  :redirect_handler,
46
- :callback_handler
68
+ :callback_handler,
69
+ :client_id_metadata_document_url
47
70
 
48
71
  def initialize(
49
72
  client_metadata:,
@@ -51,7 +74,8 @@ module MCP
51
74
  redirect_handler:,
52
75
  callback_handler:,
53
76
  scope: nil,
54
- storage: nil
77
+ storage: nil,
78
+ client_id_metadata_document_url: nil
55
79
  )
56
80
  unless Discovery.secure_url?(redirect_uri)
57
81
  raise InsecureRedirectURIError,
@@ -66,36 +90,27 @@ module MCP
66
90
  "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
67
91
  end
68
92
 
93
+ if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
94
+ raise InvalidClientIDMetadataDocumentURLError,
95
+ "client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
96
+ "with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
97
+ "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
98
+ end
99
+
69
100
  @client_metadata = client_metadata
70
101
  @redirect_uri = redirect_uri
71
102
  @redirect_handler = redirect_handler
72
103
  @callback_handler = callback_handler
73
104
  @scope = scope
74
105
  @storage = storage || InMemoryStorage.new
106
+ @client_id_metadata_document_url = client_id_metadata_document_url
75
107
  end
76
108
 
77
- def access_token
78
- tokens&.dig("access_token") || tokens&.dig(:access_token)
79
- end
80
-
81
- def tokens
82
- @storage.tokens
83
- end
84
-
85
- def save_tokens(tokens)
86
- @storage.save_tokens(tokens)
87
- end
88
-
89
- def client_information
90
- @storage.client_information
91
- end
92
-
93
- def save_client_information(info)
94
- @storage.save_client_information(info)
95
- end
96
-
97
- def clear_tokens!
98
- @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
99
114
  end
100
115
  end
101
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|
@@ -146,31 +148,19 @@ module MCP
146
148
  # end
147
149
  def tools
148
150
  # TODO: consider renaming to `list_all_tools`.
149
- all_tools = []
150
- seen = Set.new
151
- cursor = nil
152
-
153
- loop do
154
- page = list_tools(cursor: cursor)
155
- all_tools.concat(page.tools)
156
- next_cursor = page.next_cursor
157
- break if next_cursor.nil? || seen.include?(next_cursor)
158
-
159
- seen << next_cursor
160
- cursor = next_cursor
161
- end
162
-
163
- all_tools
151
+ fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
164
152
  end
165
153
 
166
154
  # Returns a single page of resources from the server.
167
155
  #
168
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}).
169
159
  # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
170
160
  # and `next_cursor` (String or nil).
171
- def list_resources(cursor: nil)
161
+ def list_resources(cursor: nil, meta: nil)
172
162
  params = cursor ? { cursor: cursor } : nil
173
- response = request(method: "resources/list", params: params)
163
+ response = request(method: "resources/list", params: params, meta: meta)
174
164
  result = response["result"] || {}
175
165
 
176
166
  ListResourcesResult.new(
@@ -189,31 +179,19 @@ module MCP
189
179
  # @return [Array<Hash>] An array of available resources.
190
180
  def resources
191
181
  # TODO: consider renaming to `list_all_resources`.
192
- all_resources = []
193
- seen = Set.new
194
- cursor = nil
195
-
196
- loop do
197
- page = list_resources(cursor: cursor)
198
- all_resources.concat(page.resources)
199
- next_cursor = page.next_cursor
200
- break if next_cursor.nil? || seen.include?(next_cursor)
201
-
202
- seen << next_cursor
203
- cursor = next_cursor
204
- end
205
-
206
- all_resources
182
+ fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
207
183
  end
208
184
 
209
185
  # Returns a single page of resource templates from the server.
210
186
  #
211
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}).
212
190
  # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
213
191
  # (Array<Hash>) and `next_cursor` (String or nil).
214
- def list_resource_templates(cursor: nil)
192
+ def list_resource_templates(cursor: nil, meta: nil)
215
193
  params = cursor ? { cursor: cursor } : nil
216
- response = request(method: "resources/templates/list", params: params)
194
+ response = request(method: "resources/templates/list", params: params, meta: meta)
217
195
  result = response["result"] || {}
218
196
 
219
197
  ListResourceTemplatesResult.new(
@@ -232,31 +210,19 @@ module MCP
232
210
  # @return [Array<Hash>] An array of available resource templates.
233
211
  def resource_templates
234
212
  # TODO: consider renaming to `list_all_resource_templates`.
235
- all_templates = []
236
- seen = Set.new
237
- cursor = nil
238
-
239
- loop do
240
- page = list_resource_templates(cursor: cursor)
241
- all_templates.concat(page.resource_templates)
242
- next_cursor = page.next_cursor
243
- break if next_cursor.nil? || seen.include?(next_cursor)
244
-
245
- seen << next_cursor
246
- cursor = next_cursor
247
- end
248
-
249
- all_templates
213
+ fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
250
214
  end
251
215
 
252
216
  # Returns a single page of prompts from the server.
253
217
  #
254
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}).
255
221
  # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
256
222
  # and `next_cursor` (String or nil).
257
- def list_prompts(cursor: nil)
223
+ def list_prompts(cursor: nil, meta: nil)
258
224
  params = cursor ? { cursor: cursor } : nil
259
- response = request(method: "prompts/list", params: params)
225
+ response = request(method: "prompts/list", params: params, meta: meta)
260
226
  result = response["result"] || {}
261
227
 
262
228
  ListPromptsResult.new(
@@ -275,21 +241,7 @@ module MCP
275
241
  # @return [Array<Hash>] An array of available prompts.
276
242
  def prompts
277
243
  # TODO: consider renaming to `list_all_prompts`.
278
- all_prompts = []
279
- seen = Set.new
280
- cursor = nil
281
-
282
- loop do
283
- page = list_prompts(cursor: cursor)
284
- all_prompts.concat(page.prompts)
285
- next_cursor = page.next_cursor
286
- break if next_cursor.nil? || seen.include?(next_cursor)
287
-
288
- seen << next_cursor
289
- cursor = next_cursor
290
- end
291
-
292
- all_prompts
244
+ fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
293
245
  end
294
246
 
295
247
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -298,6 +250,10 @@ module MCP
298
250
  # @param tool [MCP::Client::Tool] The tool to be called.
299
251
  # @param arguments [Object, nil] The arguments to pass to the tool.
300
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`.
301
257
  # @return [Hash] The full JSON-RPC response from the transport.
302
258
  #
303
259
  # @example Call by name
@@ -312,14 +268,17 @@ module MCP
312
268
  # @note
313
269
  # The exact requirements for `arguments` are determined by the transport layer in use.
314
270
  # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
315
- 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)
316
272
  tool_name = name || tool&.name
317
273
  raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
318
274
 
319
275
  params = { name: tool_name, arguments: arguments }
276
+ meta_entries = meta ? meta.dup : {}
320
277
  if progress_token
321
- params[:_meta] = { progressToken: progress_token }
278
+ meta_entries.delete("progressToken")
279
+ meta_entries[:progressToken] = progress_token
322
280
  end
281
+ params[:_meta] = meta_entries unless meta_entries.empty?
323
282
 
324
283
  request(method: "tools/call", params: params)
325
284
  end
@@ -327,9 +286,11 @@ module MCP
327
286
  # Reads a resource from the server by URI and returns the contents.
328
287
  #
329
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}).
330
291
  # @return [Array<Hash>] An array of resource contents (text or blob).
331
- def read_resource(uri:)
332
- 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)
333
294
 
334
295
  response.dig("result", "contents") || []
335
296
  end
@@ -337,9 +298,11 @@ module MCP
337
298
  # Gets a prompt from the server by name and returns its details.
338
299
  #
339
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}).
340
303
  # @return [Hash] A hash containing the prompt details.
341
- def get_prompt(name:)
342
- 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)
343
306
 
344
307
  response.fetch("result", {})
345
308
  end
@@ -350,12 +313,14 @@ module MCP
350
313
  # or `{ type: "ref/resource", uri: "file:///{path}" }`.
351
314
  # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
352
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}).
353
318
  # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
354
- def complete(ref:, argument:, context: nil)
319
+ def complete(ref:, argument:, context: nil, meta: nil)
355
320
  params = { ref: ref, argument: argument }
356
321
  params[:context] = context if context
357
322
 
358
- response = request(method: "completion/complete", params: params)
323
+ response = request(method: "completion/complete", params: params, meta: meta)
359
324
 
360
325
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
361
326
  end
@@ -371,8 +336,8 @@ module MCP
371
336
  # client.ping # => {}
372
337
  #
373
338
  # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
374
- def ping
375
- result = request(method: Methods::PING)["result"]
339
+ def ping(meta: nil)
340
+ result = request(method: Methods::PING, meta: meta)["result"]
376
341
  raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
377
342
 
378
343
  result
@@ -380,7 +345,34 @@ module MCP
380
345
 
381
346
  private
382
347
 
383
- def request(method:, params: nil)
348
+ # Walks every page of a list endpoint, following `next_cursor`, and returns
349
+ # the page results. The `seen` set guards against a server that repeats or
350
+ # cycles cursors, so the loop always terminates.
351
+ def fetch_all_pages
352
+ pages = []
353
+ seen = Set.new
354
+ cursor = nil
355
+
356
+ loop do
357
+ page = yield(cursor)
358
+ pages << page
359
+ next_cursor = page.next_cursor
360
+ break if next_cursor.nil? || seen.include?(next_cursor)
361
+
362
+ seen << next_cursor
363
+ cursor = next_cursor
364
+ end
365
+
366
+ pages
367
+ end
368
+
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
+
384
376
  request_body = {
385
377
  jsonrpc: JsonRpcHandler::Version::V2_0,
386
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,17 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
- attr_reader :uri, :name, :title, :description, :icons, :mime_type, :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, 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
18
+ @size = size
17
19
  @meta = meta
18
20
  end
19
21
 
@@ -25,6 +27,8 @@ module MCP
25
27
  description: description,
26
28
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
27
29
  mimeType: mime_type,
30
+ annotations: annotations&.to_h,
31
+ size: size,
28
32
  _meta: meta,
29
33
  }.compact
30
34
  end
@@ -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)