Class: MCP::ServerSession

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp/server_session.rb

Overview

Holds per-connection state for a single client session. Created by the transport layer; delegates request handling to the shared Server.

Constant Summary collapse

ERAS =
[:legacy, :modern].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(server:, transport:, session_id: nil, era: nil) ⇒ ServerSession

Returns a new instance of ServerSession.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/mcp/server_session.rb', line 19

def initialize(server:, transport:, session_id: nil, era: nil)
  validate_era!(era) if era

  @server = server
  @transport = transport
  @session_id = session_id
  @era = era
  @client = nil
  @client_capabilities = nil
  @logging_message_notification = nil
  @protocol_version = nil
  @in_flight = {}
  @in_flight_mutex = Mutex.new
  @initialized = false
end

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client.



12
13
14
# File 'lib/mcp/server_session.rb', line 12

def client
  @client
end

#eraObject (readonly)

Connection-era lock of the dual-era serving model (SEP-2575): nil until the first era-distinctive message succeeds, then :legacy or :modern for the connection's lifetime. Modern-era transports construct their per-request sessions with era: :modern up front.



17
18
19
# File 'lib/mcp/server_session.rb', line 17

def era
  @era
end

#logging_message_notificationObject (readonly)

Returns the value of attribute logging_message_notification.



12
13
14
# File 'lib/mcp/server_session.rb', line 12

def logging_message_notification
  @logging_message_notification
end

#protocol_versionObject (readonly)

Returns the value of attribute protocol_version.



12
13
14
# File 'lib/mcp/server_session.rb', line 12

def protocol_version
  @protocol_version
end

#session_idObject (readonly)

Returns the value of attribute session_id.



12
13
14
# File 'lib/mcp/server_session.rb', line 12

def session_id
  @session_id
end

Instance Method Details

#cancel_incoming(request_id:, reason: nil) ⇒ Object

Flips the Cancellation for a matching in-flight request received from the peer. Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5).



104
105
106
107
# File 'lib/mcp/server_session.rb', line 104

def cancel_incoming(request_id:, reason: nil)
  cancellation = lookup_in_flight(request_id)
  cancellation&.cancel(reason: reason)
end

#cancel_request(request_id:, reason: nil) ⇒ Object

Sends notifications/cancelled to the peer for a previously-issued request. Also unblocks any transport-level send_request waiting on a response for request_id.



111
112
113
114
115
116
117
118
119
120
121
# File 'lib/mcp/server_session.rb', line 111

def cancel_request(request_id:, reason: nil)
  params = { requestId: request_id }
  params[:reason] = reason if reason
  send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params)

  if @transport.respond_to?(:cancel_pending_request)
    @transport.cancel_pending_request(request_id, reason: reason)
  end
rescue => e
  MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id })
end

#client_capabilitiesObject

Returns per-session client capabilities, falling back to global.



143
144
145
# File 'lib/mcp/server_session.rb', line 143

def client_capabilities
  @client_capabilities || @server.client_capabilities
end

#configure_logging(logging_message_notification) ⇒ Object

Called by Server#configure_logging_level.



138
139
140
# File 'lib/mcp/server_session.rb', line 138

def configure_logging(logging_message_notification)
  @logging_message_notification = logging_message_notification
end

#create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil) ⇒ Object

Sends an elicitation/create request (form mode) scoped to this session.

Per SEP-2260, the request must be associated with an originating client request; prefer server_context.create_form_elicitation inside a handler, which stamps the association automatically.



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/mcp/server_session.rb', line 199

def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil)
  warn_unassociated_request(__method__, related_request_id)

  unless client_capabilities&.dig(:elicitation)
    raise "Client does not support elicitation. " \
      "The client must declare the `elicitation` capability during initialization."
  end

  params = { mode: "form", message: message, requestedSchema: requested_schema }
  send_to_transport_request(
    Methods::ELICITATION_CREATE,
    params,
    related_request_id: related_request_id,
    timeout: timeout,
  )
end

#create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) ⇒ Object

Deprecated.

MCP Sampling (sampling/createMessage) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider APIs instead.

Sends a sampling/createMessage request scoped to this session.

Per SEP-2260, the request must be associated with an originating client request; prefer server_context.create_sampling_message inside a handler, which stamps the association automatically.



182
183
184
185
186
187
188
189
190
191
192
# File 'lib/mcp/server_session.rb', line 182

def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
  warn_unassociated_request(__method__, related_request_id)

  params = @server.build_sampling_params(client_capabilities, **kwargs)
  send_to_transport_request(
    Methods::SAMPLING_CREATE_MESSAGE,
    params,
    related_request_id: related_request_id,
    timeout: timeout,
  )
end

#create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil) ⇒ Object

Sends an elicitation/create request (URL mode) scoped to this session.

Per SEP-2260, the request must be associated with an originating client request; prefer server_context.create_url_elicitation inside a handler, which stamps the association automatically.



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/mcp/server_session.rb', line 221

def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil)
  warn_unassociated_request(__method__, related_request_id)

  unless client_capabilities&.dig(:elicitation, :url)
    raise "Client does not support URL mode elicitation. " \
      "The client must declare the `elicitation.url` capability during initialization."
  end

  params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
  send_to_transport_request(
    Methods::ELICITATION_CREATE,
    params,
    related_request_id: related_request_id,
    timeout: timeout,
  )
end

#fulfill_input_request(method, params, related_request_id:) ⇒ Object

Sends an embedded SEP-2322 inputRequests entry as a real server-to-client request on the legacy wire, for the server's dual-era fulfilment shim. The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent standalone request - and stays associated with the originating client request per SEP-2260. Returns the client's result.



243
244
245
# File 'lib/mcp/server_session.rb', line 243

def fulfill_input_request(method, params, related_request_id:)
  send_to_transport_request(method, params, related_request_id: related_request_id)
end

#handle(request) ⇒ Object



123
124
125
# File 'lib/mcp/server_session.rb', line 123

def handle(request)
  @server.handle(request, session: self)
end

#handle_json(request_json) ⇒ Object



127
128
129
# File 'lib/mcp/server_session.rb', line 127

def handle_json(request_json)
  @server.handle_json(request_json, session: self)
end

#in_flight?(request_id) ⇒ Boolean

Whether request_id is currently in flight, so a transport can refuse a colliding request before registering any state of its own for it.

Returns:

  • (Boolean)


94
95
96
# File 'lib/mcp/server_session.rb', line 94

def in_flight?(request_id)
  !lookup_in_flight(request_id).nil?
end

#initialized?Boolean

Whether initialize has already completed for this session.

Returns:

  • (Boolean)


36
37
38
# File 'lib/mcp/server_session.rb', line 36

def initialized?
  @initialized
end

#list_roots(related_request_id: nil, timeout: nil) ⇒ Object

Deprecated.

MCP Roots (roots/list and notifications/roots/list_changed) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, server configuration, or environment variables instead.

Sends a roots/list request scoped to this session.

Per SEP-2260, the request must be associated with an originating client request; prefer server_context.list_roots inside a handler, which stamps the association automatically.



156
157
158
159
160
161
162
163
164
# File 'lib/mcp/server_session.rb', line 156

def list_roots(related_request_id: nil, timeout: nil)
  warn_unassociated_request(__method__, related_request_id)

  unless client_capabilities&.dig(:roots)
    raise "Client does not support roots."
  end

  send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout)
end

#lock_era!(era) ⇒ Object

One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises, because a connection can never change eras.



53
54
55
56
57
58
59
# File 'lib/mcp/server_session.rb', line 53

def lock_era!(era)
  validate_era!(era)
  return if @era == era
  raise "Session era already locked to #{@era}" if @era

  @era = era
end

#lookup_in_flight(request_id) ⇒ Object



98
99
100
# File 'lib/mcp/server_session.rb', line 98

def lookup_in_flight(request_id)
  @in_flight_mutex.synchronize { @in_flight[request_id] }
end

#mark_initialized!(protocol_version: nil) ⇒ Object

Called by Server#init after a successful initialize response, so subsequent initialize requests on the same session can be rejected per MCP spec (the initialization phase MUST be the first interaction).



43
44
45
46
47
48
49
# File 'lib/mcp/server_session.rb', line 43

def mark_initialized!(protocol_version: nil)
  @initialized = true
  @protocol_version = protocol_version
  # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575),
  # so it also locks the connection era.
  @era ||= :legacy
end

#notify_elicitation_complete(elicitation_id:) ⇒ Object

Sends an elicitation complete notification scoped to this session.



265
266
267
268
269
# File 'lib/mcp/server_session.rb', line 265

def notify_elicitation_complete(elicitation_id:)
  send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
rescue => e
  @server.report_exception(e, notification: "elicitation_complete")
end

#notify_log_message(data:, level:, logger: nil, related_request_id: nil) ⇒ Object

Deprecated.

MCP Logging (logging/setLevel and notifications/message) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). Use stderr or OpenTelemetry instead.

Sends a log message notification to this session only.



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/mcp/server_session.rb', line 296

def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
  # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member
  # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
  # does not apply (SEP-2575).
  effective_logging = if @era == :modern
    @logging_message_notification
  else
    @logging_message_notification || @server.logging_message_notification
  end

  return unless effective_logging&.should_notify?(level)

  params = { "data" => data, "level" => level }
  params["logger"] = logger if logger

  send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
rescue => e
  @server.report_exception(e, { notification: "log_message" })
end

#notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil) ⇒ Object

Sends a progress notification to this session only.



279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/mcp/server_session.rb', line 279

def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
  params = {
    "progressToken" => progress_token,
    "progress" => progress,
    "total" => total,
    "message" => message,
  }.compact

  send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
rescue => e
  @server.report_exception(e, notification: "progress")
end

#notify_resources_updated(uri:) ⇒ Object

Sends a resource updated notification to this session only.



272
273
274
275
276
# File 'lib/mcp/server_session.rb', line 272

def notify_resources_updated(uri:)
  send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
rescue => e
  @server.report_exception(e, notification: "resources_updated")
end

#ping(related_request_id: nil, timeout: nil) ⇒ Object

Sends a ping request scoped to this session.



167
168
169
170
171
172
# File 'lib/mcp/server_session.rb', line 167

def ping(related_request_id: nil, timeout: nil)
  result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout)
  raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)

  result
end

#register_in_flight(request_id) ⇒ Object

Registers a Cancellation token for an in-flight request, or returns nil when request_id is already in flight. The request id is the only key that routes request-scoped notifications, server-to-client requests, and notifications/cancelled back to the request that caused them, so a second live request under the same id has no destination of its own. Rather than let the newcomer displace the registration, report the collision and leave the first request intact; the caller turns that into an Invalid Request.



66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/mcp/server_session.rb', line 66

def register_in_flight(request_id)
  return if request_id.nil?

  cancellation = Cancellation.new(request_id: request_id)
  registered = @in_flight_mutex.synchronize do
    next false if @in_flight.key?(request_id)

    @in_flight[request_id] = cancellation
    true
  end

  registered ? cancellation : nil
end

#send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil) ⇒ Object

Sends notifications/cancelled to the peer for a nested server-to-client request that was started inside a now-cancelled parent request. related_request_id is the parent request id so the notification is routed to the same stream (e.g. the parent's POST response stream on StreamableHTTPTransport) rather than the GET SSE stream.



252
253
254
255
256
257
258
259
260
261
262
# File 'lib/mcp/server_session.rb', line 252

def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil)
  params = { requestId: nested_request_id }
  params[:reason] = reason if reason
  send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id)

  if @transport.respond_to?(:cancel_pending_request)
    @transport.cancel_pending_request(nested_request_id, reason: reason)
  end
rescue => e
  MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id })
end

#store_client_info(client:, capabilities: nil) ⇒ Object

Called by Server#init during the initialization handshake.



132
133
134
135
# File 'lib/mcp/server_session.rb', line 132

def store_client_info(client:, capabilities: nil)
  @client = client
  @client_capabilities = capabilities
end

#unregister_in_flight(request_id, cancellation: nil) ⇒ Object

Removes an in-flight registration. Passing the Cancellation that register_in_flight returned removes the entry only while it is still that one, so a request can never evict a registration it does not own.



82
83
84
85
86
87
88
89
90
# File 'lib/mcp/server_session.rb', line 82

def unregister_in_flight(request_id, cancellation: nil)
  return if request_id.nil?

  @in_flight_mutex.synchronize do
    next if cancellation && !@in_flight[request_id].equal?(cancellation)

    @in_flight.delete(request_id)
  end
end