Skip to content

Commit f5e9b4f

Browse files
authored
Merge pull request #100 from patvice/cancelation-notification-support
Full Cancelation Notification Support
2 parents 693ff07 + abd4134 commit f5e9b4f

File tree

17 files changed

+860
-25
lines changed

17 files changed

+860
-25
lines changed

lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def initialize(client, transport_type:, config: {})
5656
:roots_list_change_notification,
5757
:ping_response, :roots_list_response,
5858
:sampling_create_message_response,
59-
:error_response, :elicitation_response
59+
:error_response, :elicitation_response,
60+
:register_in_flight_request, :unregister_in_flight_request,
61+
:cancel_in_flight_request
6062

6163
def register_resource(resource)
6264
client.linked_resources << resource

lib/ruby_llm/mcp/client.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ def initialize(name:, transport_type:, sdk: nil, adapter: nil, start: true, # ru
5252
@adapter.start if start
5353
end
5454

55-
def_delegators :@adapter, :alive?, :capabilities, :ping, :client_capabilities
55+
def_delegators :@adapter, :alive?, :capabilities, :ping, :client_capabilities,
56+
:register_in_flight_request, :unregister_in_flight_request,
57+
:cancel_in_flight_request
5658

5759
def start
5860
@adapter.start

lib/ruby_llm/mcp/elicitation.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def initialize(coordinator, result)
2020

2121
def execute
2222
success = @coordinator.elicitation_callback&.call(self)
23+
2324
if success
2425
valid = validate_response
2526
if valid

lib/ruby_llm/mcp/errors.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ class UnsupportedFeature < BaseError; end
7777
class UnsupportedTransport < BaseError; end
7878

7979
class AdapterConfigurationError < BaseError; end
80+
81+
class RequestCancelled < BaseError
82+
attr_reader :request_id
83+
84+
def initialize(message:, request_id:)
85+
@request_id = request_id
86+
super(message: message)
87+
end
88+
end
8089
end
8190
end
8291
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module MCP
5+
module Native
6+
# Wraps server-initiated requests to support cancellation
7+
# Executes the request in a separate thread that can be terminated on cancellation
8+
class CancellableOperation
9+
attr_reader :request_id, :thread
10+
11+
def initialize(request_id)
12+
@request_id = request_id
13+
@cancelled = false
14+
@mutex = Mutex.new
15+
@thread = nil
16+
@result = nil
17+
@error = nil
18+
end
19+
20+
def cancelled?
21+
@mutex.synchronize { @cancelled }
22+
end
23+
24+
def cancel
25+
@mutex.synchronize { @cancelled = true }
26+
if @thread&.alive?
27+
@thread.raise(Errors::RequestCancelled.new(
28+
message: "Request #{@request_id} was cancelled",
29+
request_id: @request_id
30+
))
31+
end
32+
end
33+
34+
# Execute a block in a separate thread
35+
# This allows the thread to be terminated if cancellation is requested
36+
# Returns the result of the block or re-raises any error that occurred
37+
def execute(&)
38+
@thread = Thread.new do
39+
Thread.current.abort_on_exception = false
40+
begin
41+
@result = yield
42+
rescue Errors::RequestCancelled, StandardError => e
43+
@error = e
44+
end
45+
end
46+
47+
@thread.join
48+
raise @error if @error && !@error.is_a?(Errors::RequestCancelled)
49+
50+
@result
51+
ensure
52+
@thread = nil
53+
end
54+
end
55+
end
56+
end
57+
end

lib/ruby_llm/mcp/native/client.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ def initialize( # rubocop:disable Metrics/ParameterLists
4444

4545
@transport = nil
4646
@capabilities = nil
47+
48+
# Track in-flight server-initiated requests for cancellation
49+
@in_flight_requests = {}
50+
@in_flight_mutex = Mutex.new
4751
end
4852

4953
def request(body, **options)
@@ -342,6 +346,41 @@ def sampling_callback_enabled?
342346
def transport
343347
@transport ||= Native::Transport.new(@transport_type, self, config: @config)
344348
end
349+
350+
# Register a server-initiated request that can be cancelled
351+
# @param request_id [String] The ID of the request
352+
# @param cancellable_operation [CancellableOperation, nil] The operation that can be cancelled
353+
def register_in_flight_request(request_id, cancellable_operation = nil)
354+
@in_flight_mutex.synchronize do
355+
@in_flight_requests[request_id.to_s] = cancellable_operation
356+
end
357+
end
358+
359+
# Unregister a completed or cancelled request
360+
# @param request_id [String] The ID of the request
361+
def unregister_in_flight_request(request_id)
362+
@in_flight_mutex.synchronize do
363+
@in_flight_requests.delete(request_id.to_s)
364+
end
365+
end
366+
367+
# Cancel an in-flight server-initiated request
368+
# @param request_id [String] The ID of the request to cancel
369+
# @return [Boolean] true if the request was found and cancelled, false otherwise
370+
def cancel_in_flight_request(request_id) # rubocop:disable Naming/PredicateMethod
371+
operation = nil
372+
@in_flight_mutex.synchronize do
373+
operation = @in_flight_requests[request_id.to_s]
374+
end
375+
376+
if operation.respond_to?(:cancel)
377+
operation.cancel
378+
true
379+
else
380+
RubyLLM::MCP.logger.warn("Request #{request_id} cannot be cancelled or was already completed")
381+
false
382+
end
383+
end
345384
end
346385
end
347386
end

lib/ruby_llm/mcp/native/response_handler.rb

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,38 @@ def initialize(coordinator)
1010
@coordinator = coordinator
1111
end
1212

13-
def execute(result) # rubocop:disable Naming/PredicateMethod
14-
if result.ping?
15-
coordinator.ping_response(id: result.id)
16-
true
17-
elsif result.roots?
18-
handle_roots_response(result)
19-
true
20-
elsif result.sampling?
21-
handle_sampling_response(result)
22-
true
23-
elsif result.elicitation?
24-
handle_elicitation_response(result)
13+
def execute(result)
14+
operation = CancellableOperation.new(result.id)
15+
coordinator.register_in_flight_request(result.id, operation)
16+
17+
begin
18+
# Execute in a separate thread that can be terminated on cancellation
19+
operation.execute do
20+
if result.ping?
21+
coordinator.ping_response(id: result.id)
22+
true
23+
elsif result.roots?
24+
handle_roots_response(result)
25+
true
26+
elsif result.sampling?
27+
handle_sampling_response(result)
28+
true
29+
elsif result.elicitation?
30+
handle_elicitation_response(result)
31+
true
32+
else
33+
handle_unknown_request(result)
34+
RubyLLM::MCP.logger.error("MCP client was sent unknown method type and \
35+
could not respond: #{result.inspect}.")
36+
false
37+
end
38+
end
39+
rescue Errors::RequestCancelled => e
40+
RubyLLM::MCP.logger.info("Request #{result.id} was cancelled: #{e.message}")
41+
# Don't send response - cancellation means result is unused
2542
true
26-
else
27-
handle_unknown_request(result)
28-
RubyLLM::MCP.logger.error("MCP client was sent unknown method type and \
29-
could not respond: #{result.inspect}.")
30-
false
43+
ensure
44+
coordinator.unregister_in_flight_request(result.id)
3145
end
3246
end
3347

lib/ruby_llm/mcp/notification_handler.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def execute(notification)
2424
when "notifications/progress"
2525
process_progress_message(notification)
2626
when "notifications/cancelled"
27-
# TODO: - do nothing at the moment until we support client operations
27+
process_cancelled_notification(notification)
2828
else
2929
process_unknown_notification(notification)
3030
end
@@ -74,6 +74,23 @@ def default_process_logging_message(notification, logger: RubyLLM::MCP.logger)
7474
end
7575
end
7676

77+
def process_cancelled_notification(notification)
78+
request_id = notification.params["requestId"]
79+
reason = notification.params["reason"] || "No reason provided"
80+
81+
RubyLLM::MCP.logger.info(
82+
"Received cancellation for request #{request_id}: #{reason}"
83+
)
84+
85+
success = client.cancel_in_flight_request(request_id)
86+
87+
unless success
88+
RubyLLM::MCP.logger.debug(
89+
"Request #{request_id} was not found or already completed"
90+
)
91+
end
92+
end
93+
7794
def process_unknown_notification(notification)
7895
message = "Unknown notification type: #{notification.type} params: #{notification.params.to_h}"
7996
RubyLLM::MCP.logger.error(message)

spec/fixtures/typescript-mcp/src/tools/client-interaction.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,57 @@ export function setupClientInteractionTools(server: McpServer) {
128128
};
129129
}
130130
});
131+
132+
server.tool(
133+
"sample_with_cancellation",
134+
"Test cancellation by initiating a slow sampling request that can be cancelled",
135+
{},
136+
async ({}) => {
137+
try {
138+
// Start a sampling request that will take time
139+
// The client should have a slow sampling callback configured
140+
// The test will send a cancellation notification while this is in-flight
141+
const result = await server.server.createMessage({
142+
messages: [
143+
{
144+
role: "user" as const,
145+
content: {
146+
type: "text" as const,
147+
text: "This request should be cancelled by the client",
148+
},
149+
},
150+
],
151+
model: "gpt-4o",
152+
modelPreferences: {
153+
hints: [{ name: "gpt-4o" }],
154+
},
155+
systemPrompt: "You are a helpful assistant.",
156+
maxTokens: 100,
157+
});
158+
159+
// If we get here, the request completed (wasn't cancelled)
160+
return {
161+
content: [
162+
{
163+
type: "text" as const,
164+
text: `Cancellation test FAILED: Request completed when it should have been cancelled. Result: ${JSON.stringify(
165+
result
166+
)}`,
167+
},
168+
],
169+
isError: true,
170+
};
171+
} catch (error: any) {
172+
// An error is expected if cancellation worked
173+
return {
174+
content: [
175+
{
176+
type: "text" as const,
177+
text: `Cancellation test PASSED: Request was cancelled (${error.message})`,
178+
},
179+
],
180+
};
181+
}
182+
}
183+
);
131184
}

spec/fixtures/vcr_cassettes/Cancellation_Integration/with_stdio-native/End-to-end_cancellation_with_stdio-native/allows_multiple_cancellations_without_errors.yml

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)