mcp 1.0.0 → 1.2.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.
@@ -2,12 +2,49 @@
2
2
 
3
3
  module MCP
4
4
  class Configuration
5
- LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25"
5
+ LATEST_STABLE_PROTOCOL_VERSION = "2026-07-28"
6
+ ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION = "2026-07-28"
6
7
  SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
7
- LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
+ LATEST_STABLE_PROTOCOL_VERSION, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05",
8
9
  ].freeze
9
10
  DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
10
11
 
12
+ # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575),
13
+ # where each request carries its own version in `_meta` and is validated against this list independently,
14
+ # with no handshake. These are reachable only through `server/discover` and the per-request envelope.
15
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
16
+ LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
17
+ SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze
18
+
19
+ # Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition
20
+ # (handshake = stable minus modern) cannot drift when a new revision lands.
21
+ # Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish
22
+ # a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request
23
+ # in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version:
24
+ # a client asking `initialize` for one is counter-offered
25
+ # `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs.
26
+ SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze
27
+ LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first
28
+
29
+ class << self
30
+ def modern_protocol_version?(version)
31
+ SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
32
+ end
33
+
34
+ def handshake_protocol_version?(version)
35
+ SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version)
36
+ end
37
+
38
+ # The one statement of the client-side handshake contract, shared by every transport:
39
+ # a modern version cannot ride the legacy `initialize` handshake.
40
+ def reject_modern_handshake_version!(version)
41
+ return unless version && modern_protocol_version?(version)
42
+
43
+ raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \
44
+ "`initialize` handshake; use `mode: :modern` (or `:auto`) instead"
45
+ end
46
+ end
47
+
11
48
  attr_writer :exception_reporter, :around_request
12
49
 
13
50
  # @deprecated Use {#around_request=} instead. `instrumentation_callback`
@@ -17,7 +54,7 @@ module MCP
17
54
  attr_writer :instrumentation_callback
18
55
 
19
56
  def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
20
- validate_tool_call_arguments: true, validate_tool_call_results: false)
57
+ validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false)
21
58
  @exception_reporter = exception_reporter
22
59
  @around_request = around_request
23
60
  @instrumentation_callback = instrumentation_callback
@@ -27,9 +64,11 @@ module MCP
27
64
  end
28
65
  validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
29
66
  validate_value_of_validate_tool_call_results!(validate_tool_call_results)
67
+ validate_value_of_instrument_server_context!(instrument_server_context)
30
68
 
31
69
  @validate_tool_call_arguments = validate_tool_call_arguments
32
70
  @validate_tool_call_results = validate_tool_call_results
71
+ @instrument_server_context = instrument_server_context
33
72
  end
34
73
 
35
74
  def protocol_version=(protocol_version)
@@ -44,14 +83,28 @@ module MCP
44
83
  @validate_tool_call_arguments = validate_tool_call_arguments
45
84
  end
46
85
 
86
+ # Opt in to exposing the user-defined `server_context` in the
87
+ # `around_request` / `instrumentation_callback` data hash. Off by default:
88
+ # the hash is application-supplied and may hold values a tracing backend
89
+ # should not receive, so surfacing it has to be a deliberate choice.
90
+ def instrument_server_context=(instrument_server_context)
91
+ validate_value_of_instrument_server_context!(instrument_server_context)
92
+
93
+ @instrument_server_context = instrument_server_context
94
+ end
95
+
47
96
  def validate_tool_call_results=(validate_tool_call_results)
48
97
  validate_value_of_validate_tool_call_results!(validate_tool_call_results)
49
98
 
50
99
  @validate_tool_call_results = validate_tool_call_results
51
100
  end
52
101
 
102
+ # The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake
103
+ # settles on by default. Reading the newest version of any era here would hand back a value
104
+ # the writer rejects, and no caller wants the modern revision: a modern connection carries
105
+ # its version on every request instead of consulting configuration.
53
106
  def protocol_version
54
- @protocol_version || LATEST_STABLE_PROTOCOL_VERSION
107
+ @protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION
55
108
  end
56
109
 
57
110
  def protocol_version?
@@ -91,6 +144,10 @@ module MCP
91
144
  attr_reader :validate_tool_call_arguments
92
145
  attr_reader :validate_tool_call_results
93
146
 
147
+ def instrument_server_context?
148
+ !!@instrument_server_context
149
+ end
150
+
94
151
  def validate_tool_call_arguments?
95
152
  !!@validate_tool_call_arguments
96
153
  end
@@ -136,16 +193,30 @@ module MCP
136
193
  protocol_version: protocol_version,
137
194
  validate_tool_call_arguments: validate_tool_call_arguments,
138
195
  validate_tool_call_results: validate_tool_call_results,
196
+ instrument_server_context: other.instrument_server_context?,
139
197
  )
140
198
  end
141
199
 
142
200
  private
143
201
 
202
+ # A pin scopes the `initialize` handshake, so only handshake versions are accepted:
203
+ # a modern version has no handshake to pin (its version rides every request in `_meta`),
204
+ # and accepting one here would configure nothing. Failing at construction beats
205
+ # a setting that silently does not apply.
144
206
  def validate_protocol_version!(protocol_version)
145
- unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
146
- message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}"
147
- raise ArgumentError, message
207
+ return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version)
208
+
209
+ # A version this SDK serves, rejected only for where it was set, deserves the reason and
210
+ # the alternative rather than a list it is missing from: this is the error an upgrade from
211
+ # a release that accepted the value lands on, so it doubles as the migration note.
212
+ if self.class.modern_protocol_version?(protocol_version)
213
+ raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \
214
+ "the pin scopes the `initialize` handshake, which never negotiates a modern version. " \
215
+ "Modern clients carry their version on every request and need no pin; remove the setting."
148
216
  end
217
+
218
+ raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \
219
+ "or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}"
149
220
  end
150
221
 
151
222
  def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
@@ -160,6 +231,12 @@ module MCP
160
231
  end
161
232
  end
162
233
 
234
+ def validate_value_of_instrument_server_context!(instrument_server_context)
235
+ unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass)
236
+ raise ArgumentError, "instrument_server_context must be a boolean"
237
+ end
238
+ end
239
+
163
240
  def default_exception_reporter
164
241
  @default_exception_reporter ||= ->(exception, server_context) {}
165
242
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ module Elicitation
5
+ # Builds the four enum schema variants defined by MCP 2025-11-25 (SEP-1330) plus the legacy `enumNames` form
6
+ # retained for backward compatibility.
7
+ #
8
+ # Each class method returns an `EnumSchema` instance; call `to_h` to get the property-value Hash and
9
+ # pass it through `requested_schema` to `ServerSession#create_form_elicitation`.
10
+ class EnumSchema
11
+ class << self
12
+ # Single-select with plain string values: `{ type: "string", enum: [...] }`.
13
+ def untitled_single_select(values:, default: nil, title: nil, description: nil)
14
+ validate_values!(values)
15
+
16
+ new(
17
+ type: "string",
18
+ extras: { enum: values },
19
+ default: default,
20
+ title: title,
21
+ description: description,
22
+ )
23
+ end
24
+
25
+ # Single-select with display titles per option: `{ type: "string", oneOf: [{ const, title }, ...] }`.
26
+ # `options` is an Array of `{ value:, title: }` hashes.
27
+ def titled_single_select(options:, default: nil, title: nil, description: nil)
28
+ validate_titled_options!(options)
29
+
30
+ new(
31
+ type: "string",
32
+ extras: { oneOf: options.map { |o| { const: o[:value], title: o[:title] } } },
33
+ default: default,
34
+ title: title,
35
+ description: description,
36
+ )
37
+ end
38
+
39
+ # Multi-select with plain string values: `{ type: "array", items: { type: "string", enum: [...] } }`.
40
+ def untitled_multi_select(values:, default: nil, title: nil, description: nil)
41
+ validate_values!(values)
42
+
43
+ new(
44
+ type: "array",
45
+ extras: { items: { type: "string", enum: values } },
46
+ default: default,
47
+ title: title,
48
+ description: description,
49
+ )
50
+ end
51
+
52
+ # Multi-select with display titles per option: `{ type: "array", items: { anyOf: [{ const, title }, ...] } }`.
53
+ def titled_multi_select(options:, default: nil, title: nil, description: nil)
54
+ validate_titled_options!(options)
55
+
56
+ items_anyof = options.map { |o| { const: o[:value], title: o[:title] } }
57
+ new(
58
+ type: "array",
59
+ extras: { items: { anyOf: items_anyof } },
60
+ default: default,
61
+ title: title,
62
+ description: description,
63
+ )
64
+ end
65
+
66
+ # Legacy single-select retained for backward compatibility with clients implementing
67
+ # the pre-SEP-1330 form: `{ enum, enumNames }`.
68
+ def legacy_titled(values:, value_titles:, default: nil, title: nil, description: nil)
69
+ validate_values!(values)
70
+ unless value_titles.is_a?(Array) && value_titles.length == values.length
71
+ raise ArgumentError, "value_titles must be an Array of the same length as values"
72
+ end
73
+
74
+ new(
75
+ type: "string",
76
+ extras: { enum: values, enumNames: value_titles },
77
+ default: default,
78
+ title: title,
79
+ description: description,
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ def validate_values!(values)
86
+ unless values.is_a?(Array) && !values.empty?
87
+ raise ArgumentError, "values must be a non-empty Array"
88
+ end
89
+ end
90
+
91
+ def validate_titled_options!(options)
92
+ unless options.is_a?(Array) && !options.empty?
93
+ raise ArgumentError, "options must be a non-empty Array of {value:, title:} hashes"
94
+ end
95
+
96
+ options.each do |option|
97
+ unless option.is_a?(Hash) && option.key?(:value) && option.key?(:title)
98
+ raise ArgumentError, "each option must be a Hash with :value and :title keys"
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def initialize(type:, extras:, default: nil, title: nil, description: nil)
105
+ @type = type
106
+ @extras = extras
107
+ @default = default
108
+ @title = title
109
+ @description = description
110
+ end
111
+
112
+ def to_h
113
+ hash = { type: @type }.merge(@extras)
114
+ hash[:title] = @title if @title
115
+ hash[:description] = @description if @description
116
+ hash[:default] = @default unless @default.nil?
117
+ hash
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # Builders for elicitation `requestedSchema` definitions per MCP 2025-11-25.
5
+ # Each builder returns an instance whose `to_h` produces the JSON-Schema-shaped Hash
6
+ # a server passes as a property value in `create_form_elicitation(requested_schema:)`.
7
+ module Elicitation
8
+ autoload :EnumSchema, "mcp/elicitation/enum_schema"
9
+ end
10
+ end
@@ -3,18 +3,24 @@
3
3
  module MCP
4
4
  # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`.
5
5
  #
6
- # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
7
- # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not
8
- # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY`
9
- # rejects a request that requires a client capability the request did not declare
10
- # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise
11
- # these codes itself yet.
6
+ # All three constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
12
7
  #
13
- # The values come from the spec's MCP-specific error code block, which is allocated sequentially from
14
- # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here.
8
+ # - `HEADER_MISMATCH` rejects an HTTP request whose headers do not match the corresponding body values,
9
+ # or whose required headers are missing or malformed (no `error.data`). It is reserved for
10
+ # the Streamable HTTP transport, since headers do not exist on stdio.
11
+ # - `MISSING_REQUIRED_CLIENT_CAPABILITY` rejects a request that requires a client capability
12
+ # the request did not declare (`error.data: { requiredCapabilities: {...} }`).
13
+ # Raised via `Server::MissingRequiredClientCapabilityError`.
14
+ # - `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server
15
+ # does not support (`error.data: { supported: [...], requested: "..." }`). Raised via
16
+ # `Server::UnsupportedProtocolVersionError`.
17
+ #
18
+ # The values come from the spec's MCP-specific error code block, which is allocated sequentially from `-32020`
19
+ # toward `-32099`.
15
20
  #
16
21
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
17
22
  module ErrorCodes
23
+ HEADER_MISMATCH = -32020
18
24
  MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
19
25
  UNSUPPORTED_PROTOCOL_VERSION = -32022
20
26
  end
@@ -7,6 +7,12 @@ module MCP
7
7
  begin
8
8
  @instrumentation_data = {}
9
9
  add_instrumentation_data(method: method)
10
+ # `self.` is required: the `server_context:` keyword above shadows the
11
+ # reader, and the value we want here is the user-defined hash passed to
12
+ # `Server.new`, not the per-call reporter context.
13
+ if configuration.instrument_server_context? && respond_to?(:server_context)
14
+ add_instrumentation_data(server_context: self.server_context)
15
+ end
10
16
 
11
17
  result = configuration.around_request.call(@instrumentation_data, &block)
12
18
 
data/lib/mcp/methods.rb CHANGED
@@ -7,6 +7,10 @@ module MCP
7
7
  LOGGING_SET_LEVEL = "logging/setLevel"
8
8
  # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
9
9
  SERVER_DISCOVER = "server/discover"
10
+ # Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575),
11
+ # replacing the legacy HTTP GET listening stream. Served at the transport layer
12
+ # (Streamable HTTP modern path); transports without streaming support answer `-32601`.
13
+ SUBSCRIPTIONS_LISTEN = "subscriptions/listen"
10
14
 
11
15
  PROMPTS_GET = "prompts/get"
12
16
  PROMPTS_LIST = "prompts/list"
@@ -21,6 +25,20 @@ module MCP
21
25
  TOOLS_CALL = "tools/call"
22
26
  TOOLS_LIST = "tools/list"
23
27
 
28
+ # RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575):
29
+ # `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`,
30
+ # `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource
31
+ # subscription pair by the connectionless model, which leaves nothing to keep alive
32
+ # or subscribe on. A modern-era request naming one of these answers with `-32601`
33
+ # Method not found (HTTP 404 on Streamable HTTP).
34
+ MODERN_REMOVED_METHODS = [
35
+ INITIALIZE,
36
+ PING,
37
+ LOGGING_SET_LEVEL,
38
+ RESOURCES_SUBSCRIBE,
39
+ RESOURCES_UNSUBSCRIBE,
40
+ ].freeze
41
+
24
42
  ROOTS_LIST = "roots/list"
25
43
  SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
26
44
  ELICITATION_CREATE = "elicitation/create"
@@ -36,6 +54,9 @@ module MCP
36
54
  NOTIFICATIONS_PROGRESS = "notifications/progress"
37
55
  NOTIFICATIONS_CANCELLED = "notifications/cancelled"
38
56
  NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"
57
+ # First message on a `subscriptions/listen` stream (SEP-2575): reports the subset
58
+ # of requested notification types the server agreed to honor.
59
+ NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged"
39
60
 
40
61
  class MissingRequiredCapabilityError < StandardError
41
62
  attr_reader :method
data/lib/mcp/prompt.rb CHANGED
@@ -111,8 +111,15 @@ module MCP
111
111
  missing = required_args - args.keys
112
112
  return if missing.empty?
113
113
 
114
+ # The explicit `error_code` maps a missing prompt argument to Invalid Params (-32602) rather
115
+ # than the default Internal Error (-32603); a missing required argument is client input, not a
116
+ # server fault. `error_type: :missing_required_arguments` keeps the descriptive message and
117
+ # instrumentation label. Mirrors `MCP::Server::ResourceNotFoundError`.
114
118
  raise MCP::Server::RequestHandlerError.new(
115
- "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments
119
+ "Missing required arguments: #{missing.join(", ")}",
120
+ nil,
121
+ error_type: :missing_required_arguments,
122
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
116
123
  )
117
124
  end
118
125
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module MCP
6
+ # Warning texts for the features SEP-2577 deprecates at 2026-07-28.
7
+ #
8
+ # Only the client emits these, from the modern connect, where the capabilities it declares are
9
+ # the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on
10
+ # a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach
11
+ # a version these apply to. `LOGGING_MESSAGE` in particular has no caller left
12
+ # (a modern client declares no logging capability, and `notify_log_message` on that wire is
13
+ # the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and
14
+ # callable for embedders, and because deleting it would be a breaking change for no gain.
15
+ module ProtocolDeprecations
16
+ extend self
17
+
18
+ ROOTS_MESSAGE =
19
+ "MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version " \
20
+ "2026-07-28 (SEP-2577). Use tool parameters, resource URIs, server configuration, or environment " \
21
+ "variables instead."
22
+ SAMPLING_MESSAGE =
23
+ "MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version 2026-07-28 (SEP-2577). " \
24
+ "Use direct LLM provider APIs instead."
25
+ LOGGING_MESSAGE =
26
+ "MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version " \
27
+ "2026-07-28 (SEP-2577). Use stderr or OpenTelemetry instead."
28
+
29
+ MESSAGES = {
30
+ roots: ROOTS_MESSAGE,
31
+ sampling: SAMPLING_MESSAGE,
32
+ logging: LOGGING_MESSAGE,
33
+ }.freeze
34
+
35
+ def deprecated_roots_sampling_logging?(protocol_version)
36
+ return false unless protocol_version
37
+
38
+ protocol_version >= Configuration::ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION
39
+ end
40
+
41
+ def warn_for(feature, protocol_version:, uplevel: 1)
42
+ return unless deprecated_roots_sampling_logging?(protocol_version)
43
+
44
+ Kernel.warn(MESSAGES.fetch(feature), uplevel: uplevel)
45
+ end
46
+
47
+ def warn_for_client_capabilities(capabilities, protocol_version:, uplevel: 1)
48
+ return unless deprecated_roots_sampling_logging?(protocol_version)
49
+ return unless capabilities
50
+
51
+ warn_for(:roots, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :roots)
52
+ warn_for(:sampling, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :sampling)
53
+ end
54
+
55
+ private
56
+
57
+ def capability?(capabilities, key)
58
+ capabilities.key?(key) || capabilities.key?(key.to_s)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575).
5
+ # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version
6
+ # and client capabilities through reserved `_meta` keys (plus an optional client identity),
7
+ # and the server validates each request independently. Servers MUST NOT infer capabilities from
8
+ # prior requests, which is why the envelope is a per-request value object rather than session state.
9
+ #
10
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
11
+ class RequestEnvelope
12
+ PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"
13
+ CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"
14
+ CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"
15
+
16
+ # Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle.
17
+ # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format.
18
+ LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"
19
+ # Notification-side reserved key (SEP-2575): correlates a notification delivered on
20
+ # a `subscriptions/listen` stream (and the stream's closing result) with the JSON-RPC id of
21
+ # the `subscriptions/listen` request that opened it. Not part of the request envelope triple.
22
+ SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId"
23
+
24
+ # Result-side counterpart of the request envelope: the server's identity rides in
25
+ # the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was
26
+ # finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it.
27
+ SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
28
+
29
+ # `clientInfo` is deliberately absent: it became optional after the SEP was finalized
30
+ # (spec PR modelcontextprotocol/modelcontextprotocol#3002), so servers MUST accept
31
+ # envelopes without it. The TypeScript and Python SDKs validate the same required pair.
32
+ REQUIRED_META_KEYS = [
33
+ PROTOCOL_VERSION_META_KEY,
34
+ CLIENT_CAPABILITIES_META_KEY,
35
+ ].freeze
36
+
37
+ class << self
38
+ # A request claims the modern lifecycle when its `_meta` carries `io.modelcontextprotocol/protocolVersion`,
39
+ # matching the TypeScript SDK's envelope claim and the Python SDK's `_has_modern_envelope`.
40
+ # Classification is deliberately looser than validation: a claimed-but-malformed envelope is
41
+ # rejected by {parse!} with `-32602` instead of silently flowing through the legacy path,
42
+ # while `_meta` without the claim key (`progressToken`, trace context) stays legacy.
43
+ def modern?(params)
44
+ meta = extract_meta(params)
45
+ return false unless meta.is_a?(Hash)
46
+
47
+ !read(meta, PROTOCOL_VERSION_META_KEY).nil?
48
+ end
49
+
50
+ # Parses and validates the envelope: `protocolVersion` and `clientCapabilities` are required,
51
+ # `clientInfo` is optional. A missing or mistyped field is Invalid params (`-32602`) naming
52
+ # the offending keys, the code and shape the spec mandates and the reference SDKs emit.
53
+ # `request` is only used to enrich the raised error; callers dispatching notifications can omit it.
54
+ def parse!(params, request: nil)
55
+ meta = extract_meta(params)
56
+ meta = {} unless meta.is_a?(Hash)
57
+
58
+ protocol_version = read(meta, PROTOCOL_VERSION_META_KEY)
59
+ client_info = read(meta, CLIENT_INFO_META_KEY)
60
+ client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY)
61
+
62
+ invalid_keys = []
63
+ invalid_keys << PROTOCOL_VERSION_META_KEY unless protocol_version.is_a?(String)
64
+ invalid_keys << CLIENT_CAPABILITIES_META_KEY unless client_capabilities.is_a?(Hash)
65
+ invalid_keys << CLIENT_INFO_META_KEY unless client_info.nil? || client_info.is_a?(Hash)
66
+
67
+ unless invalid_keys.empty?
68
+ raise Server::RequestHandlerError.new(
69
+ "Invalid params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`",
70
+ request,
71
+ error_type: :invalid_params,
72
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
73
+ )
74
+ end
75
+
76
+ unless Configuration.modern_protocol_version?(protocol_version)
77
+ raise Server::UnsupportedProtocolVersionError.new(protocol_version, request)
78
+ end
79
+
80
+ new(
81
+ protocol_version: protocol_version,
82
+ client_info: client_info,
83
+ client_capabilities: client_capabilities,
84
+ log_level: read(meta, LOG_LEVEL_META_KEY),
85
+ )
86
+ end
87
+
88
+ private
89
+
90
+ # `Server#handle` accepts hashes parsed with either symbol or string keys, so read both forms
91
+ # (the same tolerance as `Server#handle_cancelled_notification`).
92
+ def extract_meta(params)
93
+ return unless params.is_a?(Hash)
94
+
95
+ meta = params[:_meta]
96
+ meta.nil? ? params["_meta"] : meta
97
+ end
98
+
99
+ def read(meta, key)
100
+ value = meta[key.to_sym]
101
+ value.nil? ? meta[key] : value
102
+ end
103
+ end
104
+
105
+ # `client_info` is `nil` when the client chose not to identify itself, which is legal:
106
+ # it is self-reported data and MUST NOT drive behavior or security decisions anyway.
107
+ attr_reader :protocol_version, :client_info, :client_capabilities, :log_level
108
+
109
+ def initialize(protocol_version:, client_capabilities:, client_info: nil, log_level: nil)
110
+ @protocol_version = protocol_version
111
+ @client_info = client_info
112
+ @client_capabilities = client_capabilities
113
+ @log_level = log_level
114
+ freeze
115
+ end
116
+ end
117
+ end