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).



82
83
84
85
# File 'lib/mcp/server_session.rb', line 82

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.



89
90
91
92
93
94
95
96
97
98
99
# File 'lib/mcp/server_session.rb', line 89

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.



121
122
123
# File 'lib/mcp/server_session.rb', line 121

def client_capabilities
  @client_capabilities || @server.client_capabilities
end

#configure_logging(logging_message_notification) ⇒ Object

Called by Server#configure_logging_level.



116
117
118
# File 'lib/mcp/server_session.rb', line 116

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.



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

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.



160
161
162
163
164
165
166
167
168
169
170
# File 'lib/mcp/server_session.rb', line 160

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.



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_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.



221
222
223
# File 'lib/mcp/server_session.rb', line 221

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



101
102
103
# File 'lib/mcp/server_session.rb', line 101

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

#handle_json(request_json) ⇒ Object



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

def handle_json(request_json)
  @server.handle_json(request_json, session: self)
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.



134
135
136
137
138
139
140
141
142
# File 'lib/mcp/server_session.rb', line 134

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



76
77
78
# File 'lib/mcp/server_session.rb', line 76

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.



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

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.



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/mcp/server_session.rb', line 274

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.



257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/mcp/server_session.rb', line 257

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.



250
251
252
253
254
# File 'lib/mcp/server_session.rb', line 250

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.



145
146
147
148
149
150
# File 'lib/mcp/server_session.rb', line 145

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.



62
63
64
65
66
67
68
# File 'lib/mcp/server_session.rb', line 62

def register_in_flight(request_id)
  return if request_id.nil?

  cancellation = Cancellation.new(request_id: request_id)
  @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
  cancellation
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.



230
231
232
233
234
235
236
237
238
239
240
# File 'lib/mcp/server_session.rb', line 230

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.



110
111
112
113
# File 'lib/mcp/server_session.rb', line 110

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

#unregister_in_flight(request_id) ⇒ Object



70
71
72
73
74
# File 'lib/mcp/server_session.rb', line 70

def unregister_in_flight(request_id)
  return if request_id.nil?

  @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
end