mcp 0.9.2 → 0.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0f26c1a29af5a799a750d9ad00a224f39d24638a9ad267a540313f06da674ed
4
- data.tar.gz: d340e2c1f6492f74a28c6dabc6a04575285269e13a2352e3f747195d4dac1527
3
+ metadata.gz: 29a39b8c5bb27a2fcdc8d084dce2cd79dfada5981a18d156bc1de78604035b2e
4
+ data.tar.gz: d969675cb0bb08b9ee3971a9bd90891767c7b28d68fe60579cf4857a06ff3680
5
5
  SHA512:
6
- metadata.gz: 7f428407e35305f1cb5a087bd7abb708df12d17abe6cb66f335c8dc2d82d1020407942dd36ad3cb200377e7d60783d87a72bab765b202ae8426cb267f5d3f46a
7
- data.tar.gz: 49d835de35b9c6c124d99ffe82561a5f7554901d43971afbb82adfb31fe13d0d6d1b6782d460d9b861b6924cc43705266cb09e5de3afd9249b7caa68634fff63
6
+ metadata.gz: 3ff992068b54cc35acd43bc9e93edb3959f270a19eedf52540748344f2a94488829cbfec65dedb2772d67dd883f7536523af25d6419ded2cc71944359ff2d016
7
+ data.tar.gz: 0f716e3f54ca10619f95787c65dfe01fecc2356474ddf2909dfb2918f544f3fc8aa2a1a798836b2c556a9178ad842472fd79820ad83e96e855e4ca1ded4f5d8b
data/README.md CHANGED
@@ -113,9 +113,17 @@ The server provides the following notification methods:
113
113
  - `notify_tools_list_changed` - Send a notification when the tools list changes
114
114
  - `notify_prompts_list_changed` - Send a notification when the prompts list changes
115
115
  - `notify_resources_list_changed` - Send a notification when the resources list changes
116
- - `notify_progress` - Send a progress notification for long-running operations
117
116
  - `notify_log_message` - Send a structured logging notification message
118
117
 
118
+ #### Session Scoping
119
+
120
+ When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows:
121
+
122
+ - **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client.
123
+ No extra configuration is needed.
124
+ - **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients,
125
+ as they represent server-wide state changes. These should be called on the `server` instance directly.
126
+
119
127
  #### Notification Format
120
128
 
121
129
  Notifications follow the JSON-RPC 2.0 specification and use these method names:
@@ -169,24 +177,10 @@ The `server_context.report_progress` method accepts:
169
177
  - `total:` (optional) — total expected value, so clients can display a percentage
170
178
  - `message:` (optional) — human-readable status message
171
179
 
172
- #### Server-Side: Direct `notify_progress` Usage
173
-
174
- You can also call `notify_progress` directly on the server instance:
175
-
176
- ```ruby
177
- server.notify_progress(
178
- progress_token: "token-123",
179
- progress: 50,
180
- total: 100, # optional
181
- message: "halfway" # optional
182
- )
183
- ```
184
-
185
180
  **Key Features:**
186
181
 
187
182
  - Tools report progress via `server_context.report_progress`
188
183
  - `report_progress` is a no-op when no `progressToken` was provided by the client
189
- - `notify_progress` is a no-op when no transport is configured
190
184
  - Supports both numeric and string progress tokens
191
185
 
192
186
  ### Logging
@@ -293,6 +287,14 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
293
287
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
294
288
  ```
295
289
 
290
+ By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
291
+ When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
292
+
293
+ ```ruby
294
+ # Session timeout of 30 minutes
295
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
296
+ ```
297
+
296
298
  ### Unsupported Features (to be implemented in future versions)
297
299
 
298
300
  - Resource subscriptions
data/lib/mcp/progress.rb CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  module MCP
4
4
  class Progress
5
- def initialize(server:, progress_token:)
6
- @server = server
5
+ def initialize(notification_target:, progress_token:)
6
+ @notification_target = notification_target
7
7
  @progress_token = progress_token
8
8
  end
9
9
 
10
10
  def report(progress, total: nil, message: nil)
11
11
  return unless @progress_token
12
+ return unless @notification_target
12
13
 
13
- @server.notify_progress(
14
+ @notification_target.notify_progress(
14
15
  progress_token: @progress_token,
15
16
  progress: progress,
16
17
  total: total,
@@ -10,17 +10,19 @@ module MCP
10
10
  STATUS_INTERRUPTED = Signal.list["INT"] + 128
11
11
 
12
12
  def initialize(server)
13
- @server = server
13
+ super(server)
14
14
  @open = false
15
+ @session = nil
15
16
  $stdin.set_encoding("UTF-8")
16
17
  $stdout.set_encoding("UTF-8")
17
- super
18
18
  end
19
19
 
20
20
  def open
21
21
  @open = true
22
+ @session = ServerSession.new(server: @server, transport: self)
22
23
  while @open && (line = $stdin.gets)
23
- handle_json_request(line.strip)
24
+ response = @session.handle_json(line.strip)
25
+ send_response(response) if response
24
26
  end
25
27
  rescue Interrupt
26
28
  warn("\nExiting...")
@@ -8,18 +8,30 @@ module MCP
8
8
  class Server
9
9
  module Transports
10
10
  class StreamableHTTPTransport < Transport
11
- def initialize(server, stateless: false)
11
+ def initialize(server, stateless: false, session_idle_timeout: nil)
12
12
  super(server)
13
- # { session_id => { stream: stream_object }
13
+ # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
14
14
  @sessions = {}
15
15
  @mutex = Mutex.new
16
16
 
17
17
  @stateless = stateless
18
+ @session_idle_timeout = session_idle_timeout
19
+
20
+ if @session_idle_timeout
21
+ if @stateless
22
+ raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
23
+ elsif @session_idle_timeout <= 0
24
+ raise ArgumentError, "session_idle_timeout must be a positive number."
25
+ end
26
+ end
27
+
28
+ start_reaper_thread if @session_idle_timeout
18
29
  end
19
30
 
20
31
  REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
21
32
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
22
33
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
34
+ SESSION_REAP_INTERVAL = 60
23
35
 
24
36
  def handle_request(request)
25
37
  case request.env["REQUEST_METHOD"]
@@ -35,6 +47,9 @@ module MCP
35
47
  end
36
48
 
37
49
  def close
50
+ @reaper_thread&.kill
51
+ @reaper_thread = nil
52
+
38
53
  @mutex.synchronize do
39
54
  @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
40
55
  end
@@ -56,6 +71,11 @@ module MCP
56
71
  session = @sessions[session_id]
57
72
  return false unless session && session[:stream]
58
73
 
74
+ if session_expired?(session)
75
+ cleanup_session_unsafe(session_id)
76
+ return false
77
+ end
78
+
59
79
  begin
60
80
  send_to_stream(session[:stream], notification)
61
81
  true
@@ -75,6 +95,11 @@ module MCP
75
95
  @sessions.each do |sid, session|
76
96
  next unless session[:stream]
77
97
 
98
+ if session_expired?(session)
99
+ failed_sessions << sid
100
+ next
101
+ end
102
+
78
103
  begin
79
104
  send_to_stream(session[:stream], notification)
80
105
  sent_count += 1
@@ -97,6 +122,39 @@ module MCP
97
122
 
98
123
  private
99
124
 
125
+ def start_reaper_thread
126
+ @reaper_thread = Thread.new do
127
+ loop do
128
+ sleep(SESSION_REAP_INTERVAL)
129
+ reap_expired_sessions
130
+ rescue StandardError => e
131
+ MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
132
+ end
133
+ end
134
+ end
135
+
136
+ def reap_expired_sessions
137
+ return unless @session_idle_timeout
138
+
139
+ expired_streams = @mutex.synchronize do
140
+ @sessions.each_with_object([]) do |(session_id, session), streams|
141
+ next unless session_expired?(session)
142
+
143
+ streams << session[:stream] if session[:stream]
144
+ @sessions.delete(session_id)
145
+ end
146
+ end
147
+
148
+ expired_streams.each do |stream|
149
+ # Closing outside the mutex is safe because expired sessions are already
150
+ # removed from `@sessions` above, so other threads will not find them
151
+ # and will not attempt to close the same stream.
152
+ stream.close
153
+ rescue
154
+ nil
155
+ end
156
+ end
157
+
100
158
  def send_to_stream(stream, data)
101
159
  message = data.is_a?(String) ? data : data.to_json
102
160
  stream.write("data: #{message}\n\n")
@@ -120,10 +178,14 @@ module MCP
120
178
 
121
179
  if body["method"] == "initialize"
122
180
  handle_initialization(body_string, body)
123
- elsif notification?(body) || response?(body)
124
- handle_accepted
125
181
  else
126
- handle_regular_request(body_string, session_id)
182
+ return missing_session_id_response if !@stateless && !session_id
183
+
184
+ if notification?(body) || response?(body)
185
+ handle_accepted
186
+ else
187
+ handle_regular_request(body_string, session_id)
188
+ end
127
189
  end
128
190
  rescue StandardError => e
129
191
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -141,7 +203,9 @@ module MCP
141
203
  session_id = extract_session_id(request)
142
204
 
143
205
  return missing_session_id_response unless session_id
144
- return session_not_found_response unless session_exists?(session_id)
206
+
207
+ error_response = validate_and_touch_session(session_id)
208
+ return error_response if error_response
145
209
  return session_already_connected_response if get_session_stream(session_id)
146
210
 
147
211
  setup_sse_stream(session_id)
@@ -228,18 +292,26 @@ module MCP
228
292
 
229
293
  def handle_initialization(body_string, body)
230
294
  session_id = nil
295
+ server_session = nil
231
296
 
232
297
  unless @stateless
233
298
  session_id = SecureRandom.uuid
299
+ server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
234
300
 
235
301
  @mutex.synchronize do
236
302
  @sessions[session_id] = {
237
303
  stream: nil,
304
+ server_session: server_session,
305
+ last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
238
306
  }
239
307
  end
240
308
  end
241
309
 
242
- response = @server.handle_json(body_string)
310
+ response = if server_session
311
+ server_session.handle_json(body_string)
312
+ else
313
+ @server.handle_json(body_string)
314
+ end
243
315
 
244
316
  headers = {
245
317
  "Content-Type" => "application/json",
@@ -255,16 +327,27 @@ module MCP
255
327
  end
256
328
 
257
329
  def handle_regular_request(body_string, session_id)
330
+ server_session = nil
331
+ stream = nil
332
+
258
333
  unless @stateless
259
- if session_id && !session_exists?(session_id)
260
- return session_not_found_response
334
+ if session_id
335
+ error_response = validate_and_touch_session(session_id)
336
+ return error_response if error_response
337
+
338
+ @mutex.synchronize do
339
+ session = @sessions[session_id]
340
+ server_session = session[:server_session] if session
341
+ stream = session[:stream] if session
342
+ end
261
343
  end
262
344
  end
263
345
 
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
346
+ response = if server_session
347
+ server_session.handle_json(body_string)
348
+ else
349
+ @server.handle_json(body_string)
350
+ end
268
351
 
269
352
  if stream
270
353
  send_response_to_stream(stream, response, session_id)
@@ -273,6 +356,22 @@ module MCP
273
356
  end
274
357
  end
275
358
 
359
+ def validate_and_touch_session(session_id)
360
+ @mutex.synchronize do
361
+ return session_not_found_response unless (session = @sessions[session_id])
362
+ return unless @session_idle_timeout
363
+
364
+ if session_expired?(session)
365
+ cleanup_session_unsafe(session_id)
366
+ return session_not_found_response
367
+ end
368
+
369
+ session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
370
+ end
371
+
372
+ nil
373
+ end
374
+
276
375
  def get_session_stream(session_id)
277
376
  @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
278
377
  end
@@ -378,6 +477,12 @@ module MCP
378
477
  )
379
478
  raise # Re-raise to exit the keepalive loop
380
479
  end
480
+
481
+ def session_expired?(session)
482
+ return false unless @session_idle_timeout
483
+
484
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
485
+ end
381
486
  end
382
487
  end
383
488
  end
data/lib/mcp/server.rb CHANGED
@@ -111,15 +111,29 @@ module MCP
111
111
  @transport = transport
112
112
  end
113
113
 
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`, progress and logging notifications from tool handlers are silently skipped.
120
+ # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
121
+ def handle(request, session: nil)
115
122
  JsonRpcHandler.handle(request) do |method|
116
- handle_request(request, method)
123
+ handle_request(request, method, session: session)
117
124
  end
118
125
  end
119
126
 
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`, progress and logging notifications from tool handlers are silently skipped.
133
+ # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
134
+ def handle_json(request, session: nil)
121
135
  JsonRpcHandler.handle_json(request) do |method|
122
- handle_request(request, method)
136
+ handle_request(request, method, session: session)
123
137
  end
124
138
  end
125
139
 
@@ -172,21 +186,6 @@ module MCP
172
186
  report_exception(e, { notification: "resources_list_changed" })
173
187
  end
174
188
 
175
- def notify_progress(progress_token:, progress:, total: nil, message: nil)
176
- return unless @transport
177
-
178
- params = {
179
- "progressToken" => progress_token,
180
- "progress" => progress,
181
- "total" => total,
182
- "message" => message,
183
- }.compact
184
-
185
- @transport.send_notification(Methods::NOTIFICATIONS_PROGRESS, params)
186
- rescue => e
187
- report_exception(e, notification: "progress")
188
- end
189
-
190
189
  def notify_log_message(data:, level:, logger: nil)
191
190
  return unless @transport
192
191
  return unless logging_message_notification&.should_notify?(level)
@@ -199,34 +198,16 @@ module MCP
199
198
  report_exception(e, { notification: "log_message" })
200
199
  end
201
200
 
202
- def resources_list_handler(&block)
203
- @handlers[Methods::RESOURCES_LIST] = block
204
- end
205
-
201
+ # Sets a custom handler for `resources/read` requests.
202
+ # The block receives the parsed request params and should return resource
203
+ # contents. The return value is set as the `contents` field of the response.
204
+ #
205
+ # @yield [params] The request params containing `:uri`.
206
+ # @yieldreturn [Array<Hash>, Hash] Resource contents.
206
207
  def resources_read_handler(&block)
207
208
  @handlers[Methods::RESOURCES_READ] = block
208
209
  end
209
210
 
210
- def resources_templates_list_handler(&block)
211
- @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
212
- end
213
-
214
- def tools_list_handler(&block)
215
- @handlers[Methods::TOOLS_LIST] = block
216
- end
217
-
218
- def tools_call_handler(&block)
219
- @handlers[Methods::TOOLS_CALL] = block
220
- end
221
-
222
- def prompts_list_handler(&block)
223
- @handlers[Methods::PROMPTS_LIST] = block
224
- end
225
-
226
- def prompts_get_handler(&block)
227
- @handlers[Methods::PROMPTS_GET] = block
228
- end
229
-
230
211
  private
231
212
 
232
213
  def validate!
@@ -297,11 +278,12 @@ module MCP
297
278
  end
298
279
  end
299
280
 
300
- def handle_request(request, method)
281
+ def handle_request(request, method, session: nil)
301
282
  handler = @handlers[method]
302
283
  unless handler
303
284
  instrument_call("unsupported_method") do
304
- add_instrumentation_data(client: @client) if @client
285
+ client = session&.client || @client
286
+ add_instrumentation_data(client: client) if client
305
287
  end
306
288
  return
307
289
  end
@@ -311,6 +293,8 @@ module MCP
311
293
  ->(params) {
312
294
  instrument_call(method) do
313
295
  result = case method
296
+ when Methods::INITIALIZE
297
+ init(params, session: session)
314
298
  when Methods::TOOLS_LIST
315
299
  { tools: @handlers[Methods::TOOLS_LIST].call(params) }
316
300
  when Methods::PROMPTS_LIST
@@ -321,10 +305,15 @@ module MCP
321
305
  { contents: @handlers[Methods::RESOURCES_READ].call(params) }
322
306
  when Methods::RESOURCES_TEMPLATES_LIST
323
307
  { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
308
+ when Methods::TOOLS_CALL
309
+ call_tool(params, session: session)
310
+ when Methods::LOGGING_SET_LEVEL
311
+ configure_logging_level(params, session: session)
324
312
  else
325
313
  @handlers[method].call(params)
326
314
  end
327
- add_instrumentation_data(client: @client) if @client
315
+ client = session&.client || @client
316
+ add_instrumentation_data(client: client) if client
328
317
 
329
318
  result
330
319
  rescue => e
@@ -360,8 +349,14 @@ module MCP
360
349
  }.compact
361
350
  end
362
351
 
363
- def init(params)
364
- @client = params[:clientInfo] if params
352
+ def init(params, session: nil)
353
+ if params
354
+ if session
355
+ session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
356
+ else
357
+ @client = params[:clientInfo]
358
+ end
359
+ end
365
360
 
366
361
  protocol_version = params[:protocolVersion] if params
367
362
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
@@ -389,7 +384,7 @@ module MCP
389
384
  }.compact
390
385
  end
391
386
 
392
- def configure_logging_level(request)
387
+ def configure_logging_level(request, session: nil)
393
388
  if capabilities[:logging].nil?
394
389
  raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
395
390
  end
@@ -399,6 +394,7 @@ module MCP
399
394
  raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
400
395
  end
401
396
 
397
+ session&.configure_logging(logging_message_notification)
402
398
  @logging_message_notification = logging_message_notification
403
399
 
404
400
  {}
@@ -408,7 +404,7 @@ module MCP
408
404
  @tools.values.map(&:to_h)
409
405
  end
410
406
 
411
- def call_tool(request)
407
+ def call_tool(request, session: nil)
412
408
  tool_name = request[:name]
413
409
 
414
410
  tool = tools[tool_name]
@@ -440,7 +436,7 @@ module MCP
440
436
 
441
437
  progress_token = request.dig(:_meta, :progressToken)
442
438
 
443
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token)
439
+ call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
444
440
  rescue RequestHandlerError
445
441
  raise
446
442
  rescue => e
@@ -509,12 +505,12 @@ module MCP
509
505
  parameters.any? { |type, name| type == :keyrest || name == :server_context }
510
506
  end
511
507
 
512
- def call_tool_with_args(tool, arguments, context, progress_token: nil)
508
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
513
509
  args = arguments&.transform_keys(&:to_sym) || {}
514
510
 
515
511
  if accepts_server_context?(tool.method(:call))
516
- progress = Progress.new(server: self, progress_token: progress_token)
517
- server_context = ServerContext.new(context, progress: progress)
512
+ progress = Progress.new(notification_target: session, progress_token: progress_token)
513
+ server_context = ServerContext.new(context, progress: progress, notification_target: session)
518
514
  tool.call(**args, server_context: server_context).to_h
519
515
  else
520
516
  tool.call(**args).to_h
@@ -2,15 +2,33 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:)
5
+ def initialize(context, progress:, notification_target:)
6
6
  @context = context
7
7
  @progress = progress
8
+ @notification_target = notification_target
8
9
  end
9
10
 
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.
10
17
  def report_progress(progress, total: nil, message: nil)
11
18
  @progress.report(progress, total: total, message: message)
12
19
  end
13
20
 
21
+ # Sends a log message notification scoped to the originating session.
22
+ #
23
+ # @param data [Object] The log data to send.
24
+ # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
25
+ # @param logger [String, nil] Logger name.
26
+ def notify_log_message(data:, level:, logger: nil)
27
+ return unless @notification_target
28
+
29
+ @notification_target.notify_log_message(data: data, level: level, logger: logger)
30
+ end
31
+
14
32
  def method_missing(name, ...)
15
33
  if @context.respond_to?(name)
16
34
  @context.public_send(name, ...)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "methods"
4
+
5
+ module MCP
6
+ # Holds per-connection state for a single client session.
7
+ # Created by the transport layer; delegates request handling to the shared `Server`.
8
+ class ServerSession
9
+ attr_reader :session_id, :client, :logging_message_notification
10
+
11
+ def initialize(server:, transport:, session_id: nil)
12
+ @server = server
13
+ @transport = transport
14
+ @session_id = session_id
15
+ @client = nil
16
+ @client_capabilities = nil # TODO: Use for per-session capability validation.
17
+ @logging_message_notification = nil
18
+ end
19
+
20
+ def handle(request)
21
+ @server.handle(request, session: self)
22
+ end
23
+
24
+ def handle_json(request_json)
25
+ @server.handle_json(request_json, session: self)
26
+ end
27
+
28
+ # Called by `Server#init` during the initialization handshake.
29
+ def store_client_info(client:, capabilities: nil)
30
+ @client = client
31
+ @client_capabilities = capabilities
32
+ end
33
+
34
+ # Called by `Server#configure_logging_level`.
35
+ def configure_logging(logging_message_notification)
36
+ @logging_message_notification = logging_message_notification
37
+ end
38
+
39
+ # Sends a progress notification to this session only.
40
+ def notify_progress(progress_token:, progress:, total: nil, message: nil)
41
+ params = {
42
+ "progressToken" => progress_token,
43
+ "progress" => progress,
44
+ "total" => total,
45
+ "message" => message,
46
+ }.compact
47
+
48
+ send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params)
49
+ rescue => e
50
+ @server.report_exception(e, notification: "progress")
51
+ end
52
+
53
+ # Sends a log message notification to this session only.
54
+ def notify_log_message(data:, level:, logger: nil)
55
+ effective_logging = @logging_message_notification || @server.logging_message_notification
56
+ return unless effective_logging&.should_notify?(level)
57
+
58
+ params = { "data" => data, "level" => level }
59
+ params["logger"] = logger if logger
60
+
61
+ send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params)
62
+ rescue => e
63
+ @server.report_exception(e, { notification: "log_message" })
64
+ end
65
+
66
+ private
67
+
68
+ # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
69
+ # `@transport.send_notification(method, params, session_id: @session_id)` and
70
+ # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
71
+ def send_to_transport(method, params)
72
+ if @session_id
73
+ @transport.send_notification(method, params, session_id: @session_id)
74
+ else
75
+ @transport.send_notification(method, params)
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/mcp/transport.rb CHANGED
@@ -36,8 +36,8 @@ module MCP
36
36
  send_response(response) if response
37
37
  end
38
38
 
39
- # Send a notification to the client
40
- # Returns true if the notification was sent successfully
39
+ # Send a notification to the client.
40
+ # Returns true if the notification was sent successfully.
41
41
  def send_notification(method, params = nil)
42
42
  raise NotImplementedError, "Subclasses must implement send_notification"
43
43
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.9.2"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -15,6 +15,7 @@ module MCP
15
15
  autoload :Resource, "mcp/resource"
16
16
  autoload :ResourceTemplate, "mcp/resource_template"
17
17
  autoload :Server, "mcp/server"
18
+ autoload :ServerSession, "mcp/server_session"
18
19
  autoload :Tool, "mcp/tool"
19
20
 
20
21
  class << self
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: 0.9.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -60,6 +60,7 @@ files:
60
60
  - lib/mcp/server/transports/stdio_transport.rb
61
61
  - lib/mcp/server/transports/streamable_http_transport.rb
62
62
  - lib/mcp/server_context.rb
63
+ - lib/mcp/server_session.rb
63
64
  - lib/mcp/string_utils.rb
64
65
  - lib/mcp/tool.rb
65
66
  - lib/mcp/tool/annotations.rb
@@ -75,7 +76,7 @@ licenses:
75
76
  - Apache-2.0
76
77
  metadata:
77
78
  allowed_push_host: https://rubygems.org
78
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.9.2
79
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.10.0
79
80
  homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
80
81
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
81
82
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues