mcp 0.8.0 → 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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +176 -5
  3. data/lib/mcp/client/stdio.rb +222 -0
  4. data/lib/mcp/client.rb +21 -3
  5. data/lib/mcp/progress.rb +22 -0
  6. data/lib/mcp/prompt.rb +4 -0
  7. data/lib/mcp/resource.rb +3 -0
  8. data/lib/mcp/server/transports/stdio_transport.rb +6 -4
  9. data/lib/mcp/server/transports/streamable_http_transport.rb +140 -31
  10. data/lib/mcp/server/transports.rb +10 -0
  11. data/lib/mcp/server.rb +71 -39
  12. data/lib/mcp/server_context.rb +44 -0
  13. data/lib/mcp/server_session.rb +79 -0
  14. data/lib/mcp/tool.rb +5 -0
  15. data/lib/mcp/transport.rb +2 -2
  16. data/lib/mcp/version.rb +1 -1
  17. data/lib/mcp.rb +11 -24
  18. metadata +8 -36
  19. data/.gitattributes +0 -4
  20. data/.github/dependabot.yml +0 -6
  21. data/.github/workflows/ci.yml +0 -54
  22. data/.github/workflows/conformance.yml +0 -29
  23. data/.github/workflows/release.yml +0 -57
  24. data/.gitignore +0 -11
  25. data/.rubocop.yml +0 -15
  26. data/AGENTS.md +0 -107
  27. data/CHANGELOG.md +0 -168
  28. data/CODE_OF_CONDUCT.md +0 -74
  29. data/Gemfile +0 -29
  30. data/RELEASE.md +0 -12
  31. data/Rakefile +0 -56
  32. data/SECURITY.md +0 -21
  33. data/bin/console +0 -15
  34. data/bin/generate-gh-pages.sh +0 -119
  35. data/bin/rake +0 -31
  36. data/bin/setup +0 -8
  37. data/conformance/README.md +0 -103
  38. data/conformance/expected_failures.yml +0 -9
  39. data/conformance/runner.rb +0 -101
  40. data/conformance/server.rb +0 -547
  41. data/dev.yml +0 -30
  42. data/docs/_config.yml +0 -6
  43. data/docs/index.md +0 -7
  44. data/docs/latest/index.html +0 -19
  45. data/examples/README.md +0 -197
  46. data/examples/http_client.rb +0 -184
  47. data/examples/http_server.rb +0 -169
  48. data/examples/stdio_server.rb +0 -94
  49. data/examples/streamable_http_client.rb +0 -207
  50. data/examples/streamable_http_server.rb +0 -172
  51. data/mcp.gemspec +0 -35
@@ -1,25 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../transport"
4
3
  require "json"
5
4
  require "securerandom"
5
+ require_relative "../../transport"
6
6
 
7
7
  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,10 @@ 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
209
+ return session_already_connected_response if get_session_stream(session_id)
145
210
 
146
211
  setup_sse_stream(session_id)
147
212
  end
@@ -154,15 +219,11 @@ module MCP
154
219
  return success_response
155
220
  end
156
221
 
157
- session_id = request.env["HTTP_MCP_SESSION_ID"]
158
-
159
- return [
160
- 400,
161
- { "Content-Type" => "application/json" },
162
- [{ error: "Missing session ID" }.to_json],
163
- ] unless session_id
222
+ return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
223
+ return session_not_found_response unless session_exists?(session_id)
164
224
 
165
225
  cleanup_session(session_id)
226
+
166
227
  success_response
167
228
  end
168
229
 
@@ -193,6 +254,8 @@ module MCP
193
254
  return not_acceptable_response(required_types) unless accept_header
194
255
 
195
256
  accepted_types = parse_accept_header(accept_header)
257
+ return if accepted_types.include?("*/*")
258
+
196
259
  missing_types = required_types - accepted_types
197
260
  return not_acceptable_response(required_types) unless missing_types.empty?
198
261
 
@@ -229,18 +292,26 @@ module MCP
229
292
 
230
293
  def handle_initialization(body_string, body)
231
294
  session_id = nil
295
+ server_session = nil
232
296
 
233
297
  unless @stateless
234
298
  session_id = SecureRandom.uuid
299
+ server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
235
300
 
236
301
  @mutex.synchronize do
237
302
  @sessions[session_id] = {
238
303
  stream: nil,
304
+ server_session: server_session,
305
+ last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
239
306
  }
240
307
  end
241
308
  end
242
309
 
243
- 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
244
315
 
245
316
  headers = {
246
317
  "Content-Type" => "application/json",
@@ -256,30 +327,49 @@ module MCP
256
327
  end
257
328
 
258
329
  def handle_regular_request(body_string, session_id)
330
+ server_session = nil
331
+ stream = nil
332
+
259
333
  unless @stateless
260
- # If session ID is provided, but not in the sessions hash, return an error
261
- if session_id && !@sessions.key?(session_id)
262
- return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
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
263
343
  end
264
344
  end
265
345
 
266
- response = @server.handle_json(body_string) || ""
267
-
268
- # Stream can be nil since stateless mode doesn't retain streams
269
- 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
270
351
 
271
352
  if stream
272
353
  send_response_to_stream(stream, response, session_id)
273
- elsif response.nil? && notification_request?(body_string)
274
- [202, { "Content-Type" => "application/json" }, [response]]
275
354
  else
276
355
  [200, { "Content-Type" => "application/json" }, [response]]
277
356
  end
278
357
  end
279
358
 
280
- def notification_request?(body_string)
281
- body = parse_request_body(body_string)
282
- body.is_a?(Hash) && body["method"].start_with?("notifications/")
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
283
373
  end
284
374
 
285
375
  def get_session_stream(session_id)
@@ -315,6 +405,14 @@ module MCP
315
405
  [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
316
406
  end
317
407
 
408
+ def session_already_connected_response
409
+ [
410
+ 409,
411
+ { "Content-Type" => "application/json" },
412
+ [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
413
+ ]
414
+ end
415
+
318
416
  def setup_sse_stream(session_id)
319
417
  body = create_sse_body(session_id)
320
418
 
@@ -329,17 +427,22 @@ module MCP
329
427
 
330
428
  def create_sse_body(session_id)
331
429
  proc do |stream|
332
- store_stream_for_session(session_id, stream)
333
- start_keepalive_thread(session_id)
430
+ stored = store_stream_for_session(session_id, stream)
431
+ start_keepalive_thread(session_id) if stored
334
432
  end
335
433
  end
336
434
 
337
435
  def store_stream_for_session(session_id, stream)
338
436
  @mutex.synchronize do
339
- if @sessions[session_id]
340
- @sessions[session_id][:stream] = stream
437
+ session = @sessions[session_id]
438
+ if session && !session[:stream]
439
+ session[:stream] = stream
341
440
  else
441
+ # Either session was removed, or another request already established a stream.
342
442
  stream.close
443
+ # `stream.close` may return a truthy value depending on the stream class.
444
+ # Explicitly return nil to guarantee a falsy return for callers.
445
+ nil
343
446
  end
344
447
  end
345
448
  end
@@ -374,6 +477,12 @@ module MCP
374
477
  )
375
478
  raise # Re-raise to exit the keepalive loop
376
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
377
486
  end
378
487
  end
379
488
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module Transports
6
+ autoload :StdioTransport, "mcp/server/transports/stdio_transport"
7
+ autoload :StreamableHTTPTransport, "mcp/server/transports/streamable_http_transport"
8
+ end
9
+ end
10
+ end
data/lib/mcp/server.rb CHANGED
@@ -4,6 +4,9 @@ require_relative "../json_rpc_handler"
4
4
  require_relative "instrumentation"
5
5
  require_relative "methods"
6
6
  require_relative "logging_message_notification"
7
+ require_relative "progress"
8
+ require_relative "server_context"
9
+ require_relative "server/transports"
7
10
 
8
11
  module MCP
9
12
  class ToolNotUnique < StandardError
@@ -96,6 +99,7 @@ module MCP
96
99
  Methods::INITIALIZE => method(:init),
97
100
  Methods::PING => ->(_) { {} },
98
101
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
102
+ Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
99
103
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
100
104
 
101
105
  # No op handlers for currently unsupported methods
@@ -107,15 +111,29 @@ module MCP
107
111
  @transport = transport
108
112
  end
109
113
 
110
- 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)
111
122
  JsonRpcHandler.handle(request) do |method|
112
- handle_request(request, method)
123
+ handle_request(request, method, session: session)
113
124
  end
114
125
  end
115
126
 
116
- 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)
117
135
  JsonRpcHandler.handle_json(request) do |method|
118
- handle_request(request, method)
136
+ handle_request(request, method, session: session)
119
137
  end
120
138
  end
121
139
 
@@ -180,34 +198,16 @@ module MCP
180
198
  report_exception(e, { notification: "log_message" })
181
199
  end
182
200
 
183
- def resources_list_handler(&block)
184
- @handlers[Methods::RESOURCES_LIST] = block
185
- end
186
-
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.
187
207
  def resources_read_handler(&block)
188
208
  @handlers[Methods::RESOURCES_READ] = block
189
209
  end
190
210
 
191
- def resources_templates_list_handler(&block)
192
- @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
193
- end
194
-
195
- def tools_list_handler(&block)
196
- @handlers[Methods::TOOLS_LIST] = block
197
- end
198
-
199
- def tools_call_handler(&block)
200
- @handlers[Methods::TOOLS_CALL] = block
201
- end
202
-
203
- def prompts_list_handler(&block)
204
- @handlers[Methods::PROMPTS_LIST] = block
205
- end
206
-
207
- def prompts_get_handler(&block)
208
- @handlers[Methods::PROMPTS_GET] = block
209
- end
210
-
211
211
  private
212
212
 
213
213
  def validate!
@@ -278,11 +278,12 @@ module MCP
278
278
  end
279
279
  end
280
280
 
281
- def handle_request(request, method)
281
+ def handle_request(request, method, session: nil)
282
282
  handler = @handlers[method]
283
283
  unless handler
284
284
  instrument_call("unsupported_method") do
285
- add_instrumentation_data(client: @client) if @client
285
+ client = session&.client || @client
286
+ add_instrumentation_data(client: client) if client
286
287
  end
287
288
  return
288
289
  end
@@ -292,6 +293,8 @@ module MCP
292
293
  ->(params) {
293
294
  instrument_call(method) do
294
295
  result = case method
296
+ when Methods::INITIALIZE
297
+ init(params, session: session)
295
298
  when Methods::TOOLS_LIST
296
299
  { tools: @handlers[Methods::TOOLS_LIST].call(params) }
297
300
  when Methods::PROMPTS_LIST
@@ -302,10 +305,15 @@ module MCP
302
305
  { contents: @handlers[Methods::RESOURCES_READ].call(params) }
303
306
  when Methods::RESOURCES_TEMPLATES_LIST
304
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)
305
312
  else
306
313
  @handlers[method].call(params)
307
314
  end
308
- add_instrumentation_data(client: @client) if @client
315
+ client = session&.client || @client
316
+ add_instrumentation_data(client: client) if client
309
317
 
310
318
  result
311
319
  rescue => e
@@ -341,8 +349,14 @@ module MCP
341
349
  }.compact
342
350
  end
343
351
 
344
- def init(params)
345
- @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
346
360
 
347
361
  protocol_version = params[:protocolVersion] if params
348
362
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
@@ -370,7 +384,7 @@ module MCP
370
384
  }.compact
371
385
  end
372
386
 
373
- def configure_logging_level(request)
387
+ def configure_logging_level(request, session: nil)
374
388
  if capabilities[:logging].nil?
375
389
  raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
376
390
  end
@@ -380,6 +394,7 @@ module MCP
380
394
  raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
381
395
  end
382
396
 
397
+ session&.configure_logging(logging_message_notification)
383
398
  @logging_message_notification = logging_message_notification
384
399
 
385
400
  {}
@@ -389,7 +404,7 @@ module MCP
389
404
  @tools.values.map(&:to_h)
390
405
  end
391
406
 
392
- def call_tool(request)
407
+ def call_tool(request, session: nil)
393
408
  tool_name = request[:name]
394
409
 
395
410
  tool = tools[tool_name]
@@ -419,7 +434,9 @@ module MCP
419
434
  end
420
435
  end
421
436
 
422
- call_tool_with_args(tool, arguments)
437
+ progress_token = request.dig(:_meta, :progressToken)
438
+
439
+ call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
423
440
  rescue RequestHandlerError
424
441
  raise
425
442
  rescue => e
@@ -445,7 +462,7 @@ module MCP
445
462
  prompt_args = request[:arguments]
446
463
  prompt.validate_arguments!(prompt_args)
447
464
 
448
- call_prompt_template_with_args(prompt, prompt_args)
465
+ call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
449
466
  end
450
467
 
451
468
  def list_resources(request)
@@ -488,22 +505,37 @@ module MCP
488
505
  parameters.any? { |type, name| type == :keyrest || name == :server_context }
489
506
  end
490
507
 
491
- def call_tool_with_args(tool, arguments)
508
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
492
509
  args = arguments&.transform_keys(&:to_sym) || {}
493
510
 
494
511
  if accepts_server_context?(tool.method(:call))
512
+ progress = Progress.new(notification_target: session, progress_token: progress_token)
513
+ server_context = ServerContext.new(context, progress: progress, notification_target: session)
495
514
  tool.call(**args, server_context: server_context).to_h
496
515
  else
497
516
  tool.call(**args).to_h
498
517
  end
499
518
  end
500
519
 
501
- def call_prompt_template_with_args(prompt, args)
520
+ def call_prompt_template_with_args(prompt, args, server_context)
502
521
  if accepts_server_context?(prompt.method(:template))
503
522
  prompt.template(args, server_context: server_context).to_h
504
523
  else
505
524
  prompt.template(args).to_h
506
525
  end
507
526
  end
527
+
528
+ def server_context_with_meta(request)
529
+ meta = request[:_meta]
530
+ if meta && server_context.is_a?(Hash)
531
+ context = server_context.dup
532
+ context[:_meta] = meta
533
+ context
534
+ elsif meta && server_context.nil?
535
+ { _meta: meta }
536
+ else
537
+ server_context
538
+ end
539
+ end
508
540
  end
509
541
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class ServerContext
5
+ def initialize(context, progress:, notification_target:)
6
+ @context = context
7
+ @progress = progress
8
+ @notification_target = notification_target
9
+ end
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.
17
+ def report_progress(progress, total: nil, message: nil)
18
+ @progress.report(progress, total: total, message: message)
19
+ end
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
+
32
+ def method_missing(name, ...)
33
+ if @context.respond_to?(name)
34
+ @context.public_send(name, ...)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(name, include_private = false)
41
+ @context.respond_to?(name) || super
42
+ end
43
+ end
44
+ end
@@ -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/tool.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "tool/annotations"
4
+ require_relative "tool/input_schema"
5
+ require_relative "tool/output_schema"
6
+ require_relative "tool/response"
7
+
3
8
  module MCP
4
9
  class Tool
5
10
  class << self