mcp 1.1.0 → 1.3.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,23 +40,59 @@ 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
34
49
  end
35
50
 
36
- # Registers a `Cancellation` token for an in-flight request.
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
59
+ end
60
+
61
+ # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight.
62
+ # The request id is the only key that routes request-scoped notifications, server-to-client requests,
63
+ # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id
64
+ # has no destination of its own. Rather than let the newcomer displace the registration, report the collision
65
+ # and leave the first request intact; the caller turns that into an Invalid Request.
37
66
  def register_in_flight(request_id)
38
67
  return if request_id.nil?
39
68
 
40
69
  cancellation = Cancellation.new(request_id: request_id)
41
- @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
42
- cancellation
70
+ registered = @in_flight_mutex.synchronize do
71
+ next false if @in_flight.key?(request_id)
72
+
73
+ @in_flight[request_id] = cancellation
74
+ true
75
+ end
76
+
77
+ registered ? cancellation : nil
43
78
  end
44
79
 
45
- def unregister_in_flight(request_id)
80
+ # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes
81
+ # the entry only while it is still that one, so a request can never evict a registration it does not own.
82
+ def unregister_in_flight(request_id, cancellation: nil)
46
83
  return if request_id.nil?
47
84
 
48
- @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
85
+ @in_flight_mutex.synchronize do
86
+ next if cancellation && !@in_flight[request_id].equal?(cancellation)
87
+
88
+ @in_flight.delete(request_id)
89
+ end
90
+ end
91
+
92
+ # Whether `request_id` is currently in flight, so a transport can refuse a colliding request
93
+ # before registering any state of its own for it.
94
+ def in_flight?(request_id)
95
+ !lookup_in_flight(request_id).nil?
49
96
  end
50
97
 
51
98
  def lookup_in_flight(request_id)
@@ -106,19 +153,19 @@ module MCP
106
153
  # `notifications/roots/list_changed`) is deprecated as of MCP protocol
107
154
  # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
108
155
  # server configuration, or environment variables instead.
109
- def list_roots(related_request_id: nil)
156
+ def list_roots(related_request_id: nil, timeout: nil)
110
157
  warn_unassociated_request(__method__, related_request_id)
111
158
 
112
159
  unless client_capabilities&.dig(:roots)
113
160
  raise "Client does not support roots."
114
161
  end
115
162
 
116
- send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
163
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout)
117
164
  end
118
165
 
119
166
  # 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)
167
+ def ping(related_request_id: nil, timeout: nil)
168
+ result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout)
122
169
  raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
123
170
 
124
171
  result
@@ -132,11 +179,16 @@ module MCP
132
179
  # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
133
180
  # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
134
181
  # APIs instead.
135
- def create_sampling_message(related_request_id: nil, **kwargs)
182
+ def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
136
183
  warn_unassociated_request(__method__, related_request_id)
137
184
 
138
185
  params = @server.build_sampling_params(client_capabilities, **kwargs)
139
- send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
186
+ send_to_transport_request(
187
+ Methods::SAMPLING_CREATE_MESSAGE,
188
+ params,
189
+ related_request_id: related_request_id,
190
+ timeout: timeout,
191
+ )
140
192
  end
141
193
 
142
194
  # Sends an `elicitation/create` request (form mode) scoped to this session.
@@ -144,7 +196,7 @@ module MCP
144
196
  # Per SEP-2260, the request must be associated with an originating client
145
197
  # request; prefer `server_context.create_form_elicitation` inside a handler,
146
198
  # which stamps the association automatically.
147
- def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
199
+ def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil)
148
200
  warn_unassociated_request(__method__, related_request_id)
149
201
 
150
202
  unless client_capabilities&.dig(:elicitation)
@@ -153,7 +205,12 @@ module MCP
153
205
  end
154
206
 
155
207
  params = { mode: "form", message: message, requestedSchema: requested_schema }
156
- 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
+ )
157
214
  end
158
215
 
159
216
  # Sends an `elicitation/create` request (URL mode) scoped to this session.
@@ -161,7 +218,7 @@ module MCP
161
218
  # Per SEP-2260, the request must be associated with an originating client
162
219
  # request; prefer `server_context.create_url_elicitation` inside a handler,
163
220
  # which stamps the association automatically.
164
- def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
221
+ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil)
165
222
  warn_unassociated_request(__method__, related_request_id)
166
223
 
167
224
  unless client_capabilities&.dig(:elicitation, :url)
@@ -170,7 +227,21 @@ module MCP
170
227
  end
171
228
 
172
229
  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)
230
+ send_to_transport_request(
231
+ Methods::ELICITATION_CREATE,
232
+ params,
233
+ related_request_id: related_request_id,
234
+ timeout: timeout,
235
+ )
236
+ end
237
+
238
+ # Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire,
239
+ # for the server's dual-era fulfilment shim.
240
+ # The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent
241
+ # standalone request - and stays associated with the originating client request per SEP-2260.
242
+ # Returns the client's result.
243
+ def fulfill_input_request(method, params, related_request_id:)
244
+ send_to_transport_request(method, params, related_request_id: related_request_id)
174
245
  end
175
246
 
176
247
  # Sends `notifications/cancelled` to the peer for a nested server-to-client request
@@ -223,7 +294,15 @@ module MCP
223
294
  # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
224
295
  # Use stderr or OpenTelemetry instead.
225
296
  def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
226
- effective_logging = @logging_message_notification || @server.logging_message_notification
297
+ # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member
298
+ # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
299
+ # does not apply (SEP-2575).
300
+ effective_logging = if @era == :modern
301
+ @logging_message_notification
302
+ else
303
+ @logging_message_notification || @server.logging_message_notification
304
+ end
305
+
227
306
  return unless effective_logging&.should_notify?(level)
228
307
 
229
308
  params = { "data" => data, "level" => level }
@@ -236,6 +315,10 @@ module MCP
236
315
 
237
316
  private
238
317
 
318
+ def validate_era!(era)
319
+ raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era)
320
+ end
321
+
239
322
  # Forwards `send_notification` to the transport with only the kwargs the transport's method signature
240
323
  # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
241
324
  # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
@@ -255,7 +338,7 @@ module MCP
255
338
  # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
256
339
  # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
257
340
  # 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)
341
+ def send_to_transport_request(method, params, related_request_id: nil, timeout: nil)
259
342
  parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
260
343
 
261
344
  kwargs = {
@@ -263,6 +346,7 @@ module MCP
263
346
  related_request_id: related_request_id,
264
347
  parent_cancellation: parent_cancellation,
265
348
  server_session: self,
349
+ timeout: timeout,
266
350
  }.compact
267
351
 
268
352
  forward_to_transport(@transport.method(:send_request), method, params, kwargs)
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.1.0"
4
+ VERSION = "1.3.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,6 +14,7 @@ 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"
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.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -43,7 +43,10 @@ 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
49
+ - lib/mcp/client/oauth/bounded_body.rb
47
50
  - lib/mcp/client/oauth/client_credentials_provider.rb
48
51
  - lib/mcp/client/oauth/cross_app_access_provider.rb
49
52
  - lib/mcp/client/oauth/discovery.rb
@@ -59,6 +62,8 @@ files:
59
62
  - lib/mcp/client/tool.rb
60
63
  - lib/mcp/configuration.rb
61
64
  - lib/mcp/content.rb
65
+ - lib/mcp/elicitation.rb
66
+ - lib/mcp/elicitation/enum_schema.rb
62
67
  - lib/mcp/error_codes.rb
63
68
  - lib/mcp/icon.rb
64
69
  - lib/mcp/instrumentation.rb
@@ -69,6 +74,7 @@ files:
69
74
  - lib/mcp/prompt/argument.rb
70
75
  - lib/mcp/prompt/message.rb
71
76
  - lib/mcp/prompt/result.rb
77
+ - lib/mcp/protocol_deprecations.rb
72
78
  - lib/mcp/request_envelope.rb
73
79
  - lib/mcp/resource.rb
74
80
  - lib/mcp/resource/contents.rb
@@ -77,7 +83,10 @@ files:
77
83
  - lib/mcp/result_type.rb
78
84
  - lib/mcp/server.rb
79
85
  - lib/mcp/server/capabilities.rb
86
+ - lib/mcp/server/input_required_result.rb
80
87
  - lib/mcp/server/pagination.rb
88
+ - lib/mcp/server/pending_response.rb
89
+ - lib/mcp/server/request_state_security.rb
81
90
  - lib/mcp/server/transports.rb
82
91
  - lib/mcp/server/transports/stdio_transport.rb
83
92
  - lib/mcp/server/transports/streamable_http_transport.rb
@@ -98,7 +107,7 @@ licenses:
98
107
  - Apache-2.0
99
108
  metadata:
100
109
  allowed_push_host: https://rubygems.org
101
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.1.0
110
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.3.0
102
111
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
103
112
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
104
113
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues