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