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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Extensions
6
+ module Apps
7
+ class ResourceMetadata
8
+ attr_reader :csp, :permissions, :domain, :prefers_border, :raw
9
+
10
+ def initialize(raw)
11
+ @raw = raw.is_a?(Hash) ? raw : {}
12
+
13
+ ui_meta = @raw[Constants::UI_KEY]
14
+ @csp = ui_meta&.dig(Constants::CSP_KEY)
15
+ @permissions = ui_meta&.dig(Constants::PERMISSIONS_KEY)
16
+ @domain = ui_meta&.dig(Constants::DOMAIN_KEY)
17
+ @prefers_border = ui_meta&.dig(Constants::PREFERS_BORDER_KEY)
18
+ @prefers_border = ui_meta&.dig(Constants::PREFERS_BORDER_ALT_KEY) if @prefers_border.nil?
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Extensions
6
+ module Apps
7
+ class ToolMetadata
8
+ attr_reader :resource_uri, :visibility, :raw
9
+
10
+ def initialize(raw)
11
+ @raw = raw.is_a?(Hash) ? raw : {}
12
+
13
+ ui_meta = @raw[Constants::UI_KEY]
14
+ @resource_uri = ui_meta&.dig(Constants::RESOURCE_URI_KEY) || @raw[Constants::LEGACY_RESOURCE_URI_KEY]
15
+
16
+ @visibility = normalize_visibility(ui_meta&.dig(Constants::VISIBILITY_KEY))
17
+ end
18
+
19
+ def model_visible?
20
+ @visibility.include?("model")
21
+ end
22
+
23
+ def app_visible?
24
+ @visibility.include?("app")
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_visibility(value)
30
+ normalized = case value
31
+ when nil
32
+ Constants::DEFAULT_VISIBILITY
33
+ when Array
34
+ value
35
+ else
36
+ [value]
37
+ end
38
+
39
+ normalized.map(&:to_s)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Extensions
6
+ class Configuration
7
+ def initialize
8
+ reset!
9
+ end
10
+
11
+ def register(id, config = {})
12
+ canonical_id = Registry.canonicalize_id(id)
13
+ if canonical_id.nil?
14
+ raise ArgumentError, "Extension id is required"
15
+ end
16
+
17
+ unless config.nil? || config.is_a?(Hash)
18
+ raise ArgumentError, "Extension config for '#{canonical_id}' must be a Hash"
19
+ end
20
+
21
+ @extensions = Registry.merge(@extensions, { canonical_id => config })
22
+ self
23
+ end
24
+
25
+ def enable_apps(config = {})
26
+ normalized = Registry.deep_stringify_keys(config || {})
27
+ validate_apps_config!(normalized)
28
+ normalized[Apps::Constants::MIME_TYPES_KEY] ||= [Apps::Constants::APP_HTML_MIME_TYPE]
29
+
30
+ register(Constants::UI_EXTENSION_ID, normalized)
31
+ end
32
+
33
+ def to_h
34
+ Registry.normalize_map(@extensions)
35
+ end
36
+
37
+ def empty?
38
+ to_h.empty?
39
+ end
40
+
41
+ def reset!
42
+ @extensions = {}
43
+ self
44
+ end
45
+
46
+ private
47
+
48
+ def validate_apps_config!(config)
49
+ misplaced_keys = [
50
+ Apps::Constants::UI_KEY,
51
+ Apps::Constants::RESOURCE_URI_KEY,
52
+ Apps::Constants::LEGACY_RESOURCE_URI_KEY,
53
+ Apps::Constants::VISIBILITY_KEY
54
+ ]
55
+
56
+ if misplaced_keys.any? { |key| config.key?(key) }
57
+ raise ArgumentError,
58
+ "MCP Apps extension config uses client capability fields (for example, 'mimeTypes'); " \
59
+ "tool metadata fields like 'resourceUri' and 'visibility' belong in tool _meta.ui"
60
+ end
61
+
62
+ mime_types = config[Apps::Constants::MIME_TYPES_KEY]
63
+ return if mime_types.nil?
64
+
65
+ unless mime_types.is_a?(Array) && mime_types.all? { |value| value.is_a?(String) && !value.empty? }
66
+ raise ArgumentError, "'mimeTypes' must be an array of non-empty strings"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Extensions
6
+ module Constants
7
+ UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
8
+ APPS_EXTENSION_ALIAS = "io.modelcontextprotocol/apps"
9
+
10
+ EXTENSION_ALIASES = {
11
+ APPS_EXTENSION_ALIAS => UI_EXTENSION_ID
12
+ }.freeze
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Extensions
6
+ module Registry
7
+ module_function
8
+
9
+ def canonicalize_id(id)
10
+ return nil if id.nil?
11
+
12
+ normalized = id.to_s.strip
13
+ return nil if normalized.empty?
14
+
15
+ Constants::EXTENSION_ALIASES.fetch(normalized, normalized)
16
+ end
17
+
18
+ def normalize_map(value)
19
+ return {} unless value.is_a?(Hash)
20
+
21
+ value.each_with_object({}) do |(id, config), acc|
22
+ canonical_id = canonicalize_id(id)
23
+ next if canonical_id.nil?
24
+
25
+ acc[canonical_id] = normalize_value(config)
26
+ end
27
+ end
28
+
29
+ def merge(global_extensions, client_extensions)
30
+ merged = normalize_map(global_extensions)
31
+ normalize_map(client_extensions).each do |id, config|
32
+ merged[id] = deep_merge_values(merged[id], config)
33
+ end
34
+ merged
35
+ end
36
+
37
+ def deep_merge_values(base_value, override_value)
38
+ if base_value.is_a?(Hash) && override_value.is_a?(Hash)
39
+ deep_merge_hashes(base_value, override_value)
40
+ else
41
+ normalize_value(override_value)
42
+ end
43
+ end
44
+
45
+ def normalize_value(value)
46
+ case value
47
+ when nil
48
+ {}
49
+ when Hash
50
+ deep_stringify_keys(value)
51
+ else
52
+ value
53
+ end
54
+ end
55
+
56
+ def deep_stringify_keys(value)
57
+ case value
58
+ when Hash
59
+ value.each_with_object({}) do |(key, nested_value), acc|
60
+ acc[key.to_s] = deep_stringify_keys(nested_value)
61
+ end
62
+ when Array
63
+ value.map { |item| deep_stringify_keys(item) }
64
+ else
65
+ value
66
+ end
67
+ end
68
+
69
+ def deep_merge_hashes(base_hash, override_hash)
70
+ merged = base_hash.dup
71
+
72
+ override_hash.each do |key, value|
73
+ merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
74
+ deep_merge_hashes(merged[key], value)
75
+ else
76
+ deep_stringify_keys(value)
77
+ end
78
+ end
79
+
80
+ merged
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ # Normalized decision returned by human-in-the-loop handlers.
7
+ class ApprovalDecision
8
+ VALID_STATUSES = %i[approved denied deferred].freeze
9
+
10
+ attr_reader :status, :reason, :approval_id, :timeout, :promise
11
+
12
+ def initialize(status:, reason: nil, approval_id: nil, timeout: nil, promise: nil)
13
+ @status = status.to_sym
14
+ @reason = reason
15
+ @approval_id = approval_id
16
+ @timeout = timeout
17
+ @promise = promise
18
+ end
19
+
20
+ def self.approved
21
+ new(status: :approved)
22
+ end
23
+
24
+ def self.denied(reason: "Denied by user")
25
+ new(status: :denied, reason: reason)
26
+ end
27
+
28
+ def self.deferred(approval_id:, timeout:)
29
+ new(status: :deferred, approval_id: approval_id, timeout: timeout)
30
+ end
31
+
32
+ def self.from_handler_result(result, approval_id:, default_timeout: nil)
33
+ unless result.is_a?(Hash)
34
+ raise Errors::InvalidApprovalDecision.new(
35
+ message: "Human-in-the-loop handler must return a Hash, got #{result.class}"
36
+ )
37
+ end
38
+
39
+ status = (result[:status] || result["status"])&.to_sym
40
+ unless VALID_STATUSES.include?(status)
41
+ raise Errors::InvalidApprovalDecision.new(
42
+ message: "Human-in-the-loop handler returned invalid status '#{status.inspect}'"
43
+ )
44
+ end
45
+
46
+ case status
47
+ when :approved
48
+ approved
49
+ when :denied
50
+ denied(reason: result[:reason] || result["reason"] || "Denied by user")
51
+ when :deferred
52
+ timeout = result[:timeout] || result["timeout"] || default_timeout
53
+ validate_timeout!(timeout)
54
+ deferred(approval_id: approval_id, timeout: timeout.to_f)
55
+ end
56
+ end
57
+
58
+ def with_promise(promise)
59
+ self.class.new(
60
+ status: status,
61
+ reason: reason,
62
+ approval_id: approval_id,
63
+ timeout: timeout,
64
+ promise: promise
65
+ )
66
+ end
67
+
68
+ def approved?
69
+ status == :approved
70
+ end
71
+
72
+ def denied?
73
+ status == :denied
74
+ end
75
+
76
+ def deferred?
77
+ status == :deferred
78
+ end
79
+
80
+ private_class_method def self.validate_timeout!(timeout)
81
+ unless timeout.is_a?(Numeric) && timeout.positive?
82
+ raise Errors::InvalidApprovalDecision.new(
83
+ message: "Deferred approvals require a positive timeout"
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ # Represents an async response for deferred completion
7
+ class AsyncResponse
8
+ attr_reader :elicitation_id, :state, :result, :error
9
+
10
+ VALID_STATES = %i[pending completed rejected cancelled timed_out].freeze
11
+
12
+ # Initialize async response
13
+ # @param elicitation_id [String] ID of the elicitation
14
+ # @param timeout [Integer, nil] optional timeout in seconds
15
+ # @param timeout_handler [Proc, Symbol, nil] handler for timeout
16
+ def initialize(elicitation_id:, timeout: nil, timeout_handler: nil)
17
+ @elicitation_id = elicitation_id
18
+ @state = :pending
19
+ @result = nil
20
+ @error = nil
21
+ @mutex = Mutex.new
22
+ @timeout = timeout
23
+ @timeout_handler = timeout_handler
24
+ @completion_callbacks = []
25
+ @created_at = Time.now
26
+
27
+ RubyLLM::MCP.logger.debug("AsyncResponse created for #{@elicitation_id} with timeout: #{@timeout || 'none'}")
28
+ schedule_timeout if @timeout
29
+ end
30
+
31
+ # Complete the async operation with data
32
+ # @param data [Object] the completion data
33
+ def complete(data)
34
+ callbacks_to_execute = nil
35
+
36
+ transitioned = transition_state(:completed) do
37
+ @result = data
38
+ callbacks_to_execute = @completion_callbacks.dup
39
+ end
40
+
41
+ if transitioned
42
+ duration = Time.now - @created_at
43
+ RubyLLM::MCP.logger.debug("AsyncResponse #{@elicitation_id} completed after #{duration.round(3)}s")
44
+ end
45
+
46
+ # Execute callbacks outside mutex to avoid deadlocks
47
+ execute_callbacks_safely(callbacks_to_execute, :completed, data) if transitioned && callbacks_to_execute
48
+ end
49
+
50
+ # Reject the async operation
51
+ # @param reason [String] reason for rejection
52
+ def reject(reason)
53
+ callbacks_to_execute = nil
54
+
55
+ transitioned = transition_state(:rejected) do
56
+ @error = reason
57
+ callbacks_to_execute = @completion_callbacks.dup
58
+ end
59
+
60
+ execute_callbacks_safely(callbacks_to_execute, :rejected, reason) if transitioned && callbacks_to_execute
61
+ end
62
+
63
+ # Cancel the async operation
64
+ # @param reason [String] reason for cancellation
65
+ def cancel(reason)
66
+ callbacks_to_execute = nil
67
+
68
+ transitioned = transition_state(:cancelled) do
69
+ @error = reason
70
+ callbacks_to_execute = @completion_callbacks.dup
71
+ end
72
+
73
+ execute_callbacks_safely(callbacks_to_execute, :cancelled, reason) if transitioned && callbacks_to_execute
74
+ end
75
+
76
+ # Mark as timed out
77
+ def timeout!
78
+ callbacks_to_execute = nil
79
+
80
+ transitioned = transition_state(:timed_out) do
81
+ @error = "Operation timed out"
82
+ callbacks_to_execute = @completion_callbacks.dup
83
+ end
84
+
85
+ execute_callbacks_safely(callbacks_to_execute, :timed_out, @error) if transitioned && callbacks_to_execute
86
+ end
87
+
88
+ # Register a callback for when operation completes/fails
89
+ # @param callback [Proc] callback to execute
90
+ def on_complete(&callback)
91
+ @mutex.synchronize do
92
+ @completion_callbacks << callback
93
+ end
94
+ end
95
+
96
+ # Check if operation is pending
97
+ def pending?
98
+ @state == :pending
99
+ end
100
+
101
+ # Check if operation is completed
102
+ def completed?
103
+ @state == :completed
104
+ end
105
+
106
+ # Check if operation is rejected
107
+ def rejected?
108
+ @state == :rejected
109
+ end
110
+
111
+ # Check if operation is cancelled
112
+ def cancelled?
113
+ @state == :cancelled
114
+ end
115
+
116
+ # Check if operation timed out
117
+ def timed_out?
118
+ @state == :timed_out
119
+ end
120
+
121
+ # Check if operation is finished (any terminal state)
122
+ def finished?
123
+ !pending?
124
+ end
125
+
126
+ private
127
+
128
+ # Transition to new state (thread-safe)
129
+ def transition_state(new_state)
130
+ @mutex.synchronize do
131
+ return false unless @state == :pending
132
+ return false unless VALID_STATES.include?(new_state)
133
+
134
+ @state = new_state
135
+ yield if block_given?
136
+ true
137
+ end
138
+ end
139
+
140
+ # Execute callbacks safely in isolation
141
+ # @param callbacks [Array] callbacks to execute
142
+ # @param state [Symbol] the state to pass to callbacks
143
+ # @param data [Object] the data to pass to callbacks
144
+ def execute_callbacks_safely(callbacks, state, data)
145
+ return unless callbacks
146
+
147
+ callbacks.each do |callback|
148
+ callback.call(state, data)
149
+ rescue StandardError => e
150
+ RubyLLM::MCP.logger.error(
151
+ "Error in async response callback: #{e.message}\n#{e.backtrace.join("\n")}"
152
+ )
153
+ # Continue executing other callbacks even if one fails
154
+ end
155
+ end
156
+
157
+ # Schedule timeout check
158
+ def schedule_timeout
159
+ Thread.new do
160
+ sleep @timeout
161
+ if pending?
162
+ timeout!
163
+ handle_timeout
164
+ end
165
+ end
166
+ end
167
+
168
+ # Handle timeout event
169
+ def handle_timeout
170
+ if @timeout_handler.is_a?(Proc)
171
+ @timeout_handler.call
172
+ end
173
+ rescue StandardError => e
174
+ RubyLLM::MCP.logger.error(
175
+ "Error in timeout handler: #{e.message}\n#{e.backtrace.join("\n")}"
176
+ )
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ module Concerns
7
+ # Provides action methods for human-in-the-loop approval handlers
8
+ module ApprovalActions
9
+ attr_reader :approval_id, :tool_name, :parameters
10
+
11
+ protected
12
+
13
+ # Approve the tool execution
14
+ # @return [Hash] structured approval response
15
+ def approve
16
+ { status: :approved }
17
+ end
18
+
19
+ # Deny the tool execution
20
+ # @param reason [String] reason for denial
21
+ # @return [Hash] structured denial response
22
+ def deny(reason = "Denied by user")
23
+ { status: :denied, reason: reason }
24
+ end
25
+
26
+ # Defer approval to complete asynchronously via registry.
27
+ # @param timeout [Numeric, nil] timeout in seconds; falls back to handler timeout
28
+ # @return [Hash] structured deferred response
29
+ def defer(timeout: nil)
30
+ resolved_timeout = timeout || (respond_to?(:timeout) ? self.timeout : nil)
31
+ { status: :deferred, timeout: resolved_timeout }
32
+ end
33
+
34
+ # Override guard_failed to return denial (if GuardChecks is included)
35
+ def guard_failed(message)
36
+ deny(message)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ module Concerns
7
+ # Provides async execution capabilities for handlers
8
+ module AsyncExecution
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ # Mark this handler as async (returns pending response)
15
+ # @param timeout [Integer, nil] optional timeout in seconds
16
+ def async_execution(timeout: nil)
17
+ @async = true
18
+ @async_timeout = timeout if timeout
19
+ end
20
+
21
+ # Check if handler is async
22
+ def async?
23
+ @async == true
24
+ end
25
+
26
+ # Get async timeout
27
+ attr_reader :async_timeout
28
+
29
+ # Inherit async settings from parent classes
30
+ def inherited(subclass)
31
+ super
32
+ subclass.instance_variable_set(:@async, @async)
33
+ subclass.instance_variable_set(:@async_timeout, @async_timeout)
34
+ end
35
+ end
36
+
37
+ # Check if this handler instance is async
38
+ def async?
39
+ self.class.async?
40
+ end
41
+
42
+ # Get timeout value for this handler
43
+ def timeout
44
+ @options[:timeout] || self.class.async_timeout
45
+ end
46
+
47
+ protected
48
+
49
+ # Create an async response for deferred completion
50
+ # @param elicitation_id [String, nil] ID for the async operation (auto-detected if not provided)
51
+ # @param timeout_handler [Proc, Symbol, nil] handler for timeout
52
+ # @return [AsyncResponse] async response object
53
+ def defer(elicitation_id: nil, timeout_handler: nil)
54
+ # Auto-detect ID from elicitation or approval_id if not provided
55
+ id = elicitation_id ||
56
+ (respond_to?(:elicitation) && elicitation&.id) ||
57
+ (respond_to?(:approval_id) && approval_id)
58
+
59
+ unless id
60
+ raise ArgumentError,
61
+ "elicitation_id must be provided or handler must have elicitation/approval_id"
62
+ end
63
+
64
+ AsyncResponse.new(
65
+ elicitation_id: id,
66
+ timeout: timeout,
67
+ timeout_handler: timeout_handler
68
+ )
69
+ end
70
+
71
+ # Create a promise for async operations
72
+ # @return [Promise] promise object
73
+ def create_promise
74
+ Promise.new
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ module Concerns
7
+ # Provides action methods for elicitation request handlers
8
+ module ElicitationActions
9
+ attr_reader :elicitation
10
+
11
+ protected
12
+
13
+ # Accept the elicitation with structured response
14
+ # @param response [Hash] the structured response data
15
+ # @return [Hash] structured acceptance response
16
+ def accept(response)
17
+ { action: :accept, response: response }
18
+ end
19
+
20
+ # Reject the elicitation
21
+ # @param reason [String] reason for rejection
22
+ # @return [Hash] structured rejection response
23
+ def reject(reason)
24
+ { action: :reject, reason: reason }
25
+ end
26
+
27
+ # Cancel the elicitation
28
+ # @param reason [String] reason for cancellation
29
+ # @return [Hash] structured cancellation response
30
+ def cancel(reason)
31
+ { action: :cancel, reason: reason }
32
+ end
33
+
34
+ # Default action when timeout occurs
35
+ def default_timeout_action
36
+ reject("Elicitation timed out")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end