mcp 0.14.0 → 0.16.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.
@@ -16,7 +16,7 @@ module MCP
16
16
  attr_writer :instrumentation_callback
17
17
 
18
18
  def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
19
- validate_tool_call_arguments: true)
19
+ validate_tool_call_arguments: true, validate_tool_call_results: false)
20
20
  @exception_reporter = exception_reporter
21
21
  @around_request = around_request
22
22
  @instrumentation_callback = instrumentation_callback
@@ -25,8 +25,10 @@ module MCP
25
25
  validate_protocol_version!(protocol_version)
26
26
  end
27
27
  validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
28
+ validate_value_of_validate_tool_call_results!(validate_tool_call_results)
28
29
 
29
30
  @validate_tool_call_arguments = validate_tool_call_arguments
31
+ @validate_tool_call_results = validate_tool_call_results
30
32
  end
31
33
 
32
34
  def protocol_version=(protocol_version)
@@ -41,6 +43,12 @@ module MCP
41
43
  @validate_tool_call_arguments = validate_tool_call_arguments
42
44
  end
43
45
 
46
+ def validate_tool_call_results=(validate_tool_call_results)
47
+ validate_value_of_validate_tool_call_results!(validate_tool_call_results)
48
+
49
+ @validate_tool_call_results = validate_tool_call_results
50
+ end
51
+
44
52
  def protocol_version
45
53
  @protocol_version || LATEST_STABLE_PROTOCOL_VERSION
46
54
  end
@@ -80,11 +88,16 @@ module MCP
80
88
  end
81
89
 
82
90
  attr_reader :validate_tool_call_arguments
91
+ attr_reader :validate_tool_call_results
83
92
 
84
93
  def validate_tool_call_arguments?
85
94
  !!@validate_tool_call_arguments
86
95
  end
87
96
 
97
+ def validate_tool_call_results?
98
+ !!@validate_tool_call_results
99
+ end
100
+
88
101
  def merge(other)
89
102
  return self if other.nil?
90
103
 
@@ -113,6 +126,7 @@ module MCP
113
126
  end
114
127
 
115
128
  validate_tool_call_arguments = other.validate_tool_call_arguments
129
+ validate_tool_call_results = other.validate_tool_call_results
116
130
 
117
131
  Configuration.new(
118
132
  exception_reporter: exception_reporter,
@@ -120,6 +134,7 @@ module MCP
120
134
  instrumentation_callback: instrumentation_callback,
121
135
  protocol_version: protocol_version,
122
136
  validate_tool_call_arguments: validate_tool_call_arguments,
137
+ validate_tool_call_results: validate_tool_call_results,
123
138
  )
124
139
  end
125
140
 
@@ -138,6 +153,12 @@ module MCP
138
153
  end
139
154
  end
140
155
 
156
+ def validate_value_of_validate_tool_call_results!(validate_tool_call_results)
157
+ unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass)
158
+ raise ArgumentError, "validate_tool_call_results must be a boolean"
159
+ end
160
+ end
161
+
141
162
  def default_exception_reporter
142
163
  @default_exception_reporter ||= ->(exception, server_context) {}
143
164
  end
@@ -54,6 +54,13 @@ module MCP
54
54
  false
55
55
  end
56
56
 
57
+ # NOTE: This signature deliberately matches the abstract `Transport#send_request` contract
58
+ # (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts.
59
+ # On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with
60
+ # explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be
61
+ # auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request
62
+ # cancellation has very limited value here regardless; servers that need cancellation propagation for nested
63
+ # server-to-client requests should use `StreamableHTTPTransport`.
57
64
  def send_request(method, params = nil)
58
65
  request_id = generate_request_id
59
66
  request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -175,7 +175,7 @@ module MCP
175
175
  # sends the request via SSE stream, then blocks on `queue.pop`.
176
176
  # When the client POSTs a response, `handle_response` matches it by `request_id`
177
177
  # and pushes the result onto the queue, unblocking this thread.
178
- def send_request(method, params = nil, session_id: nil, related_request_id: nil)
178
+ def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
179
179
  if @stateless
180
180
  raise "Stateless mode does not support server-to-client requests."
181
181
  end
@@ -190,6 +190,7 @@ module MCP
190
190
 
191
191
  request_id = generate_request_id
192
192
  queue = Queue.new
193
+ cancel_hook = nil
193
194
 
194
195
  request = { jsonrpc: "2.0", id: request_id, method: method }
195
196
  request[:params] = params if params
@@ -229,6 +230,16 @@ module MCP
229
230
  raise "No active stream for #{method} request."
230
231
  end
231
232
 
233
+ if parent_cancellation && server_session
234
+ cancel_hook = parent_cancellation.on_cancel do |reason|
235
+ server_session.send_peer_cancellation(
236
+ nested_request_id: request_id,
237
+ related_request_id: related_request_id,
238
+ reason: reason,
239
+ )
240
+ end
241
+ end
242
+
232
243
  response = queue.pop
233
244
 
234
245
  if response.is_a?(Hash) && response.key?(:error)
@@ -239,8 +250,18 @@ module MCP
239
250
  raise "SSE session closed while waiting for #{method} response."
240
251
  end
241
252
 
253
+ if response == :cancelled
254
+ reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
255
+ raise MCP::CancelledError.new(
256
+ "#{method} request was cancelled",
257
+ request_id: request_id,
258
+ reason: reason,
259
+ )
260
+ end
261
+
242
262
  response
243
263
  ensure
264
+ parent_cancellation.off_cancel(cancel_hook) if cancel_hook
244
265
  if request_id
245
266
  @mutex.synchronize do
246
267
  @pending_responses.delete(request_id)
@@ -248,6 +269,24 @@ module MCP
248
269
  end
249
270
  end
250
271
 
272
+ # Unblocks a `send_request` awaiting a response when the peer is being cancelled.
273
+ # The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
274
+ #
275
+ # Race note: this is first-writer-wins on the pending-response queue. If a real response
276
+ # has already been pushed (client responded before the cancel hook fired), that response
277
+ # wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
278
+ # the real response and deletes the pending entry in its `ensure` block. Conversely,
279
+ # if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
280
+ # because the pending entry has been removed.
281
+ def cancel_pending_request(request_id, reason: nil)
282
+ @mutex.synchronize do
283
+ if (pending = @pending_responses[request_id])
284
+ pending[:cancel_reason] = reason
285
+ pending[:queue].push(:cancelled)
286
+ end
287
+ end
288
+ end
289
+
251
290
  private
252
291
 
253
292
  def start_reaper_thread
@@ -309,6 +348,7 @@ module MCP
309
348
  return missing_session_id_response if !@stateless && !session_id
310
349
 
311
350
  if notification?(body)
351
+ dispatch_notification(body_string, session_id)
312
352
  handle_accepted
313
353
  elsif response?(body)
314
354
  return session_not_found_response if !@stateless && !session_exists?(session_id)
@@ -459,6 +499,22 @@ module MCP
459
499
  !body[:id] && !!body[:method]
460
500
  end
461
501
 
502
+ # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
503
+ # `notifications/initialized`) through the server so it can update session state.
504
+ def dispatch_notification(body_string, session_id)
505
+ server_session = nil
506
+ if session_id && !@stateless
507
+ @mutex.synchronize do
508
+ session = @sessions[session_id]
509
+ server_session = session[:server_session] if session
510
+ end
511
+ end
512
+
513
+ dispatch_handle_json(body_string, server_session)
514
+ rescue => e
515
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
516
+ end
517
+
462
518
  def response?(body)
463
519
  !!body[:id] && !body[:method]
464
520
  end
@@ -536,6 +592,12 @@ module MCP
536
592
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
537
593
  else
538
594
  response = dispatch_handle_json(body_string, server_session)
595
+
596
+ # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
597
+ # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
598
+ # which would produce an empty body the client cannot parse as JSON.
599
+ return handle_accepted if response.nil?
600
+
539
601
  [200, { "Content-Type" => "application/json" }, [response]]
540
602
  end
541
603
  end
data/lib/mcp/server.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../json_rpc_handler"
4
+ require_relative "cancellation"
5
+ require_relative "cancelled_error"
4
6
  require_relative "instrumentation"
5
7
  require_relative "methods"
6
8
  require_relative "logging_message_notification"
@@ -160,8 +162,8 @@ module MCP
160
162
  end
161
163
  end
162
164
 
163
- def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block)
164
- tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, annotations: annotations, meta: meta, &block)
165
+ def define_tool(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, meta: nil, &block)
166
+ tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, output_schema: output_schema, annotations: annotations, meta: meta, &block)
165
167
  tool_name = tool.name_value
166
168
 
167
169
  @tool_names << tool_name
@@ -384,6 +386,13 @@ module MCP
384
386
  end
385
387
 
386
388
  def handle_request(request, method, session: nil, related_request_id: nil)
389
+ # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
390
+ # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
391
+ # and rescue blocks below.
392
+ if method == Methods::NOTIFICATIONS_CANCELLED
393
+ return ->(params) { handle_cancelled_notification(params, session: session) }
394
+ end
395
+
387
396
  handler = @handlers[method]
388
397
  unless handler
389
398
  instrument_call("unsupported_method", server_context: { request: request }) do
@@ -395,6 +404,12 @@ module MCP
395
404
 
396
405
  Methods.ensure_capability!(method, capabilities)
397
406
 
407
+ # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
408
+ # so do not track it in the in-flight registry.
409
+ cancellation = if related_request_id && method != Methods::INITIALIZE
410
+ session&.register_in_flight(related_request_id)
411
+ end
412
+
398
413
  ->(params) {
399
414
  reported_exception = nil
400
415
  instrument_call(
@@ -406,23 +421,33 @@ module MCP
406
421
  when Methods::INITIALIZE
407
422
  init(params, session: session)
408
423
  when Methods::RESOURCES_READ
409
- { contents: @handlers[Methods::RESOURCES_READ].call(params) }
424
+ { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
410
425
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
411
- @handlers[method].call(params)
426
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
412
427
  {}
413
428
  when Methods::TOOLS_CALL
414
- call_tool(params, session: session, related_request_id: related_request_id)
429
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
430
+ when Methods::PROMPTS_GET
431
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
415
432
  when Methods::COMPLETION_COMPLETE
416
- complete(params)
433
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
417
434
  when Methods::LOGGING_SET_LEVEL
418
435
  configure_logging_level(params, session: session)
419
436
  else
420
- @handlers[method].call(params)
437
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
421
438
  end
422
439
  client = session&.client || @client
423
440
  add_instrumentation_data(client: client) if client
424
441
 
442
+ if cancellation&.cancelled?
443
+ add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
444
+ next JsonRpcHandler::NO_RESPONSE
445
+ end
446
+
425
447
  result
448
+ rescue CancelledError => e
449
+ add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
450
+ next JsonRpcHandler::NO_RESPONSE
426
451
  rescue RequestHandlerError => e
427
452
  report_exception(e.original_error || e, { request: request })
428
453
  add_instrumentation_data(error: e.error_type)
@@ -434,10 +459,23 @@ module MCP
434
459
  wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
435
460
  reported_exception = wrapped
436
461
  raise wrapped
462
+ ensure
463
+ session&.unregister_in_flight(related_request_id) if related_request_id
437
464
  end
438
465
  }
439
466
  end
440
467
 
468
+ def handle_cancelled_notification(params, session: nil)
469
+ return unless session
470
+ return unless params.is_a?(Hash)
471
+
472
+ request_id = params[:requestId] || params["requestId"]
473
+ return if request_id.nil?
474
+
475
+ reason = params[:reason] || params["reason"]
476
+ session.cancel_incoming(request_id: request_id, reason: reason)
477
+ end
478
+
441
479
  def default_capabilities
442
480
  {
443
481
  tools: { listChanged: true },
@@ -516,7 +554,7 @@ module MCP
516
554
  { tools: page[:items], nextCursor: page[:next_cursor] }.compact
517
555
  end
518
556
 
519
- def call_tool(request, session: nil, related_request_id: nil)
557
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
520
558
  tool_name = request[:name]
521
559
 
522
560
  tool = tools[tool_name]
@@ -533,7 +571,7 @@ module MCP
533
571
  add_instrumentation_data(error: :missing_required_arguments)
534
572
 
535
573
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
536
- raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
574
+ return error_tool_response("Missing required arguments: #{missing}")
537
575
  end
538
576
 
539
577
  if configuration.validate_tool_call_arguments && tool.input_schema
@@ -542,14 +580,20 @@ module MCP
542
580
  rescue Tool::InputSchema::ValidationError => e
543
581
  add_instrumentation_data(error: :invalid_schema)
544
582
 
545
- raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
583
+ return error_tool_response(e.message)
546
584
  end
547
585
  end
548
586
 
549
587
  progress_token = request.dig(:_meta, :progressToken)
550
588
 
551
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
552
- rescue RequestHandlerError
589
+ result = call_tool_with_args(
590
+ tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
591
+ )
592
+ validate_tool_call_result!(tool, result)
593
+ result
594
+ rescue RequestHandlerError, CancelledError
595
+ # CancelledError is intentionally not wrapped so `handle_request` can turn it into
596
+ # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
553
597
  raise
554
598
  rescue => e
555
599
  raise RequestHandlerError.new(
@@ -566,7 +610,7 @@ module MCP
566
610
  { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
567
611
  end
568
612
 
569
- def get_prompt(request)
613
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
570
614
  prompt_name = request[:name]
571
615
  prompt = @prompts[prompt_name]
572
616
  unless prompt
@@ -579,7 +623,14 @@ module MCP
579
623
  prompt_args = request[:arguments]
580
624
  prompt.validate_arguments!(prompt_args)
581
625
 
582
- call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
626
+ server_context = build_server_context(
627
+ request: request,
628
+ session: session,
629
+ related_request_id: related_request_id,
630
+ cancellation: cancellation,
631
+ )
632
+
633
+ call_prompt_template_with_args(prompt, prompt_args, server_context)
583
634
  end
584
635
 
585
636
  def list_resources(request)
@@ -600,14 +651,82 @@ module MCP
600
651
  { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
601
652
  end
602
653
 
603
- def complete(params)
654
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
604
655
  validate_completion_params!(params)
605
656
 
606
- result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
657
+ result = dispatch_optional_context_handler(
658
+ @handlers[Methods::COMPLETION_COMPLETE],
659
+ params,
660
+ session: session,
661
+ related_request_id: related_request_id,
662
+ cancellation: cancellation,
663
+ )
607
664
 
608
665
  normalize_completion_result(result)
609
666
  end
610
667
 
668
+ # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
669
+ # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
670
+ # `server_context.raise_if_cancelled!`.
671
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
672
+ dispatch_optional_context_handler(
673
+ @handlers[Methods::RESOURCES_READ],
674
+ request,
675
+ session: session,
676
+ related_request_id: related_request_id,
677
+ cancellation: cancellation,
678
+ )
679
+ end
680
+
681
+ # Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
682
+ # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
683
+ # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
684
+ # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
685
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
686
+ return handler.call(params) unless handler_declares_server_context?(handler)
687
+
688
+ server_context = build_server_context(
689
+ request: params,
690
+ session: session,
691
+ related_request_id: related_request_id,
692
+ cancellation: cancellation,
693
+ )
694
+ handler.call(params, server_context: server_context)
695
+ end
696
+
697
+ # Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
698
+ # (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
699
+ # treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
700
+ # Hash as the handler's second positional argument, which is never what the user meant.
701
+ #
702
+ # `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
703
+ # because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
704
+ # that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
705
+ # Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
706
+ # via `tool.call(**args, server_context: …)` without a positional argument.
707
+ def handler_declares_server_context?(handler)
708
+ return false unless handler.respond_to?(:parameters)
709
+
710
+ handler.parameters.any? do |type, name|
711
+ name == :server_context && (type == :key || type == :keyreq)
712
+ end
713
+ end
714
+
715
+ # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
716
+ # (progress, cancellation, nested server-to-client requests).
717
+ def build_server_context(request:, session:, related_request_id:, cancellation:)
718
+ meta_source = request.is_a?(Hash) ? request : {}
719
+ progress_token = meta_source.dig(:_meta, :progressToken)
720
+ progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
721
+ ServerContext.new(
722
+ server_context_with_meta(meta_source),
723
+ progress: progress,
724
+ notification_target: session,
725
+ related_request_id: related_request_id,
726
+ cancellation: cancellation,
727
+ )
728
+ end
729
+
611
730
  def report_exception(exception, server_context = {})
612
731
  configuration.exception_reporter.call(exception, server_context)
613
732
  end
@@ -628,18 +747,40 @@ module MCP
628
747
  ).to_h
629
748
  end
630
749
 
750
+ def validate_tool_call_result!(tool, result)
751
+ return unless configuration.validate_tool_call_results
752
+ return unless tool.output_schema
753
+ return if result[:isError]
754
+
755
+ tool.output_schema.validate_result(result[:structuredContent])
756
+ end
757
+
758
+ # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
759
+ # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
760
+ # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
761
+ # Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
762
+ # happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
763
+ # and a positional slot would receive the `{server_context: ctx}` Hash instead.
631
764
  def accepts_server_context?(method_object)
632
765
  parameters = method_object.parameters
633
766
 
634
- parameters.any? { |type, name| type == :keyrest || name == :server_context }
767
+ parameters.any? do |type, name|
768
+ type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
769
+ end
635
770
  end
636
771
 
637
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
772
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
638
773
  args = arguments&.transform_keys(&:to_sym) || {}
639
774
 
640
775
  if accepts_server_context?(tool.method(:call))
641
776
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
642
- server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
777
+ server_context = ServerContext.new(
778
+ context,
779
+ progress: progress,
780
+ notification_target: session,
781
+ related_request_id: related_request_id,
782
+ cancellation: cancellation,
783
+ )
643
784
  tool.call(**args, server_context: server_context).to_h
644
785
  else
645
786
  tool.call(**args).to_h
@@ -2,11 +2,22 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:, related_request_id: nil)
5
+ attr_reader :cancellation
6
+
7
+ def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
6
8
  @context = context
7
9
  @progress = progress
8
10
  @notification_target = notification_target
9
11
  @related_request_id = related_request_id
12
+ @cancellation = cancellation
13
+ end
14
+
15
+ def cancelled?
16
+ !!@cancellation&.cancelled?
17
+ end
18
+
19
+ def raise_if_cancelled!
20
+ @cancellation&.raise_if_cancelled!
10
21
  end
11
22
 
12
23
  # Reports progress for the current tool operation.