Skip to content

Commit 719e5e7

Browse files
committed
Introduce ServerSession for per-connection state
## Motivation and Context The Ruby SDK uses a 1-Server-to-many-sessions model where session-specific state (`@client`, `@logging_message_notification`) is stored on the shared `Server` instance. This causes the last client to connect to overwrite state for all sessions, and requires `session_id` to be threaded through method signatures for session-scoped notifications. The Python SDK avoids this by creating a `ServerSession` per connection that wraps a shared `Server`. Each session holds its own state and naturally scopes notifications through its own stream. ### Changes - Added `MCP::ServerSession` class holding per-session state: client info, logging configuration, and a `transport` reference for session-scoped notification delivery. - `StreamableHTTPTransport#handle_initialization` creates a `ServerSession` per `initialize` request and stores it in the session hash. - `handle_regular_request` routes requests through the session's `ServerSession` instead of the shared `Server` directly. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword (`ServerSession` instance). - `Server#init` stores client info on the session when available. - `Server#configure_logging_level` stores logging config on the session. - `ServerContext` and `Progress` accept a `notification_target:` which can be either a `ServerSession` (session-scoped) or a `Server` (broadcast). No `session_id:` parameter needed. - `ServerContext#notify_progress` and `#notify_log_message` delegate to the `notification_target` without `session_id` threading. - `StdioTransport` creates a single `ServerSession` on `open`, making the session model transparent across both transports. ### Design Decision This follows the Python SDK's "shared `Server` + per-connection `Session`" pattern. The `Server` holds configuration (tools, prompts, resources, handlers). Each `ServerSession` holds per-connection state (client info, logging level, stream writer). Notifications from `ServerSession` go only to that session's stream. ## How Has This Been Tested? All existing tests pass. All conformance tests pass. Added tests verifying: - Session-scoped log notification is sent only to the originating session. - Session-scoped progress notification is sent only to the originating session. - Each session stores its own client info independently. - Each session stores its own logging level independently. - `StdioTransport#open` creates a `ServerSession` and stores client info on it. ## Breaking Change None for end users. The public API (`Server.new`, `define_tool`,`server_context.report_progress`, `server_context.notify_log_message`, etc.) is unchanged. The following internal API changes affect only SDK internals: - `ServerContext.new` now requires `notification_target:` instead of just`progress:`. - `Progress.new` now takes `notification_target:` instead of `server:`. - `Server#handle` and `Server#handle_json` accept an optional `session:` keyword.
1 parent a9e4514 commit 719e5e7

16 files changed

+563
-63
lines changed

AGENTS.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
5656

5757
**MCP::Server** (`lib/mcp/server.rb`):
5858

59-
- Main server class handling JSON-RPC requests
59+
- Main server class handling JSON-RPC requests and holding shared configuration (tools, prompts, resources, handlers, capabilities)
6060
- Implements MCP protocol methods: initialize, ping, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read
6161
- Supports custom method registration via `define_custom_method`
6262
- Handles instrumentation, exception reporting, and notifications
6363
- Uses JsonRpcHandler for request processing
6464

65+
**MCP::ServerSession** (`lib/mcp/server_session.rb`):
66+
67+
- Per-connection state: client info, logging level
68+
- Created by the transport layer for each client connection
69+
- Delegates request handling to the shared `Server`
70+
6571
**MCP::Client** (`lib/mcp/client.rb`):
6672

6773
- Client interface for communicating with MCP servers
@@ -95,6 +101,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
95101
- Protocol version 2025-03-26+ supports tool annotations (destructive_hint, idempotent_hint, etc.)
96102
- Validation is configurable via `configuration.validate_tool_call_arguments`
97103

104+
**Session Architecture**:
105+
106+
- `Server` holds shared configuration (tools, prompts, resources, handlers)
107+
- `ServerSession` holds per-connection state (client info, logging level)
108+
- Both `StdioTransport` and `StreamableHTTPTransport` create a `ServerSession` per connection, making the session model transparent across transports
109+
- Session-scoped notifications (`notify_progress`, `notify_log_message`) are sent only to the originating client via `ServerSession`
110+
- Server-wide notifications (`notify_tools_list_changed`, etc.) broadcast to all sessions via `Server`
111+
98112
**Context Passing**:
99113

100114
- `server_context` hash passed through tool/prompt calls for request-specific data

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ The server provides the following notification methods:
116116
- `notify_progress` - Send a progress notification for long-running operations
117117
- `notify_log_message` - Send a structured logging notification message
118118

119+
#### Session Scoping
120+
121+
When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows:
122+
123+
- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client.
124+
No extra configuration is needed.
125+
- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients,
126+
as they represent server-wide state changes. These should be called on the `server` instance directly.
127+
119128
#### Notification Format
120129

121130
Notifications follow the JSON-RPC 2.0 specification and use these method names:

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module MCP
1515
autoload :Resource, "mcp/resource"
1616
autoload :ResourceTemplate, "mcp/resource_template"
1717
autoload :Server, "mcp/server"
18+
autoload :ServerSession, "mcp/server_session"
1819
autoload :Tool, "mcp/tool"
1920

2021
class << self

lib/mcp/progress.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
module MCP
44
class Progress
5-
def initialize(server:, progress_token:)
6-
@server = server
5+
def initialize(notification_target:, progress_token:)
6+
@notification_target = notification_target
77
@progress_token = progress_token
88
end
99

1010
def report(progress, total: nil, message: nil)
1111
return unless @progress_token
1212

13-
@server.notify_progress(
13+
@notification_target.notify_progress(
1414
progress_token: @progress_token,
1515
progress: progress,
1616
total: total,

lib/mcp/server.rb

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,29 @@ def initialize(
111111
@transport = transport
112112
end
113113

114-
def handle(request)
114+
# Processes a parsed JSON-RPC request and returns the response as a Hash.
115+
#
116+
# @param request [Hash] A parsed JSON-RPC request.
117+
# @param session [ServerSession, nil] Per-connection session. Passed by
118+
# `ServerSession#handle` for session-scoped notification delivery.
119+
# When `nil`, notifications broadcast to all sessions.
120+
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
121+
def handle(request, session: nil)
115122
JsonRpcHandler.handle(request) do |method|
116-
handle_request(request, method)
123+
handle_request(request, method, session: session)
117124
end
118125
end
119126

120-
def handle_json(request)
127+
# Processes a JSON-RPC request string and returns the response as a JSON string.
128+
#
129+
# @param request [String] A JSON-RPC request as a JSON string.
130+
# @param session [ServerSession, nil] Per-connection session. Passed by
131+
# `ServerSession#handle_json` for session-scoped notification delivery.
132+
# When `nil`, notifications broadcast to all sessions.
133+
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
134+
def handle_json(request, session: nil)
121135
JsonRpcHandler.handle_json(request) do |method|
122-
handle_request(request, method)
136+
handle_request(request, method, session: session)
123137
end
124138
end
125139

@@ -279,11 +293,12 @@ def schema_contains_ref?(schema)
279293
end
280294
end
281295

282-
def handle_request(request, method)
296+
def handle_request(request, method, session: nil)
283297
handler = @handlers[method]
284298
unless handler
285299
instrument_call("unsupported_method") do
286-
add_instrumentation_data(client: @client) if @client
300+
client = session&.client || @client
301+
add_instrumentation_data(client: client) if client
287302
end
288303
return
289304
end
@@ -293,6 +308,8 @@ def handle_request(request, method)
293308
->(params) {
294309
instrument_call(method) do
295310
result = case method
311+
when Methods::INITIALIZE
312+
init(params, session: session)
296313
when Methods::TOOLS_LIST
297314
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
298315
when Methods::PROMPTS_LIST
@@ -303,10 +320,15 @@ def handle_request(request, method)
303320
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
304321
when Methods::RESOURCES_TEMPLATES_LIST
305322
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
323+
when Methods::TOOLS_CALL
324+
call_tool(params, session: session)
325+
when Methods::LOGGING_SET_LEVEL
326+
configure_logging_level(params, session: session)
306327
else
307328
@handlers[method].call(params)
308329
end
309-
add_instrumentation_data(client: @client) if @client
330+
client = session&.client || @client
331+
add_instrumentation_data(client: client) if client
310332

311333
result
312334
rescue => e
@@ -342,8 +364,14 @@ def server_info
342364
}.compact
343365
end
344366

345-
def init(params)
346-
@client = params[:clientInfo] if params
367+
def init(params, session: nil)
368+
if params
369+
if session
370+
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
371+
else
372+
@client = params[:clientInfo]
373+
end
374+
end
347375

348376
protocol_version = params[:protocolVersion] if params
349377
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
@@ -371,7 +399,7 @@ def init(params)
371399
}.compact
372400
end
373401

374-
def configure_logging_level(request)
402+
def configure_logging_level(request, session: nil)
375403
if capabilities[:logging].nil?
376404
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
377405
end
@@ -381,6 +409,7 @@ def configure_logging_level(request)
381409
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
382410
end
383411

412+
session&.configure_logging(logging_message_notification)
384413
@logging_message_notification = logging_message_notification
385414

386415
{}
@@ -390,7 +419,7 @@ def list_tools(request)
390419
@tools.values.map(&:to_h)
391420
end
392421

393-
def call_tool(request)
422+
def call_tool(request, session: nil)
394423
tool_name = request[:name]
395424

396425
tool = tools[tool_name]
@@ -422,7 +451,7 @@ def call_tool(request)
422451

423452
progress_token = request.dig(:_meta, :progressToken)
424453

425-
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token)
454+
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
426455
rescue RequestHandlerError
427456
raise
428457
rescue => e
@@ -491,12 +520,13 @@ def accepts_server_context?(method_object)
491520
parameters.any? { |type, name| type == :keyrest || name == :server_context }
492521
end
493522

494-
def call_tool_with_args(tool, arguments, context, progress_token: nil)
523+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
495524
args = arguments&.transform_keys(&:to_sym) || {}
496525

497526
if accepts_server_context?(tool.method(:call))
498-
progress = Progress.new(server: self, progress_token: progress_token)
499-
server_context = ServerContext.new(context, progress: progress)
527+
notification_target = session || self
528+
progress = Progress.new(notification_target: notification_target, progress_token: progress_token)
529+
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
500530
tool.call(**args, server_context: server_context).to_h
501531
else
502532
tool.call(**args).to_h

lib/mcp/server/transports/stdio_transport.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ class StdioTransport < Transport
1010
STATUS_INTERRUPTED = Signal.list["INT"] + 128
1111

1212
def initialize(server)
13-
@server = server
13+
super(server)
1414
@open = false
15+
@session = nil
1516
$stdin.set_encoding("UTF-8")
1617
$stdout.set_encoding("UTF-8")
17-
super
1818
end
1919

2020
def open
2121
@open = true
22+
@session = ServerSession.new(server: @server, transport: self)
2223
while @open && (line = $stdin.gets)
23-
handle_json_request(line.strip)
24+
response = @session.handle_json(line.strip)
25+
send_response(response) if response
2426
end
2527
rescue Interrupt
2628
warn("\nExiting...")

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module Transports
1010
class StreamableHTTPTransport < Transport
1111
def initialize(server, stateless: false)
1212
super(server)
13-
# { session_id => { stream: stream_object }
13+
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`.
1414
@sessions = {}
1515
@mutex = Mutex.new
1616

@@ -228,18 +228,25 @@ def response?(body)
228228

229229
def handle_initialization(body_string, body)
230230
session_id = nil
231+
server_session = nil
231232

232233
unless @stateless
233234
session_id = SecureRandom.uuid
235+
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
234236

235237
@mutex.synchronize do
236238
@sessions[session_id] = {
237239
stream: nil,
240+
server_session: server_session,
238241
}
239242
end
240243
end
241244

242-
response = @server.handle_json(body_string)
245+
response = if server_session
246+
server_session.handle_json(body_string)
247+
else
248+
@server.handle_json(body_string)
249+
end
243250

244251
headers = {
245252
"Content-Type" => "application/json",
@@ -255,16 +262,24 @@ def handle_accepted
255262
end
256263

257264
def handle_regular_request(body_string, session_id)
258-
unless @stateless
259-
if session_id && !session_exists?(session_id)
260-
return session_not_found_response
265+
server_session = nil
266+
stream = nil
267+
268+
if session_id && !@stateless
269+
@mutex.synchronize do
270+
session = @sessions[session_id]
271+
return session_not_found_response unless session
272+
273+
server_session = session[:server_session]
274+
stream = session[:stream]
261275
end
262276
end
263277

264-
response = @server.handle_json(body_string)
265-
266-
# Stream can be nil since stateless mode doesn't retain streams
267-
stream = get_session_stream(session_id) if session_id
278+
response = if server_session
279+
server_session.handle_json(body_string)
280+
else
281+
@server.handle_json(body_string)
282+
end
268283

269284
if stream
270285
send_response_to_stream(stream, response, session_id)

lib/mcp/server_context.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,41 @@
22

33
module MCP
44
class ServerContext
5-
def initialize(context, progress:)
5+
def initialize(context, progress:, notification_target:)
66
@context = context
77
@progress = progress
8+
@notification_target = notification_target
89
end
910

11+
# Reports progress for the current tool operation.
12+
# The notification is automatically scoped to the originating session.
13+
#
14+
# @param progress [Numeric] Current progress value.
15+
# @param total [Numeric, nil] Total expected value.
16+
# @param message [String, nil] Human-readable status message.
1017
def report_progress(progress, total: nil, message: nil)
1118
@progress.report(progress, total: total, message: message)
1219
end
1320

21+
# Sends a progress notification scoped to the originating session.
22+
#
23+
# @param progress_token [String, Integer] The token identifying the operation.
24+
# @param progress [Numeric] Current progress value.
25+
# @param total [Numeric, nil] Total expected value.
26+
# @param message [String, nil] Human-readable status message.
27+
def notify_progress(progress_token:, progress:, total: nil, message: nil)
28+
@notification_target.notify_progress(progress_token: progress_token, progress: progress, total: total, message: message)
29+
end
30+
31+
# Sends a log message notification scoped to the originating session.
32+
#
33+
# @param data [Object] The log data to send.
34+
# @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
35+
# @param logger [String, nil] Logger name.
36+
def notify_log_message(data:, level:, logger: nil)
37+
@notification_target.notify_log_message(data: data, level: level, logger: logger)
38+
end
39+
1440
def method_missing(name, ...)
1541
if @context.respond_to?(name)
1642
@context.public_send(name, ...)

0 commit comments

Comments
 (0)