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.
@@ -4,12 +4,35 @@ module MCP
4
4
  class ServerContext
5
5
  attr_reader :cancellation
6
6
 
7
- def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
7
+ # The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern;
8
+ # `nil` on legacy requests.
9
+ attr_reader :envelope
10
+
11
+ # SEP-2322 multi round-trip retry fields, present when the client re-issued the request after
12
+ # an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to
13
+ # the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly.
14
+ # Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them
15
+ # (the same access model as the envelope readers).
16
+ attr_reader :input_responses, :request_state
17
+
18
+ def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil,
19
+ input_responses: nil, request_state: nil)
8
20
  @context = context
9
21
  @progress = progress
10
22
  @notification_target = notification_target
11
23
  @related_request_id = related_request_id
12
24
  @cancellation = cancellation
25
+ @envelope = envelope
26
+ @input_responses = input_responses
27
+ @request_state = request_state
28
+ end
29
+
30
+ # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys.
31
+ def input_response(key)
32
+ return unless @input_responses.is_a?(Hash)
33
+
34
+ value = @input_responses[key.to_sym]
35
+ value.nil? ? @input_responses[key.to_s] : value
13
36
  end
14
37
 
15
38
  def cancelled?
@@ -20,6 +43,52 @@ module MCP
20
43
  @cancellation&.raise_if_cancelled!
21
44
  end
22
45
 
46
+ # Whether the current request follows the stateless modern lifecycle (SEP-2575).
47
+ def modern?
48
+ !@envelope.nil?
49
+ end
50
+
51
+ # Client identity for the current request. Modern requests carry it in the `_meta` envelope;
52
+ # legacy sessions fall back to the state stored by `initialize`. The envelope always wins
53
+ # because servers MUST NOT infer identity from prior requests.
54
+ def client_info
55
+ return @envelope.client_info if @envelope
56
+
57
+ @notification_target.client if @notification_target.respond_to?(:client)
58
+ end
59
+
60
+ # Client capabilities for the current request, with the same envelope-first resolution as {#client_info}.
61
+ def client_capabilities
62
+ return @envelope.client_capabilities if @envelope
63
+
64
+ @notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities)
65
+ end
66
+
67
+ # The protocol version the current request was made with. `nil` on legacy requests,
68
+ # where the version is a session-level negotiation result rather than per-request data.
69
+ def protocol_version
70
+ @envelope&.protocol_version
71
+ end
72
+
73
+ # Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys,
74
+ # e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError`
75
+ # (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared.
76
+ def require_client_capability!(*path)
77
+ raise ArgumentError, "at least one capability key is required" if path.empty?
78
+
79
+ declared = client_capabilities
80
+ value = path.reduce(declared) do |acc, key|
81
+ break unless acc.is_a?(Hash)
82
+
83
+ symbol_value = acc[key.to_sym]
84
+ symbol_value.nil? ? acc[key.to_s] : symbol_value
85
+ end
86
+ return unless value.nil?
87
+
88
+ required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } }
89
+ raise Server::MissingRequiredClientCapabilityError, required
90
+ end
91
+
23
92
  # Reports progress for the current tool operation.
24
93
  # The notification is automatically scoped to the originating session.
25
94
  #
@@ -41,6 +110,14 @@ module MCP
41
110
  def notify_log_message(data:, level:, logger: nil)
42
111
  return unless @notification_target
43
112
 
113
+ # Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`,
114
+ # the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops
115
+ # the message the same way. Session- or server-level gating still applies downstream on delegation.
116
+ if @envelope
117
+ threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level)
118
+ return unless threshold&.valid_level? && threshold.should_notify?(level)
119
+ end
120
+
44
121
  @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
45
122
  end
46
123
 
@@ -61,9 +138,9 @@ module MCP
61
138
  # `notifications/roots/list_changed`) is deprecated as of MCP protocol
62
139
  # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
63
140
  # server configuration, or environment variables instead.
64
- def list_roots
141
+ def list_roots(timeout: nil)
65
142
  if @notification_target.respond_to?(:list_roots)
66
- @notification_target.list_roots(related_request_id: @related_request_id)
143
+ @notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout))
67
144
  else
68
145
  raise NoMethodError, "undefined method 'list_roots' for #{self}"
69
146
  end
@@ -83,9 +160,9 @@ module MCP
83
160
  # end
84
161
  #
85
162
  # @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
86
- def ping
163
+ def ping(timeout: nil)
87
164
  if @notification_target.respond_to?(:ping)
88
- @notification_target.ping(related_request_id: @related_request_id)
165
+ @notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout))
89
166
  else
90
167
  raise NoMethodError, "undefined method 'ping' for #{self}"
91
168
  end
@@ -168,5 +245,15 @@ module MCP
168
245
  def respond_to_missing?(name, include_private = false)
169
246
  @context.respond_to?(name) || super
170
247
  end
248
+
249
+ private
250
+
251
+ # An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had
252
+ # before per-request timeouts existed. A notification target that predates the keyword
253
+ # (a custom object standing in for a session) keeps working until a caller actually asks for a timeout,
254
+ # and the transport applies its own default in that case.
255
+ def timeout_kwarg(timeout)
256
+ timeout.nil? ? {} : { timeout: timeout }
257
+ end
171
258
  end
172
259
  end
@@ -7,15 +7,26 @@ module MCP
7
7
  # Holds per-connection state for a single client session.
8
8
  # Created by the transport layer; delegates request handling to the shared `Server`.
9
9
  class ServerSession
10
- attr_reader :session_id, :client, :logging_message_notification
10
+ ERAS = [:legacy, :modern].freeze
11
+
12
+ attr_reader :session_id, :client, :logging_message_notification, :protocol_version
13
+
14
+ # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds,
15
+ # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions
16
+ # with `era: :modern` up front.
17
+ attr_reader :era
18
+
19
+ def initialize(server:, transport:, session_id: nil, era: nil)
20
+ validate_era!(era) if era
11
21
 
12
- def initialize(server:, transport:, session_id: nil)
13
22
  @server = server
14
23
  @transport = transport
15
24
  @session_id = session_id
25
+ @era = era
16
26
  @client = nil
17
27
  @client_capabilities = nil
18
28
  @logging_message_notification = nil
29
+ @protocol_version = nil
19
30
  @in_flight = {}
20
31
  @in_flight_mutex = Mutex.new
21
32
  @initialized = false
@@ -29,8 +40,22 @@ module MCP
29
40
  # Called by `Server#init` after a successful `initialize` response, so subsequent
30
41
  # `initialize` requests on the same session can be rejected per MCP spec
31
42
  # (the initialization phase MUST be the first interaction).
32
- def mark_initialized!
43
+ def mark_initialized!(protocol_version: nil)
33
44
  @initialized = true
45
+ @protocol_version = protocol_version
46
+ # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575),
47
+ # so it also locks the connection era.
48
+ @era ||= :legacy
49
+ end
50
+
51
+ # One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises,
52
+ # because a connection can never change eras.
53
+ def lock_era!(era)
54
+ validate_era!(era)
55
+ return if @era == era
56
+ raise "Session era already locked to #{@era}" if @era
57
+
58
+ @era = era
34
59
  end
35
60
 
36
61
  # Registers a `Cancellation` token for an in-flight request.
@@ -106,19 +131,19 @@ module MCP
106
131
  # `notifications/roots/list_changed`) is deprecated as of MCP protocol
107
132
  # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
108
133
  # server configuration, or environment variables instead.
109
- def list_roots(related_request_id: nil)
134
+ def list_roots(related_request_id: nil, timeout: nil)
110
135
  warn_unassociated_request(__method__, related_request_id)
111
136
 
112
137
  unless client_capabilities&.dig(:roots)
113
138
  raise "Client does not support roots."
114
139
  end
115
140
 
116
- send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
141
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout)
117
142
  end
118
143
 
119
144
  # Sends a `ping` request scoped to this session.
120
- def ping(related_request_id: nil)
121
- result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
145
+ def ping(related_request_id: nil, timeout: nil)
146
+ result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout)
122
147
  raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
123
148
 
124
149
  result
@@ -132,11 +157,16 @@ module MCP
132
157
  # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
133
158
  # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
134
159
  # APIs instead.
135
- def create_sampling_message(related_request_id: nil, **kwargs)
160
+ def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
136
161
  warn_unassociated_request(__method__, related_request_id)
137
162
 
138
163
  params = @server.build_sampling_params(client_capabilities, **kwargs)
139
- send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
164
+ send_to_transport_request(
165
+ Methods::SAMPLING_CREATE_MESSAGE,
166
+ params,
167
+ related_request_id: related_request_id,
168
+ timeout: timeout,
169
+ )
140
170
  end
141
171
 
142
172
  # Sends an `elicitation/create` request (form mode) scoped to this session.
@@ -144,7 +174,7 @@ module MCP
144
174
  # Per SEP-2260, the request must be associated with an originating client
145
175
  # request; prefer `server_context.create_form_elicitation` inside a handler,
146
176
  # which stamps the association automatically.
147
- def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
177
+ def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil)
148
178
  warn_unassociated_request(__method__, related_request_id)
149
179
 
150
180
  unless client_capabilities&.dig(:elicitation)
@@ -153,7 +183,12 @@ module MCP
153
183
  end
154
184
 
155
185
  params = { mode: "form", message: message, requestedSchema: requested_schema }
156
- send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
186
+ send_to_transport_request(
187
+ Methods::ELICITATION_CREATE,
188
+ params,
189
+ related_request_id: related_request_id,
190
+ timeout: timeout,
191
+ )
157
192
  end
158
193
 
159
194
  # Sends an `elicitation/create` request (URL mode) scoped to this session.
@@ -161,7 +196,7 @@ module MCP
161
196
  # Per SEP-2260, the request must be associated with an originating client
162
197
  # request; prefer `server_context.create_url_elicitation` inside a handler,
163
198
  # which stamps the association automatically.
164
- def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
199
+ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil)
165
200
  warn_unassociated_request(__method__, related_request_id)
166
201
 
167
202
  unless client_capabilities&.dig(:elicitation, :url)
@@ -170,7 +205,21 @@ module MCP
170
205
  end
171
206
 
172
207
  params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
173
- send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
208
+ send_to_transport_request(
209
+ Methods::ELICITATION_CREATE,
210
+ params,
211
+ related_request_id: related_request_id,
212
+ timeout: timeout,
213
+ )
214
+ end
215
+
216
+ # Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire,
217
+ # for the server's dual-era fulfilment shim.
218
+ # The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent
219
+ # standalone request - and stays associated with the originating client request per SEP-2260.
220
+ # Returns the client's result.
221
+ def fulfill_input_request(method, params, related_request_id:)
222
+ send_to_transport_request(method, params, related_request_id: related_request_id)
174
223
  end
175
224
 
176
225
  # Sends `notifications/cancelled` to the peer for a nested server-to-client request
@@ -223,7 +272,15 @@ module MCP
223
272
  # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
224
273
  # Use stderr or OpenTelemetry instead.
225
274
  def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
226
- effective_logging = @logging_message_notification || @server.logging_message_notification
275
+ # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member
276
+ # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
277
+ # does not apply (SEP-2575).
278
+ effective_logging = if @era == :modern
279
+ @logging_message_notification
280
+ else
281
+ @logging_message_notification || @server.logging_message_notification
282
+ end
283
+
227
284
  return unless effective_logging&.should_notify?(level)
228
285
 
229
286
  params = { "data" => data, "level" => level }
@@ -236,6 +293,10 @@ module MCP
236
293
 
237
294
  private
238
295
 
296
+ def validate_era!(era)
297
+ raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era)
298
+ end
299
+
239
300
  # Forwards `send_notification` to the transport with only the kwargs the transport's method signature
240
301
  # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
241
302
  # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
@@ -255,7 +316,7 @@ module MCP
255
316
  # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
256
317
  # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
257
318
  # so that cancelling the parent also cancels this nested server-to-client request.
258
- def send_to_transport_request(method, params, related_request_id: nil)
319
+ def send_to_transport_request(method, params, related_request_id: nil, timeout: nil)
259
320
  parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
260
321
 
261
322
  kwargs = {
@@ -263,6 +324,7 @@ module MCP
263
324
  related_request_id: related_request_id,
264
325
  parent_cancellation: parent_cancellation,
265
326
  server_session: self,
327
+ timeout: timeout,
266
328
  }.compact
267
329
 
268
330
  forward_to_transport(@transport.method(:send_request), method, params, kwargs)
@@ -7,18 +7,24 @@ module MCP
7
7
 
8
8
  attr_reader :content, :structured_content, :meta
9
9
 
10
- def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil)
10
+ def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil)
11
11
  if deprecated_error != NOT_GIVEN
12
12
  warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1)
13
13
  error = deprecated_error
14
14
  end
15
15
 
16
- @content = content || []
16
+ content_given = !content.equal?(NOT_GIVEN)
17
+ @content_provided = content_given && !content.nil?
18
+ @content = content_given ? (content || []) : []
17
19
  @error = error
18
20
  @structured_content = structured_content
19
21
  @meta = meta
20
22
  end
21
23
 
24
+ def content_provided?
25
+ @content_provided
26
+ end
27
+
22
28
  def error?
23
29
  !!@error
24
30
  end
data/lib/mcp/transport.rb CHANGED
@@ -50,6 +50,13 @@ module MCP
50
50
  raise NotImplementedError, "Subclasses must implement send_request"
51
51
  end
52
52
 
53
+ # Whether the transport serves the `subscriptions/listen` notification stream (MCP 2026-07-28, SEP-2575).
54
+ # `Server#discover` strips the `listChanged`/`subscribe` capability flags when the transport cannot deliver
55
+ # those notifications in the modern lifecycle.
56
+ def serves_subscriptions_listen?
57
+ false
58
+ end
59
+
53
60
  private
54
61
 
55
62
  def generate_request_id
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "json_rpc_handler"
4
4
  require_relative "mcp/configuration"
5
+ require_relative "mcp/protocol_deprecations"
5
6
  require_relative "mcp/string_utils"
6
7
  require_relative "mcp/transport"
7
8
  require_relative "mcp/version"
@@ -13,9 +14,11 @@ module MCP
13
14
  autoload :CancelledError, "mcp/cancelled_error"
14
15
  autoload :Client, "mcp/client"
15
16
  autoload :Content, "mcp/content"
17
+ autoload :Elicitation, "mcp/elicitation"
16
18
  autoload :ErrorCodes, "mcp/error_codes"
17
19
  autoload :Icon, "mcp/icon"
18
20
  autoload :Prompt, "mcp/prompt"
21
+ autoload :RequestEnvelope, "mcp/request_envelope"
19
22
  autoload :Resource, "mcp/resource"
20
23
  autoload :ResourceTemplate, "mcp/resource_template"
21
24
  autoload :ResultType, "mcp/result_type"
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: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -43,6 +43,8 @@ files:
43
43
  - lib/mcp/client.rb
44
44
  - lib/mcp/client/elicitation.rb
45
45
  - lib/mcp/client/http.rb
46
+ - lib/mcp/client/mcp_param_headers.rb
47
+ - lib/mcp/client/modern_envelope.rb
46
48
  - lib/mcp/client/oauth.rb
47
49
  - lib/mcp/client/oauth/client_credentials_provider.rb
48
50
  - lib/mcp/client/oauth/cross_app_access_provider.rb
@@ -59,6 +61,8 @@ files:
59
61
  - lib/mcp/client/tool.rb
60
62
  - lib/mcp/configuration.rb
61
63
  - lib/mcp/content.rb
64
+ - lib/mcp/elicitation.rb
65
+ - lib/mcp/elicitation/enum_schema.rb
62
66
  - lib/mcp/error_codes.rb
63
67
  - lib/mcp/icon.rb
64
68
  - lib/mcp/instrumentation.rb
@@ -69,6 +73,8 @@ files:
69
73
  - lib/mcp/prompt/argument.rb
70
74
  - lib/mcp/prompt/message.rb
71
75
  - lib/mcp/prompt/result.rb
76
+ - lib/mcp/protocol_deprecations.rb
77
+ - lib/mcp/request_envelope.rb
72
78
  - lib/mcp/resource.rb
73
79
  - lib/mcp/resource/contents.rb
74
80
  - lib/mcp/resource/embedded.rb
@@ -76,7 +82,10 @@ files:
76
82
  - lib/mcp/result_type.rb
77
83
  - lib/mcp/server.rb
78
84
  - lib/mcp/server/capabilities.rb
85
+ - lib/mcp/server/input_required_result.rb
79
86
  - lib/mcp/server/pagination.rb
87
+ - lib/mcp/server/pending_response.rb
88
+ - lib/mcp/server/request_state_security.rb
80
89
  - lib/mcp/server/transports.rb
81
90
  - lib/mcp/server/transports/stdio_transport.rb
82
91
  - lib/mcp/server/transports/streamable_http_transport.rb
@@ -97,7 +106,7 @@ licenses:
97
106
  - Apache-2.0
98
107
  metadata:
99
108
  allowed_push_host: https://rubygems.org
100
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.0.0
109
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.2.0
101
110
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
102
111
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
103
112
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues