mcp 0.7.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -5
  3. data/lib/mcp/client/stdio.rb +222 -0
  4. data/lib/mcp/client.rb +21 -3
  5. data/lib/mcp/content.rb +28 -1
  6. data/lib/mcp/progress.rb +22 -0
  7. data/lib/mcp/prompt.rb +8 -3
  8. data/lib/mcp/resource/contents.rb +2 -2
  9. data/lib/mcp/resource.rb +3 -0
  10. data/lib/mcp/server/transports/stdio_transport.rb +6 -4
  11. data/lib/mcp/server/transports/streamable_http_transport.rb +146 -36
  12. data/lib/mcp/server/transports.rb +10 -0
  13. data/lib/mcp/server.rb +98 -43
  14. data/lib/mcp/server_context.rb +44 -0
  15. data/lib/mcp/server_session.rb +79 -0
  16. data/lib/mcp/tool/schema.rb +0 -4
  17. data/lib/mcp/tool.rb +5 -0
  18. data/lib/mcp/transport.rb +2 -2
  19. data/lib/mcp/version.rb +1 -1
  20. data/lib/mcp.rb +11 -24
  21. metadata +8 -30
  22. data/.gitattributes +0 -4
  23. data/.github/dependabot.yml +0 -6
  24. data/.github/workflows/ci.yml +0 -54
  25. data/.github/workflows/release.yml +0 -57
  26. data/.gitignore +0 -10
  27. data/.rubocop.yml +0 -15
  28. data/AGENTS.md +0 -107
  29. data/CHANGELOG.md +0 -143
  30. data/CODE_OF_CONDUCT.md +0 -74
  31. data/Gemfile +0 -29
  32. data/RELEASE.md +0 -12
  33. data/Rakefile +0 -17
  34. data/bin/console +0 -15
  35. data/bin/generate-gh-pages.sh +0 -119
  36. data/bin/rake +0 -31
  37. data/bin/setup +0 -8
  38. data/dev.yml +0 -30
  39. data/docs/_config.yml +0 -6
  40. data/docs/index.md +0 -7
  41. data/docs/latest/index.html +0 -19
  42. data/examples/README.md +0 -197
  43. data/examples/http_client.rb +0 -184
  44. data/examples/http_server.rb +0 -169
  45. data/examples/stdio_server.rb +0 -94
  46. data/examples/streamable_http_client.rb +0 -207
  47. data/examples/streamable_http_server.rb +0 -172
  48. data/mcp.gemspec +0 -35
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../transport"
4
3
  require "json"
4
+ require_relative "../../transport"
5
5
 
6
6
  module MCP
7
7
  class Server
@@ -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...")
@@ -1,24 +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
33
+ STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
34
+ SESSION_REAP_INTERVAL = 60
22
35
 
23
36
  def handle_request(request)
24
37
  case request.env["REQUEST_METHOD"]
@@ -34,6 +47,9 @@ module MCP
34
47
  end
35
48
 
36
49
  def close
50
+ @reaper_thread&.kill
51
+ @reaper_thread = nil
52
+
37
53
  @mutex.synchronize do
38
54
  @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
39
55
  end
@@ -55,10 +71,15 @@ module MCP
55
71
  session = @sessions[session_id]
56
72
  return false unless session && session[:stream]
57
73
 
74
+ if session_expired?(session)
75
+ cleanup_session_unsafe(session_id)
76
+ return false
77
+ end
78
+
58
79
  begin
59
80
  send_to_stream(session[:stream], notification)
60
81
  true
61
- rescue IOError, Errno::EPIPE => e
82
+ rescue *STREAM_WRITE_ERRORS => e
62
83
  MCP.configuration.exception_reporter.call(
63
84
  e,
64
85
  { session_id: session_id, error: "Failed to send notification" },
@@ -74,10 +95,15 @@ module MCP
74
95
  @sessions.each do |sid, session|
75
96
  next unless session[:stream]
76
97
 
98
+ if session_expired?(session)
99
+ failed_sessions << sid
100
+ next
101
+ end
102
+
77
103
  begin
78
104
  send_to_stream(session[:stream], notification)
79
105
  sent_count += 1
80
- rescue IOError, Errno::EPIPE => e
106
+ rescue *STREAM_WRITE_ERRORS => e
81
107
  MCP.configuration.exception_reporter.call(
82
108
  e,
83
109
  { session_id: sid, error: "Failed to send notification" },
@@ -96,6 +122,39 @@ module MCP
96
122
 
97
123
  private
98
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
+
99
158
  def send_to_stream(stream, data)
100
159
  message = data.is_a?(String) ? data : data.to_json
101
160
  stream.write("data: #{message}\n\n")
@@ -119,10 +178,14 @@ module MCP
119
178
 
120
179
  if body["method"] == "initialize"
121
180
  handle_initialization(body_string, body)
122
- elsif notification?(body) || response?(body)
123
- handle_accepted
124
181
  else
125
- 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
126
189
  end
127
190
  rescue StandardError => e
128
191
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -140,7 +203,10 @@ module MCP
140
203
  session_id = extract_session_id(request)
141
204
 
142
205
  return missing_session_id_response unless session_id
143
- 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)
144
210
 
145
211
  setup_sse_stream(session_id)
146
212
  end
@@ -153,15 +219,11 @@ module MCP
153
219
  return success_response
154
220
  end
155
221
 
156
- session_id = request.env["HTTP_MCP_SESSION_ID"]
157
-
158
- return [
159
- 400,
160
- { "Content-Type" => "application/json" },
161
- [{ error: "Missing session ID" }.to_json],
162
- ] 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)
163
224
 
164
225
  cleanup_session(session_id)
226
+
165
227
  success_response
166
228
  end
167
229
 
@@ -192,6 +254,8 @@ module MCP
192
254
  return not_acceptable_response(required_types) unless accept_header
193
255
 
194
256
  accepted_types = parse_accept_header(accept_header)
257
+ return if accepted_types.include?("*/*")
258
+
195
259
  missing_types = required_types - accepted_types
196
260
  return not_acceptable_response(required_types) unless missing_types.empty?
197
261
 
@@ -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,30 +327,49 @@ 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 is provided, but not in the sessions hash, return an error
260
- if session_id && !@sessions.key?(session_id)
261
- 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
262
343
  end
263
344
  end
264
345
 
265
- response = @server.handle_json(body_string) || ""
266
-
267
- # Stream can be nil since stateless mode doesn't retain streams
268
- 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
269
351
 
270
352
  if stream
271
353
  send_response_to_stream(stream, response, session_id)
272
- elsif response.nil? && notification_request?(body_string)
273
- [202, { "Content-Type" => "application/json" }, [response]]
274
354
  else
275
355
  [200, { "Content-Type" => "application/json" }, [response]]
276
356
  end
277
357
  end
278
358
 
279
- def notification_request?(body_string)
280
- body = parse_request_body(body_string)
281
- 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
282
373
  end
283
374
 
284
375
  def get_session_stream(session_id)
@@ -288,8 +379,8 @@ module MCP
288
379
  def send_response_to_stream(stream, response, session_id)
289
380
  message = JSON.parse(response)
290
381
  send_to_stream(stream, message)
291
- [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
292
- rescue IOError, Errno::EPIPE => e
382
+ handle_accepted
383
+ rescue *STREAM_WRITE_ERRORS => e
293
384
  MCP.configuration.exception_reporter.call(
294
385
  e,
295
386
  { session_id: session_id, error: "Stream closed during response" },
@@ -314,6 +405,14 @@ module MCP
314
405
  [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
315
406
  end
316
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
+
317
416
  def setup_sse_stream(session_id)
318
417
  body = create_sse_body(session_id)
319
418
 
@@ -328,17 +427,22 @@ module MCP
328
427
 
329
428
  def create_sse_body(session_id)
330
429
  proc do |stream|
331
- store_stream_for_session(session_id, stream)
332
- start_keepalive_thread(session_id)
430
+ stored = store_stream_for_session(session_id, stream)
431
+ start_keepalive_thread(session_id) if stored
333
432
  end
334
433
  end
335
434
 
336
435
  def store_stream_for_session(session_id, stream)
337
436
  @mutex.synchronize do
338
- if @sessions[session_id]
339
- @sessions[session_id][:stream] = stream
437
+ session = @sessions[session_id]
438
+ if session && !session[:stream]
439
+ session[:stream] = stream
340
440
  else
441
+ # Either session was removed, or another request already established a stream.
341
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
342
446
  end
343
447
  end
344
448
  end
@@ -366,13 +470,19 @@ module MCP
366
470
  send_ping_to_stream(@sessions[session_id][:stream])
367
471
  end
368
472
  end
369
- rescue IOError, Errno::EPIPE => e
473
+ rescue *STREAM_WRITE_ERRORS => e
370
474
  MCP.configuration.exception_reporter.call(
371
475
  e,
372
476
  { session_id: session_id, error: "Stream closed" },
373
477
  )
374
478
  raise # Re-raise to exit the keepalive loop
375
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
376
486
  end
377
487
  end
378
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,26 +99,41 @@ 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
102
- Methods::RESOURCES_SUBSCRIBE => ->(_) {},
103
- Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
104
- Methods::COMPLETION_COMPLETE => ->(_) {},
106
+ Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
107
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
108
+ Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
105
109
  Methods::ELICITATION_CREATE => ->(_) {},
106
110
  }
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!
@@ -219,6 +219,14 @@ module MCP
219
219
  message = "Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier"
220
220
  raise ArgumentError, message
221
221
  end
222
+
223
+ tools_with_ref = @tools.each_with_object([]) do |(tool_name, tool), names|
224
+ names << tool_name if schema_contains_ref?(tool.input_schema_value.to_h)
225
+ end
226
+ unless tools_with_ref.empty?
227
+ message = "Error occurred in #{tools_with_ref.join(", ")}. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher"
228
+ raise ArgumentError, message
229
+ end
222
230
  end
223
231
 
224
232
  if @configuration.protocol_version <= "2025-03-26"
@@ -259,11 +267,23 @@ module MCP
259
267
  raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty?
260
268
  end
261
269
 
262
- def handle_request(request, method)
270
+ def schema_contains_ref?(schema)
271
+ case schema
272
+ when Hash
273
+ schema.any? { |key, value| key.to_s == "$ref" || schema_contains_ref?(value) }
274
+ when Array
275
+ schema.any? { |element| schema_contains_ref?(element) }
276
+ else
277
+ false
278
+ end
279
+ end
280
+
281
+ def handle_request(request, method, session: nil)
263
282
  handler = @handlers[method]
264
283
  unless handler
265
284
  instrument_call("unsupported_method") do
266
- add_instrumentation_data(client: @client) if @client
285
+ client = session&.client || @client
286
+ add_instrumentation_data(client: client) if client
267
287
  end
268
288
  return
269
289
  end
@@ -273,6 +293,8 @@ module MCP
273
293
  ->(params) {
274
294
  instrument_call(method) do
275
295
  result = case method
296
+ when Methods::INITIALIZE
297
+ init(params, session: session)
276
298
  when Methods::TOOLS_LIST
277
299
  { tools: @handlers[Methods::TOOLS_LIST].call(params) }
278
300
  when Methods::PROMPTS_LIST
@@ -283,10 +305,15 @@ module MCP
283
305
  { contents: @handlers[Methods::RESOURCES_READ].call(params) }
284
306
  when Methods::RESOURCES_TEMPLATES_LIST
285
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)
286
312
  else
287
313
  @handlers[method].call(params)
288
314
  end
289
- add_instrumentation_data(client: @client) if @client
315
+ client = session&.client || @client
316
+ add_instrumentation_data(client: client) if client
290
317
 
291
318
  result
292
319
  rescue => e
@@ -322,8 +349,14 @@ module MCP
322
349
  }.compact
323
350
  end
324
351
 
325
- def init(params)
326
- @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
327
360
 
328
361
  protocol_version = params[:protocolVersion] if params
329
362
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
@@ -351,7 +384,7 @@ module MCP
351
384
  }.compact
352
385
  end
353
386
 
354
- def configure_logging_level(request)
387
+ def configure_logging_level(request, session: nil)
355
388
  if capabilities[:logging].nil?
356
389
  raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
357
390
  end
@@ -361,21 +394,24 @@ module MCP
361
394
  raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
362
395
  end
363
396
 
397
+ session&.configure_logging(logging_message_notification)
364
398
  @logging_message_notification = logging_message_notification
399
+
400
+ {}
365
401
  end
366
402
 
367
403
  def list_tools(request)
368
404
  @tools.values.map(&:to_h)
369
405
  end
370
406
 
371
- def call_tool(request)
407
+ def call_tool(request, session: nil)
372
408
  tool_name = request[:name]
373
409
 
374
410
  tool = tools[tool_name]
375
411
  unless tool
376
412
  add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
377
413
 
378
- return error_tool_response("Tool not found: #{tool_name}")
414
+ raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
379
415
  end
380
416
 
381
417
  arguments = request[:arguments] || {}
@@ -398,7 +434,11 @@ module MCP
398
434
  end
399
435
  end
400
436
 
401
- 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)
440
+ rescue RequestHandlerError
441
+ raise
402
442
  rescue => e
403
443
  report_exception(e, request: request)
404
444
 
@@ -422,7 +462,7 @@ module MCP
422
462
  prompt_args = request[:arguments]
423
463
  prompt.validate_arguments!(prompt_args)
424
464
 
425
- call_prompt_template_with_args(prompt, prompt_args)
465
+ call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
426
466
  end
427
467
 
428
468
  def list_resources(request)
@@ -465,22 +505,37 @@ module MCP
465
505
  parameters.any? { |type, name| type == :keyrest || name == :server_context }
466
506
  end
467
507
 
468
- def call_tool_with_args(tool, arguments)
508
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
469
509
  args = arguments&.transform_keys(&:to_sym) || {}
470
510
 
471
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)
472
514
  tool.call(**args, server_context: server_context).to_h
473
515
  else
474
516
  tool.call(**args).to_h
475
517
  end
476
518
  end
477
519
 
478
- def call_prompt_template_with_args(prompt, args)
520
+ def call_prompt_template_with_args(prompt, args, server_context)
479
521
  if accepts_server_context?(prompt.method(:template))
480
522
  prompt.template(args, server_context: server_context).to_h
481
523
  else
482
524
  prompt.template(args).to_h
483
525
  end
484
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
485
540
  end
486
541
  end