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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  4. data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
  5. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  19. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  20. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  21. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  25. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  26. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  27. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
  28. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  29. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  30. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  31. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
  32. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  33. data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
  34. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
  35. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
  36. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  37. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  38. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  39. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  40. data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
  41. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
  42. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  43. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  44. data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
  45. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  46. data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
  47. data/lib/ruby_llm/mcp/auth.rb +371 -0
  48. data/lib/ruby_llm/mcp/client.rb +312 -35
  49. data/lib/ruby_llm/mcp/configuration.rb +199 -24
  50. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  51. data/lib/ruby_llm/mcp/errors.rb +29 -0
  52. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  53. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  54. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  55. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  56. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  57. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  58. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  59. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  60. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  61. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  62. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  63. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  64. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  65. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  66. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  67. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  68. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  69. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  70. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  71. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  72. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  73. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  74. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  75. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  76. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  77. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  78. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  79. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  80. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  81. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  82. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  83. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  84. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  85. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  86. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  87. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  88. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  89. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  90. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  91. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  92. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  93. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  94. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  95. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  96. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  97. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  98. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  99. data/lib/ruby_llm/mcp/native.rb +12 -0
  100. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  101. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  102. data/lib/ruby_llm/mcp/railtie.rb +7 -13
  103. data/lib/ruby_llm/mcp/resource.rb +17 -8
  104. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  105. data/lib/ruby_llm/mcp/result.rb +8 -4
  106. data/lib/ruby_llm/mcp/roots.rb +4 -4
  107. data/lib/ruby_llm/mcp/sample.rb +83 -13
  108. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  109. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  110. data/lib/ruby_llm/mcp/task.rb +65 -0
  111. data/lib/ruby_llm/mcp/tool.rb +33 -27
  112. data/lib/ruby_llm/mcp/version.rb +1 -1
  113. data/lib/ruby_llm/mcp.rb +37 -7
  114. data/lib/tasks/smoke.rake +66 -0
  115. metadata +115 -39
  116. data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
  117. data/lib/ruby_llm/mcp/coordinator.rb +0 -293
  118. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  119. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  120. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  121. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  122. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  123. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  124. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  125. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  126. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  127. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  128. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  129. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  130. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  131. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  132. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  133. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  134. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  135. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  136. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  137. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  138. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  139. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  140. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  141. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  142. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  143. data/lib/ruby_llm/mcp/transport.rb +0 -58
  144. data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
  145. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
  146. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
  147. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  148. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  149. 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