ruby_llm-mcp 0.7.1 → 1.0.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 +4 -4
- data/README.md +144 -162
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
- data/lib/ruby_llm/mcp/auth.rb +371 -0
- data/lib/ruby_llm/mcp/client.rb +312 -35
- data/lib/ruby_llm/mcp/configuration.rb +199 -24
- data/lib/ruby_llm/mcp/elicitation.rb +261 -14
- data/lib/ruby_llm/mcp/errors.rb +29 -0
- data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
- data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
- data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
- data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
- data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
- data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
- data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
- data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
- data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
- data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
- data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
- data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
- data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
- data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
- data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
- data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
- data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
- data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
- data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
- data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
- data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
- data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
- data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
- data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
- data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
- data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
- data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
- data/lib/ruby_llm/mcp/handlers.rb +14 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
- data/lib/ruby_llm/mcp/native/client.rb +551 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
- data/lib/ruby_llm/mcp/native/messages.rb +43 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
- data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
- data/lib/ruby_llm/mcp/prompt.rb +7 -7
- data/lib/ruby_llm/mcp/railtie.rb +7 -13
- data/lib/ruby_llm/mcp/resource.rb +17 -8
- data/lib/ruby_llm/mcp/resource_template.rb +8 -7
- data/lib/ruby_llm/mcp/result.rb +8 -4
- data/lib/ruby_llm/mcp/roots.rb +4 -4
- data/lib/ruby_llm/mcp/sample.rb +83 -13
- data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
- data/lib/ruby_llm/mcp/task.rb +65 -0
- data/lib/ruby_llm/mcp/tool.rb +33 -27
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +37 -7
- data/lib/tasks/smoke.rake +66 -0
- metadata +115 -39
- data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
- data/lib/ruby_llm/mcp/coordinator.rb +0 -293
- data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
- data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
- data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
- data/lib/ruby_llm/mcp/protocol.rb +0 -34
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
- data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
- data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
- data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
- data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
- data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
- data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
- data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
- data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
- data/lib/ruby_llm/mcp/response_handler.rb +0 -67
- data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
- data/lib/ruby_llm/mcp/responses/error.rb +0 -33
- data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
- data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
- data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
- data/lib/ruby_llm/mcp/transport.rb +0 -58
- data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
- data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
- data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
- data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
- data/lib/ruby_llm/mcp/transports/support/timeout.rb +0 -34
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Registry for tracking pending elicitations
|
|
7
|
+
# Provides thread-safe storage and retrieval for async completions
|
|
8
|
+
class ElicitationRegistry
|
|
9
|
+
class << self
|
|
10
|
+
# Get the singleton registry instance
|
|
11
|
+
def instance
|
|
12
|
+
@instance ||= new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Delegate class methods to instance
|
|
16
|
+
def store(id, elicitation, schedule_timeout: true)
|
|
17
|
+
instance.store(id, elicitation, schedule_timeout: schedule_timeout)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def retrieve(id)
|
|
21
|
+
instance.retrieve(id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remove(id)
|
|
25
|
+
instance.remove(id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def complete(id, response:)
|
|
29
|
+
instance.complete(id, response: response)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cancel(id, reason: "Cancelled")
|
|
33
|
+
instance.cancel(id, reason: reason)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
instance.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def size
|
|
41
|
+
instance.size
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@registry = {}
|
|
47
|
+
@timeouts = {}
|
|
48
|
+
@registry_mutex = Mutex.new
|
|
49
|
+
@timeouts_mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Store an elicitation in the registry
|
|
53
|
+
# @param id [String] elicitation ID
|
|
54
|
+
# @param elicitation [RubyLLM::MCP::Elicitation] elicitation object
|
|
55
|
+
def store(id, elicitation, schedule_timeout: true)
|
|
56
|
+
@registry_mutex.synchronize do
|
|
57
|
+
@registry[id] = elicitation
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Set up timeout if specified
|
|
61
|
+
if schedule_timeout && elicitation.timeout
|
|
62
|
+
schedule_timeout(id, elicitation.timeout)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
RubyLLM::MCP.logger.debug("Stored elicitation #{id} in registry")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Retrieve an elicitation from the registry
|
|
69
|
+
# @param id [String] elicitation ID
|
|
70
|
+
# @return [RubyLLM::MCP::Elicitation, nil] elicitation or nil if not found
|
|
71
|
+
def retrieve(id)
|
|
72
|
+
@registry_mutex.synchronize do
|
|
73
|
+
@registry[id]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Remove an elicitation from the registry
|
|
78
|
+
# @param id [String] elicitation ID
|
|
79
|
+
# @return [RubyLLM::MCP::Elicitation, nil] removed elicitation or nil
|
|
80
|
+
def remove(id)
|
|
81
|
+
elicitation = nil
|
|
82
|
+
|
|
83
|
+
# Cancel timeout first (before removing from registry)
|
|
84
|
+
cancel_timeout(id)
|
|
85
|
+
|
|
86
|
+
# Remove from registry
|
|
87
|
+
elicitation = @registry_mutex.synchronize do
|
|
88
|
+
@registry.delete(id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
RubyLLM::MCP.logger.debug("Removed elicitation #{id} from registry") if elicitation
|
|
92
|
+
elicitation
|
|
93
|
+
ensure
|
|
94
|
+
# Ensure timeout thread is cleaned up even if removal fails
|
|
95
|
+
cancel_timeout(id) unless elicitation
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Complete a pending elicitation
|
|
99
|
+
# @param id [String] elicitation ID
|
|
100
|
+
# @param response [Hash] response data
|
|
101
|
+
def complete(id, response:)
|
|
102
|
+
elicitation = retrieve(id)
|
|
103
|
+
|
|
104
|
+
if elicitation
|
|
105
|
+
RubyLLM::MCP.logger.info("Completing elicitation #{id}")
|
|
106
|
+
elicitation.complete(response)
|
|
107
|
+
remove(id)
|
|
108
|
+
else
|
|
109
|
+
RubyLLM::MCP.logger.warn("Attempted to complete unknown elicitation #{id}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Cancel a pending elicitation
|
|
114
|
+
# @param id [String] elicitation ID
|
|
115
|
+
# @param reason [String] cancellation reason
|
|
116
|
+
def cancel(id, reason: "Cancelled")
|
|
117
|
+
elicitation = retrieve(id)
|
|
118
|
+
|
|
119
|
+
if elicitation
|
|
120
|
+
RubyLLM::MCP.logger.info("Cancelling elicitation #{id}: #{reason}")
|
|
121
|
+
elicitation.cancel_async(reason)
|
|
122
|
+
remove(id)
|
|
123
|
+
else
|
|
124
|
+
RubyLLM::MCP.logger.warn("Attempted to cancel unknown elicitation #{id}")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Clear all pending elicitations
|
|
129
|
+
def clear
|
|
130
|
+
ids = @registry_mutex.synchronize { @registry.keys }
|
|
131
|
+
ids.each { |id| cancel_timeout(id) }
|
|
132
|
+
@registry_mutex.synchronize { @registry.clear }
|
|
133
|
+
RubyLLM::MCP.logger.debug("Cleared elicitation registry")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get number of pending elicitations
|
|
137
|
+
# @return [Integer] count of pending elicitations
|
|
138
|
+
def size
|
|
139
|
+
@registry_mutex.synchronize { @registry.size }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Schedule timeout for an elicitation
|
|
145
|
+
def schedule_timeout(id, timeout_seconds)
|
|
146
|
+
timeout_thread = Thread.new do
|
|
147
|
+
sleep timeout_seconds
|
|
148
|
+
handle_timeout(id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
@timeouts_mutex.synchronize do
|
|
152
|
+
@timeouts[id] = timeout_thread
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Cancel scheduled timeout
|
|
157
|
+
# Ensures thread is properly terminated and resources are freed
|
|
158
|
+
def cancel_timeout(id)
|
|
159
|
+
timeout_thread = @timeouts_mutex.synchronize do
|
|
160
|
+
@timeouts.delete(id)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return unless timeout_thread
|
|
164
|
+
|
|
165
|
+
# Safely terminate the thread
|
|
166
|
+
begin
|
|
167
|
+
timeout_thread.kill if timeout_thread.alive?
|
|
168
|
+
timeout_thread.join(0.1) # Wait briefly for cleanup
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
RubyLLM::MCP.logger.debug("Error cancelling timeout thread for #{id}: #{e.message}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Handle timeout event
|
|
175
|
+
def handle_timeout(id)
|
|
176
|
+
elicitation = retrieve(id)
|
|
177
|
+
|
|
178
|
+
if elicitation
|
|
179
|
+
RubyLLM::MCP.logger.warn("Elicitation #{id} timed out")
|
|
180
|
+
elicitation.timeout!
|
|
181
|
+
# Remove from registry without cancelling timeout (we're IN the timeout thread)
|
|
182
|
+
remove_without_timeout_cancel(id)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Remove from registry without cancelling timeout thread
|
|
187
|
+
# Used when called from within the timeout thread itself
|
|
188
|
+
def remove_without_timeout_cancel(id)
|
|
189
|
+
@registry_mutex.synchronize do
|
|
190
|
+
@registry.delete(id)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Clean up timeout thread reference
|
|
194
|
+
@timeouts_mutex.synchronize do
|
|
195
|
+
@timeouts.delete(id)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
RubyLLM::MCP.logger.debug("Removed elicitation #{id} from registry")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Base class for human-in-the-loop approval handlers
|
|
7
|
+
# Provides access to tool details, guards, and async support
|
|
8
|
+
#
|
|
9
|
+
# @example Basic approval handler
|
|
10
|
+
# class MyApprovalHandler < RubyLLM::MCP::Handlers::HumanInTheLoopHandler
|
|
11
|
+
# def execute
|
|
12
|
+
# if safe_tool?(tool_name)
|
|
13
|
+
# approve
|
|
14
|
+
# else
|
|
15
|
+
# deny("Tool requires approval")
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# private
|
|
20
|
+
#
|
|
21
|
+
# def safe_tool?(name)
|
|
22
|
+
# ["read_file", "list_files"].include?(name)
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Approval handler with guards and filtering
|
|
27
|
+
# class SecureApprovalHandler < RubyLLM::MCP::Handlers::HumanInTheLoopHandler
|
|
28
|
+
# allow_tools "read_file", "list_files"
|
|
29
|
+
# deny_tools "rm", "delete_all"
|
|
30
|
+
#
|
|
31
|
+
# guard :check_tool_safety
|
|
32
|
+
#
|
|
33
|
+
# def execute
|
|
34
|
+
# return deny("Tool denied") if tool_denied?
|
|
35
|
+
# approve
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# private
|
|
39
|
+
#
|
|
40
|
+
# def check_tool_safety
|
|
41
|
+
# return true if tool_allowed?
|
|
42
|
+
# "Tool not in safe list"
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# @example Async approval handler
|
|
47
|
+
# class AsyncApprovalHandler < RubyLLM::MCP::Handlers::HumanInTheLoopHandler
|
|
48
|
+
# async_execution timeout: 300
|
|
49
|
+
#
|
|
50
|
+
# on_timeout :handle_timeout_event
|
|
51
|
+
#
|
|
52
|
+
# def execute
|
|
53
|
+
# notify_user(tool_name, parameters)
|
|
54
|
+
# defer # Returns { status: :deferred, timeout: 300 }
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# private
|
|
58
|
+
#
|
|
59
|
+
# def handle_timeout_event
|
|
60
|
+
# deny("User did not respond in time")
|
|
61
|
+
# end
|
|
62
|
+
# end
|
|
63
|
+
class HumanInTheLoopHandler
|
|
64
|
+
include Concerns::Options
|
|
65
|
+
include Concerns::Lifecycle
|
|
66
|
+
include Concerns::Logging
|
|
67
|
+
include Concerns::ErrorHandling
|
|
68
|
+
include Concerns::AsyncExecution
|
|
69
|
+
include Concerns::Timeouts
|
|
70
|
+
include Concerns::GuardChecks
|
|
71
|
+
include Concerns::ToolFiltering
|
|
72
|
+
include Concerns::ApprovalActions
|
|
73
|
+
include Concerns::RegistryIntegration
|
|
74
|
+
|
|
75
|
+
attr_reader :coordinator
|
|
76
|
+
|
|
77
|
+
# Initialize human-in-the-loop handler
|
|
78
|
+
# @param tool_name [String] the tool name
|
|
79
|
+
# @param parameters [Hash] the tool parameters
|
|
80
|
+
# @param approval_id [String] unique identifier for this approval
|
|
81
|
+
# @param coordinator [Object] the coordinator managing the request
|
|
82
|
+
# @param options [Hash] handler-specific options
|
|
83
|
+
def initialize(tool_name:, parameters:, approval_id:, coordinator:, **options)
|
|
84
|
+
@tool_name = tool_name
|
|
85
|
+
@parameters = parameters
|
|
86
|
+
@approval_id = approval_id
|
|
87
|
+
@coordinator = coordinator
|
|
88
|
+
super(**options)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Registry for tracking pending human-in-the-loop approvals.
|
|
7
|
+
# Registries are scoped per native client, with global ID routing so
|
|
8
|
+
# approval IDs can still be completed externally.
|
|
9
|
+
class HumanInTheLoopRegistry
|
|
10
|
+
GLOBAL_OWNER = "__global__"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def instance
|
|
14
|
+
for_owner(GLOBAL_OWNER)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def for_owner(owner_id)
|
|
18
|
+
key = owner_id.to_s
|
|
19
|
+
registries_mutex.synchronize do
|
|
20
|
+
@registries ||= {}
|
|
21
|
+
@registries[key] ||= new(owner_id: key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def release(owner_id)
|
|
26
|
+
key = owner_id.to_s
|
|
27
|
+
registry = registries_mutex.synchronize { (@registries ||= {}).delete(key) }
|
|
28
|
+
registry&.shutdown
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Backward-compatible global store path.
|
|
32
|
+
def store(id, approval)
|
|
33
|
+
instance.store(id, approval)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retrieve(id)
|
|
37
|
+
route_registry(id)&.retrieve(id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def remove(id)
|
|
41
|
+
route_registry(id)&.remove(id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def approve(id)
|
|
45
|
+
registry = route_registry(id)
|
|
46
|
+
if registry
|
|
47
|
+
registry.approve(id)
|
|
48
|
+
else
|
|
49
|
+
RubyLLM::MCP.logger.warn("Attempted to approve unknown approval #{id}")
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deny(id, reason: "Denied")
|
|
55
|
+
registry = route_registry(id)
|
|
56
|
+
if registry
|
|
57
|
+
registry.deny(id, reason: reason)
|
|
58
|
+
else
|
|
59
|
+
RubyLLM::MCP.logger.warn("Attempted to deny unknown approval #{id}")
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear(owner_id: nil)
|
|
65
|
+
if owner_id
|
|
66
|
+
release(owner_id)
|
|
67
|
+
else
|
|
68
|
+
registries = registries_mutex.synchronize do
|
|
69
|
+
current = (@registries ||= {}).values
|
|
70
|
+
@registries = {}
|
|
71
|
+
current
|
|
72
|
+
end
|
|
73
|
+
registries.each(&:shutdown)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def size(owner_id: nil)
|
|
78
|
+
if owner_id
|
|
79
|
+
registry = registries_mutex.synchronize { (@registries ||= {})[owner_id.to_s] }
|
|
80
|
+
registry ? registry.size : 0
|
|
81
|
+
else
|
|
82
|
+
registries_mutex.synchronize { (@registries ||= {}).values.sum(&:size) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def register_approval(id, owner_id)
|
|
87
|
+
approval_index_mutex.synchronize do
|
|
88
|
+
@approval_index ||= {}
|
|
89
|
+
@approval_index[id.to_s] = owner_id.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unregister_approval(id)
|
|
94
|
+
approval_index_mutex.synchronize do
|
|
95
|
+
(@approval_index ||= {}).delete(id.to_s)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def route_registry(id)
|
|
100
|
+
owner_id = approval_index_mutex.synchronize { (@approval_index ||= {})[id.to_s] }
|
|
101
|
+
if owner_id
|
|
102
|
+
registries_mutex.synchronize { (@registries ||= {})[owner_id] }
|
|
103
|
+
else
|
|
104
|
+
registries_mutex.synchronize { (@registries ||= {})[GLOBAL_OWNER] }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def registries_mutex
|
|
111
|
+
@registries_mutex ||= Mutex.new
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def approval_index_mutex
|
|
115
|
+
@approval_index_mutex ||= Mutex.new
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attr_reader :owner_id
|
|
120
|
+
|
|
121
|
+
def initialize(owner_id:)
|
|
122
|
+
@owner_id = owner_id
|
|
123
|
+
@registry = {}
|
|
124
|
+
@deadlines = {}
|
|
125
|
+
@registry_mutex = Mutex.new
|
|
126
|
+
@condition = ConditionVariable.new
|
|
127
|
+
@stopped = false
|
|
128
|
+
start_timeout_scheduler
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Store approval context: { promise:, timeout:, tool_name:, parameters: }
|
|
132
|
+
def store(id, approval_context)
|
|
133
|
+
key = id.to_s
|
|
134
|
+
timeout = approval_context[:timeout]
|
|
135
|
+
|
|
136
|
+
@registry_mutex.synchronize do
|
|
137
|
+
@registry[key] = approval_context
|
|
138
|
+
if timeout
|
|
139
|
+
@deadlines[key] = monotonic_now + timeout.to_f
|
|
140
|
+
else
|
|
141
|
+
@deadlines.delete(key)
|
|
142
|
+
end
|
|
143
|
+
@condition.signal
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
self.class.register_approval(key, owner_id)
|
|
147
|
+
RubyLLM::MCP.logger.debug("Stored approval #{key} in registry for #{owner_id}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def retrieve(id)
|
|
151
|
+
@registry_mutex.synchronize { @registry[id.to_s] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def remove(id)
|
|
155
|
+
key = id.to_s
|
|
156
|
+
approval = nil
|
|
157
|
+
|
|
158
|
+
@registry_mutex.synchronize do
|
|
159
|
+
approval = @registry.delete(key)
|
|
160
|
+
@deadlines.delete(key)
|
|
161
|
+
@condition.signal
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
self.class.unregister_approval(key) if approval
|
|
165
|
+
RubyLLM::MCP.logger.debug("Removed approval #{key} from registry for #{owner_id}") if approval
|
|
166
|
+
approval
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def approve(id) # rubocop:disable Naming/PredicateMethod
|
|
170
|
+
approval = remove(id)
|
|
171
|
+
unless approval && approval[:promise]
|
|
172
|
+
RubyLLM::MCP.logger.warn("Attempted to approve unknown approval #{id}")
|
|
173
|
+
return false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
RubyLLM::MCP.logger.info("Approving #{id}")
|
|
177
|
+
approval[:promise].resolve(true)
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def deny(id, reason: "Denied") # rubocop:disable Naming/PredicateMethod
|
|
182
|
+
approval = remove(id)
|
|
183
|
+
unless approval && approval[:promise]
|
|
184
|
+
RubyLLM::MCP.logger.warn("Attempted to deny unknown approval #{id}")
|
|
185
|
+
return false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
RubyLLM::MCP.logger.info("Denying #{id}: #{reason}")
|
|
189
|
+
approval[:promise].resolve(false)
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def clear
|
|
194
|
+
approvals = @registry_mutex.synchronize do
|
|
195
|
+
current = @registry.dup
|
|
196
|
+
@registry.clear
|
|
197
|
+
@deadlines.clear
|
|
198
|
+
@condition.broadcast
|
|
199
|
+
current
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
approvals.each_key { |id| self.class.unregister_approval(id) }
|
|
203
|
+
RubyLLM::MCP.logger.debug("Cleared human-in-the-loop registry for #{owner_id}")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def size
|
|
207
|
+
@registry_mutex.synchronize { @registry.size }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def shutdown
|
|
211
|
+
clear
|
|
212
|
+
@registry_mutex.synchronize do
|
|
213
|
+
@stopped = true
|
|
214
|
+
@condition.broadcast
|
|
215
|
+
end
|
|
216
|
+
@scheduler_thread&.join(0.5)
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
RubyLLM::MCP.logger.debug("Error shutting down approval registry #{owner_id}: #{e.message}")
|
|
219
|
+
ensure
|
|
220
|
+
@scheduler_thread = nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def start_timeout_scheduler
|
|
226
|
+
@scheduler_thread = Thread.new do
|
|
227
|
+
loop do
|
|
228
|
+
expired_ids = wait_for_expired_ids
|
|
229
|
+
break if expired_ids.nil?
|
|
230
|
+
|
|
231
|
+
expired_ids.each { |id| handle_timeout(id) }
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def wait_for_expired_ids
|
|
237
|
+
@registry_mutex.synchronize do
|
|
238
|
+
loop do
|
|
239
|
+
return nil if @stopped
|
|
240
|
+
|
|
241
|
+
now = monotonic_now
|
|
242
|
+
expired_ids = @deadlines.each_with_object([]) do |(id, deadline), ids|
|
|
243
|
+
ids << id if deadline <= now
|
|
244
|
+
end
|
|
245
|
+
return expired_ids unless expired_ids.empty?
|
|
246
|
+
|
|
247
|
+
if @deadlines.empty?
|
|
248
|
+
@condition.wait(@registry_mutex)
|
|
249
|
+
else
|
|
250
|
+
wait_time = @deadlines.values.min - now
|
|
251
|
+
@condition.wait(@registry_mutex, wait_time) if wait_time.positive?
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def handle_timeout(id)
|
|
258
|
+
approval = remove(id)
|
|
259
|
+
return unless approval && approval[:promise]
|
|
260
|
+
|
|
261
|
+
RubyLLM::MCP.logger.warn("Approval #{id} timed out")
|
|
262
|
+
approval[:promise].resolve(false)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def monotonic_now
|
|
266
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|