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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM::MCP::Adapters::MCPTransports
|
|
4
|
+
# Custom SSE transport for MCP SDK adapter
|
|
5
|
+
# Wraps the native SSE transport to provide the interface expected by MCP::Client
|
|
6
|
+
class SSE
|
|
7
|
+
attr_reader :native_transport
|
|
8
|
+
|
|
9
|
+
def initialize(url:, headers: {}, version: :http2, request_timeout: 10_000, # rubocop:disable Metrics/ParameterLists
|
|
10
|
+
protocol_version: RubyLLM::MCP.config.protocol_version, notification_callback: nil)
|
|
11
|
+
# Create a minimal coordinator-like object for the native transport
|
|
12
|
+
@coordinator = CoordinatorStub.new(
|
|
13
|
+
protocol_version: protocol_version,
|
|
14
|
+
notification_callback: notification_callback
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@native_transport = RubyLLM::MCP::Native::Transports::SSE.new(
|
|
18
|
+
url: url,
|
|
19
|
+
coordinator: @coordinator,
|
|
20
|
+
request_timeout: request_timeout,
|
|
21
|
+
options: {
|
|
22
|
+
headers: headers,
|
|
23
|
+
version: version
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@native_transport.start
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close
|
|
33
|
+
@native_transport.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Send a JSON-RPC request and return the response
|
|
37
|
+
# This is the interface expected by MCP::Client
|
|
38
|
+
#
|
|
39
|
+
# @param request [Hash] A JSON-RPC request object
|
|
40
|
+
# @return [Hash] A JSON-RPC response object
|
|
41
|
+
def send_request(request:)
|
|
42
|
+
start unless @native_transport.alive?
|
|
43
|
+
|
|
44
|
+
unless request["id"] || request[:id]
|
|
45
|
+
request["id"] = SecureRandom.uuid
|
|
46
|
+
end
|
|
47
|
+
result = @native_transport.request(request, wait_for_response: true)
|
|
48
|
+
|
|
49
|
+
if result.is_a?(RubyLLM::MCP::Result)
|
|
50
|
+
result.response
|
|
51
|
+
else
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM::MCP::Adapters::MCPTransports
|
|
4
|
+
# Custom Stdio transport for MCP SDK adapter
|
|
5
|
+
# Wraps the native Stdio transport to provide the interface expected by MCP::Client
|
|
6
|
+
class Stdio
|
|
7
|
+
attr_reader :native_transport
|
|
8
|
+
|
|
9
|
+
def initialize(command:, args: [], env: {}, request_timeout: 10_000, # rubocop:disable Metrics/ParameterLists
|
|
10
|
+
protocol_version: RubyLLM::MCP.config.protocol_version, notification_callback: nil)
|
|
11
|
+
# Create a minimal coordinator-like object for the native transport
|
|
12
|
+
@coordinator = CoordinatorStub.new(
|
|
13
|
+
protocol_version: protocol_version,
|
|
14
|
+
notification_callback: notification_callback
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@native_transport = RubyLLM::MCP::Native::Transports::Stdio.new(
|
|
18
|
+
command: command,
|
|
19
|
+
args: args,
|
|
20
|
+
env: env,
|
|
21
|
+
coordinator: @coordinator,
|
|
22
|
+
request_timeout: request_timeout
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@coordinator.transport = @native_transport
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@native_transport.start
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close
|
|
33
|
+
@native_transport.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Send a JSON-RPC request and return the response
|
|
37
|
+
# This is the interface expected by MCP::Client
|
|
38
|
+
#
|
|
39
|
+
# @param request [Hash] A JSON-RPC request object
|
|
40
|
+
# @return [Hash] A JSON-RPC response object
|
|
41
|
+
def send_request(request:)
|
|
42
|
+
start unless @native_transport.alive?
|
|
43
|
+
|
|
44
|
+
unless request["id"] || request[:id]
|
|
45
|
+
request["id"] = SecureRandom.uuid
|
|
46
|
+
end
|
|
47
|
+
result = @native_transport.request(request, wait_for_response: true)
|
|
48
|
+
|
|
49
|
+
if result.is_a?(RubyLLM::MCP::Result)
|
|
50
|
+
result.response
|
|
51
|
+
else
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM::MCP::Adapters::MCPTransports
|
|
4
|
+
# Custom Streamable HTTP transport for MCP SDK adapter
|
|
5
|
+
# Wraps the native StreamableHTTP transport to provide the interface expected by MCP::Client
|
|
6
|
+
class StreamableHTTP
|
|
7
|
+
attr_reader :native_transport
|
|
8
|
+
|
|
9
|
+
def initialize(url:, headers: {}, version: :http2, request_timeout: 10_000, # rubocop:disable Metrics/ParameterLists
|
|
10
|
+
reconnection: {}, oauth_provider: nil, rate_limit: nil, session_id: nil,
|
|
11
|
+
protocol_version: RubyLLM::MCP.config.protocol_version, notification_callback: nil)
|
|
12
|
+
# Create a minimal coordinator-like object for the native transport
|
|
13
|
+
@coordinator = CoordinatorStub.new(
|
|
14
|
+
protocol_version: protocol_version,
|
|
15
|
+
notification_callback: notification_callback
|
|
16
|
+
)
|
|
17
|
+
@initialized = false
|
|
18
|
+
|
|
19
|
+
@native_transport = RubyLLM::MCP::Native::Transports::StreamableHTTP.new(
|
|
20
|
+
url: url,
|
|
21
|
+
headers: headers,
|
|
22
|
+
version: version,
|
|
23
|
+
coordinator: @coordinator,
|
|
24
|
+
request_timeout: request_timeout,
|
|
25
|
+
reconnection: reconnection,
|
|
26
|
+
oauth_provider: oauth_provider,
|
|
27
|
+
rate_limit: rate_limit,
|
|
28
|
+
session_id: session_id
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@coordinator.transport = @native_transport
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start
|
|
35
|
+
@native_transport.start
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
@initialized = false
|
|
40
|
+
@native_transport.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Send a JSON-RPC request and return the response
|
|
44
|
+
# This is the interface expected by MCP::Client
|
|
45
|
+
#
|
|
46
|
+
# @param request [Hash] A JSON-RPC request object
|
|
47
|
+
# @return [Hash] A JSON-RPC response object
|
|
48
|
+
def send_request(request:)
|
|
49
|
+
# Auto-initialize on first non-initialize request
|
|
50
|
+
# Streamable HTTP servers require initialization before other requests
|
|
51
|
+
unless @initialized || request[:method] == "initialize" || request["method"] == "initialize"
|
|
52
|
+
perform_initialization
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
unless request["id"] || request[:id]
|
|
56
|
+
request["id"] = SecureRandom.uuid
|
|
57
|
+
end
|
|
58
|
+
result = @native_transport.request(request, wait_for_response: true)
|
|
59
|
+
|
|
60
|
+
if result.is_a?(RubyLLM::MCP::Result)
|
|
61
|
+
result.response
|
|
62
|
+
else
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def perform_initialization
|
|
70
|
+
# Send initialization request
|
|
71
|
+
init_request = RubyLLM::MCP::Native::Messages::Requests.initialize(
|
|
72
|
+
protocol_version: @coordinator.protocol_version,
|
|
73
|
+
capabilities: @coordinator.client_capabilities
|
|
74
|
+
)
|
|
75
|
+
result = @native_transport.request(init_request, wait_for_response: true)
|
|
76
|
+
|
|
77
|
+
if result.is_a?(RubyLLM::MCP::Result) && result.error?
|
|
78
|
+
raise RubyLLM::MCP::Errors::TransportError.new(
|
|
79
|
+
message: "Initialization failed: #{result.error}",
|
|
80
|
+
error: result.error
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
initialized_notification = RubyLLM::MCP::Native::Messages::Notifications.initialized
|
|
85
|
+
@native_transport.request(initialized_notification, wait_for_response: false)
|
|
86
|
+
|
|
87
|
+
@initialized = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module MCP
|
|
7
|
+
module Adapters
|
|
8
|
+
# RubyLLM Adapter - wraps the Native protocol implementation
|
|
9
|
+
# This is a thin bridge between the public API and Native::Client
|
|
10
|
+
class RubyLLMAdapter < BaseAdapter
|
|
11
|
+
extend Forwardable
|
|
12
|
+
|
|
13
|
+
supports :tools, :prompts, :resources, :resource_templates,
|
|
14
|
+
:completions, :logging, :sampling, :roots,
|
|
15
|
+
:notifications, :progress_tracking, :human_in_the_loop,
|
|
16
|
+
:elicitation, :subscriptions, :list_changed_notifications,
|
|
17
|
+
:tasks
|
|
18
|
+
|
|
19
|
+
supports_transport :stdio, :sse, :streamable, :streamable_http
|
|
20
|
+
|
|
21
|
+
attr_reader :native_client
|
|
22
|
+
|
|
23
|
+
def initialize(client, transport_type:, config: {})
|
|
24
|
+
validate_transport!(transport_type)
|
|
25
|
+
super
|
|
26
|
+
|
|
27
|
+
request_timeout = config.delete(:request_timeout)
|
|
28
|
+
protocol_version = @config[:protocol_version]
|
|
29
|
+
extensions_capabilities = build_client_extensions_capabilities(protocol_version: protocol_version)
|
|
30
|
+
transport_config = prepare_transport_config(config, transport_type)
|
|
31
|
+
|
|
32
|
+
@native_client = Native::Client.new(
|
|
33
|
+
name: client.name,
|
|
34
|
+
transport_type: transport_type,
|
|
35
|
+
transport_config: transport_config,
|
|
36
|
+
request_timeout: request_timeout,
|
|
37
|
+
human_in_the_loop_callback: build_human_in_the_loop_callback(client),
|
|
38
|
+
roots_callback: -> { client.roots.paths },
|
|
39
|
+
logging_enabled: client.logging_handler_enabled?,
|
|
40
|
+
logging_level: client.on_logging_level,
|
|
41
|
+
# Always advertise elicitation capability for native adapter clients.
|
|
42
|
+
# If no handler is set, requests are safely rejected by the callback path.
|
|
43
|
+
elicitation_enabled: true,
|
|
44
|
+
progress_tracking_enabled: client.tracking_progress?,
|
|
45
|
+
elicitation_callback: build_elicitation_callback(client),
|
|
46
|
+
sampling_callback: build_sampling_callback(client),
|
|
47
|
+
notification_callback: ->(notification) { NotificationHandler.new(client).execute(notification) },
|
|
48
|
+
protocol_version: @config[:protocol_version],
|
|
49
|
+
extensions_capabilities: extensions_capabilities
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def_delegators :@native_client,
|
|
54
|
+
:start, :stop, :restart!, :alive?, :ping,
|
|
55
|
+
:capabilities, :client_capabilities, :protocol_version,
|
|
56
|
+
:tool_list, :execute_tool,
|
|
57
|
+
:resource_list, :resource_read, :resource_template_list,
|
|
58
|
+
:resources_subscribe, :resources_unsubscribe,
|
|
59
|
+
:prompt_list, :execute_prompt,
|
|
60
|
+
:tasks_list, :task_get, :task_result, :task_cancel, :task_status_notification,
|
|
61
|
+
:completion_resource, :completion_prompt,
|
|
62
|
+
:set_logging, :set_progress_tracking,
|
|
63
|
+
:set_elicitation_enabled,
|
|
64
|
+
:initialize_notification, :cancelled_notification,
|
|
65
|
+
:roots_list_change_notification,
|
|
66
|
+
:ping_response, :roots_list_response,
|
|
67
|
+
:sampling_create_message_response,
|
|
68
|
+
:error_response, :elicitation_response,
|
|
69
|
+
:register_in_flight_request, :unregister_in_flight_request,
|
|
70
|
+
:cancel_in_flight_request
|
|
71
|
+
|
|
72
|
+
def supports_extension_negotiation?
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extension_mode
|
|
77
|
+
:full
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_client_extensions_capabilities(protocol_version:)
|
|
81
|
+
return {} unless Native::Protocol.extensions_supported?(protocol_version)
|
|
82
|
+
|
|
83
|
+
Extensions::Registry.normalize_map(@config[:extensions]).transform_values { |value| value || {} }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def register_resource(resource)
|
|
87
|
+
client.linked_resources << resource
|
|
88
|
+
client.resources[resource.name] = resource
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def prepare_transport_config(config, transport_type)
|
|
94
|
+
transport_config = config.dup
|
|
95
|
+
transport_config.delete(:protocol_version)
|
|
96
|
+
transport_config.delete("protocol_version")
|
|
97
|
+
transport_config.delete(:extensions)
|
|
98
|
+
transport_config.delete("extensions")
|
|
99
|
+
|
|
100
|
+
if %i[sse streamable streamable_http].include?(transport_type)
|
|
101
|
+
oauth_provider = Auth::TransportOauthHelper.create_oauth_provider(transport_config) if Auth::TransportOauthHelper.oauth_config_present?(transport_config)
|
|
102
|
+
|
|
103
|
+
Auth::TransportOauthHelper.prepare_http_transport_config(transport_config, oauth_provider)
|
|
104
|
+
elsif transport_type == :stdio
|
|
105
|
+
Auth::TransportOauthHelper.prepare_stdio_transport_config(transport_config)
|
|
106
|
+
else
|
|
107
|
+
transport_config
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_human_in_the_loop_callback(client)
|
|
112
|
+
lambda do |name, params|
|
|
113
|
+
return Handlers::ApprovalDecision.approved unless client.human_in_the_loop?
|
|
114
|
+
|
|
115
|
+
handler_config = normalize_human_in_the_loop_handler(client.on[:human_in_the_loop])
|
|
116
|
+
unless handler_config
|
|
117
|
+
RubyLLM::MCP.logger.error(
|
|
118
|
+
"Human-in-the-loop callback must be a handler class configuration"
|
|
119
|
+
)
|
|
120
|
+
return Handlers::ApprovalDecision.denied(reason: "Invalid approval handler configuration")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
execute_handler_class(
|
|
124
|
+
handler_config[:class],
|
|
125
|
+
name,
|
|
126
|
+
params,
|
|
127
|
+
handler_options: handler_config[:options]
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_sampling_callback(client)
|
|
133
|
+
lambda do |sample|
|
|
134
|
+
return nil unless client.sampling_callback_enabled?
|
|
135
|
+
|
|
136
|
+
handler_or_block = client.on[:sampling]
|
|
137
|
+
|
|
138
|
+
if Handlers.handler_class?(handler_or_block)
|
|
139
|
+
handler_or_block.new(sample: sample, coordinator: @native_client).call
|
|
140
|
+
else
|
|
141
|
+
handler_or_block.call(sample)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_elicitation_callback(client)
|
|
147
|
+
lambda do |elicitation|
|
|
148
|
+
return nil unless client.elicitation_enabled?
|
|
149
|
+
|
|
150
|
+
handler_or_block = client.on[:elicitation]
|
|
151
|
+
return false unless handler_or_block
|
|
152
|
+
|
|
153
|
+
if Handlers.handler_class?(handler_or_block)
|
|
154
|
+
handler_or_block.new(elicitation: elicitation, coordinator: @native_client).call
|
|
155
|
+
else
|
|
156
|
+
handler_or_block.call(elicitation)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def execute_handler_class(handler_class, name, params, handler_options: {})
|
|
162
|
+
approval_id = "#{@native_client.registry_owner_id}:#{SecureRandom.uuid}"
|
|
163
|
+
|
|
164
|
+
handler_instance = handler_class.new(
|
|
165
|
+
tool_name: name,
|
|
166
|
+
parameters: params,
|
|
167
|
+
approval_id: approval_id,
|
|
168
|
+
coordinator: @native_client,
|
|
169
|
+
**handler_options
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
result = handler_instance.call
|
|
173
|
+
decision = Handlers::ApprovalDecision.from_handler_result(
|
|
174
|
+
result,
|
|
175
|
+
approval_id: approval_id,
|
|
176
|
+
default_timeout: handler_instance.timeout
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if decision.deferred?
|
|
180
|
+
promise = Handlers::Promise.new
|
|
181
|
+
@native_client.human_in_the_loop_registry.store(
|
|
182
|
+
approval_id,
|
|
183
|
+
{
|
|
184
|
+
promise: promise,
|
|
185
|
+
timeout: decision.timeout,
|
|
186
|
+
tool_name: name,
|
|
187
|
+
parameters: params
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return decision.with_promise(promise)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
decision
|
|
194
|
+
rescue Errors::InvalidApprovalDecision => e
|
|
195
|
+
RubyLLM::MCP.logger.error("Invalid human-in-the-loop handler decision: #{e.message}")
|
|
196
|
+
Handlers::ApprovalDecision.denied(reason: "Invalid approval decision")
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
RubyLLM::MCP.logger.error("Error in human-in-the-loop handler: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
199
|
+
Handlers::ApprovalDecision.denied(reason: "Approval handler error")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_human_in_the_loop_handler(handler_config)
|
|
203
|
+
if handler_config.is_a?(Hash)
|
|
204
|
+
handler_class = handler_config[:class] || handler_config["class"]
|
|
205
|
+
options = handler_config[:options] || handler_config["options"] || {}
|
|
206
|
+
return nil unless Handlers.handler_class?(handler_class)
|
|
207
|
+
|
|
208
|
+
{ class: handler_class, options: options }
|
|
209
|
+
elsif Handlers.handler_class?(handler_config)
|
|
210
|
+
{ class: handler_config, options: {} }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
module Browser
|
|
7
|
+
# Handles OAuth callback request processing
|
|
8
|
+
# Extracts and validates OAuth parameters from callback requests
|
|
9
|
+
class CallbackHandler
|
|
10
|
+
attr_reader :callback_path, :logger
|
|
11
|
+
|
|
12
|
+
def initialize(callback_path:, logger: nil)
|
|
13
|
+
@callback_path = callback_path
|
|
14
|
+
@logger = logger || MCP.logger
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate that the request path matches the expected callback path
|
|
18
|
+
# @param path [String] request path
|
|
19
|
+
# @return [Boolean] true if path is valid
|
|
20
|
+
def valid_callback_path?(path)
|
|
21
|
+
uri_path, = path.split("?", 2)
|
|
22
|
+
uri_path == @callback_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse callback parameters from path
|
|
26
|
+
# @param path [String] full request path with query string
|
|
27
|
+
# @param http_server [HttpServer] HTTP server instance for parsing
|
|
28
|
+
# @return [Hash] parsed parameters
|
|
29
|
+
def parse_callback_params(path, http_server)
|
|
30
|
+
_, query_string = path.split("?", 2)
|
|
31
|
+
params = http_server.parse_query_params(query_string || "")
|
|
32
|
+
@logger.debug("Callback params: #{params.keys.join(', ')}")
|
|
33
|
+
params
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extract OAuth parameters from parsed params
|
|
37
|
+
# @param params [Hash] parsed query parameters
|
|
38
|
+
# @return [Hash] OAuth parameters (code, state, error, error_description)
|
|
39
|
+
def extract_oauth_params(params)
|
|
40
|
+
{
|
|
41
|
+
code: params["code"],
|
|
42
|
+
state: params["state"],
|
|
43
|
+
error: params["error"],
|
|
44
|
+
error_description: params["error_description"]
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Update result hash with OAuth parameters (thread-safe)
|
|
49
|
+
# @param oauth_params [Hash] OAuth parameters
|
|
50
|
+
# @param result [Hash] result container
|
|
51
|
+
# @param mutex [Mutex] synchronization mutex
|
|
52
|
+
# @param condition [ConditionVariable] wait condition
|
|
53
|
+
def update_result_with_oauth_params(oauth_params, result, mutex, condition)
|
|
54
|
+
mutex.synchronize do
|
|
55
|
+
if oauth_params[:error]
|
|
56
|
+
result[:error] = oauth_params[:error_description] || oauth_params[:error]
|
|
57
|
+
elsif oauth_params[:code] && oauth_params[:state]
|
|
58
|
+
result[:code] = oauth_params[:code]
|
|
59
|
+
result[:state] = oauth_params[:state]
|
|
60
|
+
else
|
|
61
|
+
result[:error] = "Invalid callback: missing code or state parameter"
|
|
62
|
+
end
|
|
63
|
+
result[:completed] = true
|
|
64
|
+
condition.signal
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
module Browser
|
|
7
|
+
# Callback server wrapper for clean shutdown
|
|
8
|
+
# Manages server lifecycle and thread coordination
|
|
9
|
+
class CallbackServer
|
|
10
|
+
def initialize(server, thread, stop_proc, start_proc = nil)
|
|
11
|
+
@server = server
|
|
12
|
+
@thread = thread
|
|
13
|
+
@stop_proc = stop_proc
|
|
14
|
+
@start_proc = start_proc || -> {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Start callback processing loop
|
|
18
|
+
def start
|
|
19
|
+
@start_proc.call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Shutdown server and cleanup resources
|
|
23
|
+
# @return [nil] always returns nil
|
|
24
|
+
def shutdown
|
|
25
|
+
@stop_proc.call
|
|
26
|
+
@server.close unless @server.closed?
|
|
27
|
+
@thread.join(5) # Wait max 5 seconds for thread to finish
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# Ignore shutdown errors
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
module Browser
|
|
7
|
+
# HTTP server utilities for OAuth callback handling
|
|
8
|
+
# Provides lightweight HTTP server functionality without external dependencies
|
|
9
|
+
class HttpServer
|
|
10
|
+
attr_reader :port, :logger
|
|
11
|
+
|
|
12
|
+
def initialize(port:, logger: nil)
|
|
13
|
+
@port = port
|
|
14
|
+
@logger = logger || MCP.logger
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Start TCP server for OAuth callbacks
|
|
18
|
+
# @return [TCPServer] TCP server instance
|
|
19
|
+
# @raise [Errors::TransportError] if server cannot start
|
|
20
|
+
def start_server
|
|
21
|
+
TCPServer.new("127.0.0.1", @port)
|
|
22
|
+
rescue Errno::EADDRINUSE
|
|
23
|
+
raise Errors::TransportError.new(
|
|
24
|
+
message: "Cannot start OAuth callback server: port #{@port} is already in use. " \
|
|
25
|
+
"Please close the application using this port or choose a different callback_port."
|
|
26
|
+
)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise Errors::TransportError.new(
|
|
29
|
+
message: "Failed to start OAuth callback server on port #{@port}: #{e.message}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Configure client socket with timeout
|
|
34
|
+
# @param client [TCPSocket] client socket
|
|
35
|
+
def configure_client_socket(client)
|
|
36
|
+
client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read HTTP request line from client
|
|
40
|
+
# @param client [TCPSocket] client socket
|
|
41
|
+
# @return [String, nil] request line
|
|
42
|
+
def read_request_line(client)
|
|
43
|
+
client.gets
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract method and path from request line
|
|
47
|
+
# @param request_line [String] HTTP request line
|
|
48
|
+
# @return [Array<String, String>, nil] method and path, or nil if invalid
|
|
49
|
+
def extract_request_parts(request_line)
|
|
50
|
+
parts = request_line.split
|
|
51
|
+
return nil unless parts.length >= 2
|
|
52
|
+
|
|
53
|
+
parts[0..1]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Read HTTP headers from client
|
|
57
|
+
# @param client [TCPSocket] client socket
|
|
58
|
+
def read_http_headers(client)
|
|
59
|
+
header_count = 0
|
|
60
|
+
loop do
|
|
61
|
+
break if header_count >= 100
|
|
62
|
+
|
|
63
|
+
line = client.gets
|
|
64
|
+
break if line.nil? || line.strip.empty?
|
|
65
|
+
|
|
66
|
+
header_count += 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse URL query parameters
|
|
71
|
+
# @param query_string [String] query string
|
|
72
|
+
# @return [Hash] parsed parameters
|
|
73
|
+
def parse_query_params(query_string)
|
|
74
|
+
params = {}
|
|
75
|
+
query_string.split("&").each do |param|
|
|
76
|
+
next if param.empty?
|
|
77
|
+
|
|
78
|
+
key, value = param.split("=", 2)
|
|
79
|
+
params[CGI.unescape(key)] = CGI.unescape(value || "")
|
|
80
|
+
end
|
|
81
|
+
params
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Send HTTP response to client
|
|
85
|
+
# @param client [TCPSocket] client socket
|
|
86
|
+
# @param status [Integer] HTTP status code
|
|
87
|
+
# @param content_type [String] content type
|
|
88
|
+
# @param body [String] response body
|
|
89
|
+
def send_http_response(client, status, content_type, body)
|
|
90
|
+
status_text = case status
|
|
91
|
+
when 200 then "OK"
|
|
92
|
+
when 400 then "Bad Request"
|
|
93
|
+
when 404 then "Not Found"
|
|
94
|
+
else "Unknown"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
response = "HTTP/1.1 #{status} #{status_text}\r\n"
|
|
98
|
+
response += "Content-Type: #{content_type}\r\n"
|
|
99
|
+
response += "Content-Length: #{body.bytesize}\r\n"
|
|
100
|
+
response += "Connection: close\r\n"
|
|
101
|
+
response += "\r\n"
|
|
102
|
+
response += body
|
|
103
|
+
|
|
104
|
+
client.write(response)
|
|
105
|
+
rescue IOError, Errno::EPIPE => e
|
|
106
|
+
@logger.debug("Error sending response: #{e.message}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
module Browser
|
|
7
|
+
# Browser opening utilities for different operating systems
|
|
8
|
+
# Handles cross-platform browser launching
|
|
9
|
+
class Opener
|
|
10
|
+
attr_reader :logger
|
|
11
|
+
|
|
12
|
+
def initialize(logger: nil)
|
|
13
|
+
@logger = logger || MCP.logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Open browser to URL
|
|
17
|
+
# @param url [String] URL to open
|
|
18
|
+
# @return [Boolean] true if successful
|
|
19
|
+
def open_browser(url)
|
|
20
|
+
case RbConfig::CONFIG["host_os"]
|
|
21
|
+
when /darwin/
|
|
22
|
+
system("open", url)
|
|
23
|
+
when /linux|bsd/
|
|
24
|
+
system("xdg-open", url)
|
|
25
|
+
when /mswin|mingw|cygwin/
|
|
26
|
+
system("start", url)
|
|
27
|
+
else
|
|
28
|
+
@logger.warn("Unknown operating system, cannot open browser automatically")
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
@logger.warn("Failed to open browser: #{e.message}")
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|