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,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
|