headless_browser_tool 0.1.1
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 +7 -0
- data/.claude/settings.json +21 -0
- data/.rubocop.yml +56 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +298 -0
- data/LICENSE.md +7 -0
- data/README.md +522 -0
- data/Rakefile +12 -0
- data/config.ru +8 -0
- data/exe/hbt +7 -0
- data/lib/headless_browser_tool/browser.rb +374 -0
- data/lib/headless_browser_tool/browser_adapter.rb +320 -0
- data/lib/headless_browser_tool/cli.rb +34 -0
- data/lib/headless_browser_tool/directory_setup.rb +25 -0
- data/lib/headless_browser_tool/logger.rb +31 -0
- data/lib/headless_browser_tool/server.rb +150 -0
- data/lib/headless_browser_tool/session_manager.rb +199 -0
- data/lib/headless_browser_tool/session_middleware.rb +158 -0
- data/lib/headless_browser_tool/session_persistence.rb +146 -0
- data/lib/headless_browser_tool/stdio_server.rb +73 -0
- data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
- data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
- data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
- data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
- data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
- data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
- data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
- data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
- data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
- data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
- data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
- data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
- data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
- data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
- data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
- data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
- data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
- data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
- data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
- data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
- data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
- data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
- data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
- data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
- data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
- data/lib/headless_browser_tool/tools.rb +104 -0
- data/lib/headless_browser_tool/version.rb +5 -0
- data/lib/headless_browser_tool.rb +8 -0
- metadata +256 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
module DirectorySetup
|
7
|
+
HBT_DIR = "./.hbt"
|
8
|
+
SCREENSHOTS_DIR = File.join(HBT_DIR, "screenshots").freeze
|
9
|
+
SESSIONS_DIR = File.join(HBT_DIR, "sessions").freeze
|
10
|
+
LOGS_DIR = File.join(HBT_DIR, "logs").freeze
|
11
|
+
|
12
|
+
module_function
|
13
|
+
|
14
|
+
def setup_directories(include_logs: false)
|
15
|
+
# Create all necessary directories
|
16
|
+
FileUtils.mkdir_p(SCREENSHOTS_DIR)
|
17
|
+
FileUtils.mkdir_p(SESSIONS_DIR)
|
18
|
+
FileUtils.mkdir_p(LOGS_DIR) if include_logs
|
19
|
+
|
20
|
+
# Create .gitignore in .hbt directory
|
21
|
+
gitignore_path = File.join(HBT_DIR, ".gitignore")
|
22
|
+
File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "fileutils"
|
5
|
+
require_relative "directory_setup"
|
6
|
+
|
7
|
+
module HeadlessBrowserTool
|
8
|
+
class Logger
|
9
|
+
class << self
|
10
|
+
attr_accessor :instance
|
11
|
+
|
12
|
+
def initialize_logger(mode: :http)
|
13
|
+
@instance = if mode == :stdio
|
14
|
+
# In stdio mode, write to log file
|
15
|
+
log_file = File.join(DirectorySetup::LOGS_DIR, "#{Process.pid}.log")
|
16
|
+
::Logger.new(log_file, progname: "HBT")
|
17
|
+
else
|
18
|
+
# In HTTP mode, write to stdout
|
19
|
+
::Logger.new($stdout, progname: "HBT")
|
20
|
+
end
|
21
|
+
|
22
|
+
@instance.level = ::Logger::INFO
|
23
|
+
@instance
|
24
|
+
end
|
25
|
+
|
26
|
+
def log
|
27
|
+
@instance ||= initialize_logger
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sinatra/base"
|
4
|
+
require "fast_mcp"
|
5
|
+
require "rack"
|
6
|
+
require "fileutils"
|
7
|
+
require "securerandom"
|
8
|
+
require "json"
|
9
|
+
require_relative "browser"
|
10
|
+
require_relative "tools"
|
11
|
+
require_relative "session_manager"
|
12
|
+
require_relative "session_middleware"
|
13
|
+
require_relative "strict_session_middleware"
|
14
|
+
require_relative "browser_adapter"
|
15
|
+
require_relative "logger"
|
16
|
+
require_relative "session_persistence"
|
17
|
+
require_relative "directory_setup"
|
18
|
+
|
19
|
+
module HeadlessBrowserTool
|
20
|
+
class Server < Sinatra::Base
|
21
|
+
class << self
|
22
|
+
attr_accessor :browser_instance, :session_manager, :single_session_mode, :show_headers, :session_id
|
23
|
+
|
24
|
+
def start_server(options = {})
|
25
|
+
# Initialize logger for HTTP mode
|
26
|
+
HeadlessBrowserTool::Logger.initialize_logger(mode: :http)
|
27
|
+
|
28
|
+
# Check if we should use single session mode
|
29
|
+
@single_session_mode = options[:single_session] || ENV["HBT_SINGLE_SESSION"] == "true"
|
30
|
+
@show_headers = options[:show_headers] || ENV["HBT_SHOW_HEADERS"] == "true"
|
31
|
+
|
32
|
+
# Validate session_id option
|
33
|
+
if options[:session_id] && !@single_session_mode
|
34
|
+
puts "Error: --session-id can only be used with --single-session"
|
35
|
+
exit 1
|
36
|
+
end
|
37
|
+
|
38
|
+
if @single_session_mode
|
39
|
+
puts "Running in single session mode"
|
40
|
+
if options[:session_id]
|
41
|
+
puts "Session ID: #{options[:session_id]}"
|
42
|
+
@session_id = options[:session_id]
|
43
|
+
end
|
44
|
+
@browser_instance = Browser.new(headless: options[:headless])
|
45
|
+
|
46
|
+
# Restore session if session_id provided
|
47
|
+
restore_single_session if @session_id
|
48
|
+
else
|
49
|
+
puts "Running in multi-session mode"
|
50
|
+
@session_manager = SessionManager.new(headless: options[:headless])
|
51
|
+
end
|
52
|
+
|
53
|
+
# Setup directory structure
|
54
|
+
DirectorySetup.setup_directories
|
55
|
+
|
56
|
+
puts "Starting HeadlessBrowserTool MCP server on port #{options[:port]}"
|
57
|
+
puts "Using fast-mcp for MCP protocol support"
|
58
|
+
|
59
|
+
# Register shutdown hook for single session persistence
|
60
|
+
at_exit { save_single_session } if @single_session_mode && @session_id
|
61
|
+
|
62
|
+
# Configure and run with Puma directly
|
63
|
+
require "puma"
|
64
|
+
require "puma/configuration"
|
65
|
+
require "puma/launcher"
|
66
|
+
|
67
|
+
puma_config = Puma::Configuration.new do |config|
|
68
|
+
config.bind "tcp://0.0.0.0:#{options[:port]}"
|
69
|
+
config.environment "production"
|
70
|
+
config.quiet false
|
71
|
+
config.app Server
|
72
|
+
end
|
73
|
+
|
74
|
+
launcher = Puma::Launcher.new(puma_config)
|
75
|
+
launcher.run
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def restore_single_session
|
81
|
+
SessionPersistence.restore_session(@session_id, @browser_instance.session)
|
82
|
+
end
|
83
|
+
|
84
|
+
def save_single_session
|
85
|
+
return unless @session_id && @browser_instance
|
86
|
+
|
87
|
+
SessionPersistence.save_session(@session_id, @browser_instance.session)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Use strict session middleware if in multi-session mode
|
92
|
+
use StrictSessionMiddleware unless Server.single_session_mode
|
93
|
+
|
94
|
+
# Create MCP server instance with custom context handler
|
95
|
+
mcp_server = FastMcp::Server.new(name: "headless-browser-tool", version: HeadlessBrowserTool::VERSION)
|
96
|
+
|
97
|
+
# Register all browser tools
|
98
|
+
HeadlessBrowserTool::Tools::ALL_TOOLS.each do |tool_class|
|
99
|
+
mcp_server.register_tool(tool_class)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Create custom transport that passes session context
|
103
|
+
class SessionAwareTransport < FastMcp::Transports::RackTransport
|
104
|
+
def call(env)
|
105
|
+
# Get or create session ID for this connection
|
106
|
+
session_id = env["hbt.session_id"]
|
107
|
+
|
108
|
+
# Use the session ID from middleware (which handles persistence)
|
109
|
+
# Don't generate new ones here - let the middleware handle it
|
110
|
+
|
111
|
+
# Store session ID in thread local for tools to access
|
112
|
+
Thread.current[:hbt_session_id] = session_id
|
113
|
+
|
114
|
+
# Call parent
|
115
|
+
super
|
116
|
+
ensure
|
117
|
+
Thread.current[:hbt_session_id] = nil
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Use our custom transport
|
122
|
+
use SessionAwareTransport, mcp_server
|
123
|
+
|
124
|
+
# Session management endpoints
|
125
|
+
get "/sessions" do
|
126
|
+
if Server.single_session_mode
|
127
|
+
{ mode: "single", message: "Server is running in single session mode" }.to_json
|
128
|
+
else
|
129
|
+
content_type :json
|
130
|
+
Server.session_manager.session_info.to_json
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
delete "/sessions/:id" do
|
135
|
+
if Server.single_session_mode
|
136
|
+
status 400
|
137
|
+
{ error: "Cannot manage sessions in single session mode" }.to_json
|
138
|
+
else
|
139
|
+
Server.session_manager.close_session(params[:id])
|
140
|
+
{ message: "Session closed", session_id: params[:id] }.to_json
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Default route
|
145
|
+
get "/" do
|
146
|
+
mode = Server.single_session_mode ? "single session" : "multi-session"
|
147
|
+
"HeadlessBrowserTool MCP Server is running in #{mode} mode"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "capybara"
|
4
|
+
require "json"
|
5
|
+
require "fileutils"
|
6
|
+
require_relative "logger"
|
7
|
+
require_relative "session_persistence"
|
8
|
+
require_relative "directory_setup"
|
9
|
+
|
10
|
+
module HeadlessBrowserTool
|
11
|
+
class SessionManager
|
12
|
+
SESSION_TIMEOUT = 30 * 60 # 30 minutes
|
13
|
+
CLEANUP_INTERVAL = 60 # 1 minute
|
14
|
+
MAX_SESSIONS = 10 # Maximum concurrent sessions
|
15
|
+
|
16
|
+
attr_reader :sessions_dir
|
17
|
+
|
18
|
+
def initialize(headless: true)
|
19
|
+
@sessions = {}
|
20
|
+
@session_data = {}
|
21
|
+
@mutex = Mutex.new
|
22
|
+
@headless = headless
|
23
|
+
|
24
|
+
# Enable Capybara threadsafe mode for per-session configuration
|
25
|
+
Capybara.threadsafe = true
|
26
|
+
|
27
|
+
# Use common sessions directory
|
28
|
+
@sessions_dir = DirectorySetup::SESSIONS_DIR
|
29
|
+
|
30
|
+
# Load existing session data
|
31
|
+
load_persisted_sessions
|
32
|
+
|
33
|
+
# Start cleanup thread
|
34
|
+
start_cleanup_thread
|
35
|
+
|
36
|
+
# Register shutdown hook
|
37
|
+
at_exit { shutdown_all_sessions }
|
38
|
+
end
|
39
|
+
|
40
|
+
def get_or_create_session(session_id)
|
41
|
+
@mutex.synchronize do
|
42
|
+
# Validate session_id
|
43
|
+
raise ArgumentError, "Invalid session ID: #{session_id}" unless valid_session_id?(session_id)
|
44
|
+
|
45
|
+
# Update last activity
|
46
|
+
session_data = @session_data[session_id] ||= {
|
47
|
+
created_at: Time.now,
|
48
|
+
last_activity: Time.now
|
49
|
+
}
|
50
|
+
session_data[:last_activity] = Time.now
|
51
|
+
|
52
|
+
# Check if we're at capacity
|
53
|
+
cleanup_least_recently_used if !@sessions[session_id] && @sessions.size >= MAX_SESSIONS
|
54
|
+
|
55
|
+
# Get or create the Capybara session
|
56
|
+
@sessions[session_id] ||= create_session(session_id)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def close_session(session_id)
|
61
|
+
@mutex.synchronize do
|
62
|
+
if (session = @sessions.delete(session_id))
|
63
|
+
begin
|
64
|
+
save_session_state(session_id, session)
|
65
|
+
session.quit
|
66
|
+
rescue StandardError => e
|
67
|
+
HeadlessBrowserTool::Logger.log.info "Error closing session #{session_id}: #{e.message}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
@session_data.delete(session_id)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def save_all_sessions
|
75
|
+
@mutex.synchronize do
|
76
|
+
@sessions.each do |session_id, session|
|
77
|
+
save_session_state(session_id, session)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def session_info
|
83
|
+
@mutex.synchronize do
|
84
|
+
{
|
85
|
+
active_sessions: @sessions.keys,
|
86
|
+
session_count: @sessions.size,
|
87
|
+
session_data: @session_data.transform_values do |data|
|
88
|
+
{
|
89
|
+
created_at: data[:created_at],
|
90
|
+
last_activity: data[:last_activity],
|
91
|
+
idle_time: Time.now - data[:last_activity]
|
92
|
+
}
|
93
|
+
end
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def create_session(session_id)
|
101
|
+
HeadlessBrowserTool::Logger.log.info "Creating new Capybara session: #{session_id}"
|
102
|
+
|
103
|
+
# Create a new Capybara session
|
104
|
+
# With threadsafe mode enabled, each session is isolated
|
105
|
+
session = Capybara::Session.new(:selenium_chrome_headless)
|
106
|
+
|
107
|
+
# Try to restore previous state
|
108
|
+
restore_session_state(session_id, session)
|
109
|
+
|
110
|
+
session
|
111
|
+
rescue StandardError => e
|
112
|
+
HeadlessBrowserTool::Logger.log.info "Error creating session #{session_id}: #{e.message}"
|
113
|
+
raise
|
114
|
+
end
|
115
|
+
|
116
|
+
def save_session_state(session_id, session)
|
117
|
+
SessionPersistence.save_session(session_id, session)
|
118
|
+
end
|
119
|
+
|
120
|
+
def restore_session_state(session_id, session)
|
121
|
+
SessionPersistence.restore_session(session_id, session)
|
122
|
+
end
|
123
|
+
|
124
|
+
def load_persisted_sessions
|
125
|
+
Dir.glob(File.join(@sessions_dir, "*.json")).each do |file|
|
126
|
+
state = JSON.parse(File.read(file))
|
127
|
+
session_id = state["session_id"]
|
128
|
+
|
129
|
+
# Initialize session data for persisted sessions
|
130
|
+
@session_data[session_id] = {
|
131
|
+
created_at: Time.parse(state["saved_at"]),
|
132
|
+
last_activity: Time.now,
|
133
|
+
persisted: true
|
134
|
+
}
|
135
|
+
|
136
|
+
HeadlessBrowserTool::Logger.log.info "Found persisted session: #{session_id}"
|
137
|
+
rescue StandardError => e
|
138
|
+
HeadlessBrowserTool::Logger.log.info "Error loading persisted session from #{file}: #{e.message}"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def start_cleanup_thread
|
143
|
+
Thread.new do
|
144
|
+
loop do
|
145
|
+
sleep CLEANUP_INTERVAL
|
146
|
+
cleanup_idle_sessions
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def cleanup_idle_sessions
|
152
|
+
@mutex.synchronize do
|
153
|
+
now = Time.now
|
154
|
+
sessions_to_close = []
|
155
|
+
|
156
|
+
@session_data.each do |session_id, data|
|
157
|
+
sessions_to_close << session_id if now - data[:last_activity] > SESSION_TIMEOUT
|
158
|
+
end
|
159
|
+
|
160
|
+
sessions_to_close.each do |session_id|
|
161
|
+
HeadlessBrowserTool::Logger.log.info "Cleaning up idle session: #{session_id} (idle for #{(now - @session_data[session_id][:last_activity]).to_i}s)" # rubocop:disable Layout/LineLength
|
162
|
+
close_session(session_id)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
rescue StandardError => e
|
166
|
+
HeadlessBrowserTool::Logger.log.info "Error during cleanup: #{e.message}"
|
167
|
+
end
|
168
|
+
|
169
|
+
def cleanup_least_recently_used
|
170
|
+
# Find the least recently used session
|
171
|
+
lru_session = @session_data.min_by { |_, data| data[:last_activity] }&.first
|
172
|
+
|
173
|
+
return unless lru_session
|
174
|
+
|
175
|
+
HeadlessBrowserTool::Logger.log.info "Closing LRU session to make room: #{lru_session}"
|
176
|
+
close_session(lru_session)
|
177
|
+
end
|
178
|
+
|
179
|
+
def shutdown_all_sessions
|
180
|
+
HeadlessBrowserTool::Logger.log.info "Shutting down all sessions..."
|
181
|
+
@mutex.synchronize do
|
182
|
+
@sessions.each do |session_id, session|
|
183
|
+
save_session_state(session_id, session)
|
184
|
+
session.quit
|
185
|
+
rescue StandardError => e
|
186
|
+
HeadlessBrowserTool::Logger.log.info "Error shutting down session #{session_id}: #{e.message}"
|
187
|
+
end
|
188
|
+
@sessions.clear
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def valid_session_id?(session_id)
|
193
|
+
session_id.is_a?(String) &&
|
194
|
+
session_id.length >= 1 &&
|
195
|
+
session_id.length <= 64 &&
|
196
|
+
session_id.match?(/\A[a-zA-Z0-9_\-]+\z/)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require_relative "logger"
|
5
|
+
|
6
|
+
module HeadlessBrowserTool
|
7
|
+
class SessionMiddleware
|
8
|
+
# Headers to check for session ID (in priority order)
|
9
|
+
SESSION_HEADERS = %w[
|
10
|
+
HTTP_X_SESSION_ID
|
11
|
+
HTTP_X_CLIENT_ID
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
DEFAULT_SESSION = "default"
|
15
|
+
|
16
|
+
# Class-level session storage for MCP connections
|
17
|
+
@mcp_sessions = {}
|
18
|
+
@session_mutex = Mutex.new
|
19
|
+
|
20
|
+
class << self
|
21
|
+
attr_accessor :mcp_sessions, :session_mutex
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(app)
|
25
|
+
@app = app
|
26
|
+
end
|
27
|
+
|
28
|
+
def call(env)
|
29
|
+
# Log request headers if enabled
|
30
|
+
log_request_headers(env) if HeadlessBrowserTool::Server.show_headers
|
31
|
+
|
32
|
+
# Extract session ID from headers first
|
33
|
+
session_id = extract_session_id(env)
|
34
|
+
|
35
|
+
# For MCP connections, try to maintain session continuity
|
36
|
+
session_id = get_or_create_mcp_session(env) if session_id == DEFAULT_SESSION && mcp_request?(env)
|
37
|
+
|
38
|
+
# Store in environment for downstream use
|
39
|
+
env["hbt.session_id"] = session_id
|
40
|
+
|
41
|
+
# Call the app
|
42
|
+
status, headers, response = @app.call(env)
|
43
|
+
|
44
|
+
# Add session ID to response headers
|
45
|
+
headers["X-Session-ID"] = session_id
|
46
|
+
|
47
|
+
# For MCP SSE connections, send session info
|
48
|
+
if env["PATH_INFO"] == "/mcp/sse" && session_id != DEFAULT_SESSION
|
49
|
+
# Store for future requests from this client
|
50
|
+
store_mcp_session(env, session_id)
|
51
|
+
end
|
52
|
+
|
53
|
+
[status, headers, response]
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def extract_session_id(env)
|
59
|
+
# Check for explicit session headers
|
60
|
+
SESSION_HEADERS.each do |header|
|
61
|
+
if (value = env[header])
|
62
|
+
sanitized = sanitize_session_id(value)
|
63
|
+
return sanitized if sanitized
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Default session if none provided
|
68
|
+
DEFAULT_SESSION
|
69
|
+
end
|
70
|
+
|
71
|
+
def sanitize_session_id(value)
|
72
|
+
return nil if value.nil? || value.empty?
|
73
|
+
|
74
|
+
# Ensure it's alphanumeric with underscores/hyphens only
|
75
|
+
sanitized = value.gsub(/[^a-zA-Z0-9_\-]/, "")
|
76
|
+
|
77
|
+
# Limit length
|
78
|
+
sanitized = sanitized[0..64]
|
79
|
+
|
80
|
+
# Return nil if invalid
|
81
|
+
return nil if sanitized.empty?
|
82
|
+
|
83
|
+
sanitized
|
84
|
+
end
|
85
|
+
|
86
|
+
def mcp_request?(env)
|
87
|
+
env["PATH_INFO"]&.start_with?("/mcp")
|
88
|
+
end
|
89
|
+
|
90
|
+
def get_or_create_mcp_session(env)
|
91
|
+
client_key = generate_client_key(env)
|
92
|
+
|
93
|
+
self.class.session_mutex.synchronize do
|
94
|
+
# Check if we have a session for this client
|
95
|
+
if self.class.mcp_sessions[client_key]
|
96
|
+
self.class.mcp_sessions[client_key][:last_seen] = Time.now
|
97
|
+
return self.class.mcp_sessions[client_key][:session_id]
|
98
|
+
end
|
99
|
+
|
100
|
+
# Create new session
|
101
|
+
session_id = "mcp_#{SecureRandom.hex(8)}"
|
102
|
+
self.class.mcp_sessions[client_key] = {
|
103
|
+
session_id: session_id,
|
104
|
+
created_at: Time.now,
|
105
|
+
last_seen: Time.now
|
106
|
+
}
|
107
|
+
|
108
|
+
# Clean old sessions
|
109
|
+
cleanup_old_sessions
|
110
|
+
|
111
|
+
session_id
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def store_mcp_session(env, session_id)
|
116
|
+
client_key = generate_client_key(env)
|
117
|
+
|
118
|
+
self.class.session_mutex.synchronize do
|
119
|
+
self.class.mcp_sessions[client_key] = {
|
120
|
+
session_id: session_id,
|
121
|
+
created_at: Time.now,
|
122
|
+
last_seen: Time.now
|
123
|
+
}
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def generate_client_key(env)
|
128
|
+
# Use IP + User-Agent as a simple client identifier
|
129
|
+
# This isn't perfect but works for most cases
|
130
|
+
ip = env["REMOTE_ADDR"] || "unknown"
|
131
|
+
user_agent = env["HTTP_USER_AGENT"] || "unknown"
|
132
|
+
"#{ip}:#{user_agent.hash}"
|
133
|
+
end
|
134
|
+
|
135
|
+
def cleanup_old_sessions
|
136
|
+
# Remove sessions older than 1 hour
|
137
|
+
cutoff = Time.now - 3600
|
138
|
+
self.class.mcp_sessions.delete_if { |_, data| data[:last_seen] < cutoff }
|
139
|
+
end
|
140
|
+
|
141
|
+
def log_request_headers(env)
|
142
|
+
HeadlessBrowserTool::Logger.log.info "\n=== REQUEST HEADERS ==="
|
143
|
+
HeadlessBrowserTool::Logger.log.info "Method: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
144
|
+
HeadlessBrowserTool::Logger.log.info "Remote IP: #{env["REMOTE_ADDR"]}"
|
145
|
+
|
146
|
+
# Log all HTTP headers
|
147
|
+
env.select { |k, _v| k.start_with?("HTTP_") }.each do |key, value|
|
148
|
+
header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
|
149
|
+
HeadlessBrowserTool::Logger.log.info "#{header_name}: #{value}"
|
150
|
+
end
|
151
|
+
|
152
|
+
# Log specific session-related info
|
153
|
+
HeadlessBrowserTool::Logger.log.info "Session ID from headers: #{extract_session_id(env)}"
|
154
|
+
HeadlessBrowserTool::Logger.log.info "Client Key: #{generate_client_key(env)}"
|
155
|
+
HeadlessBrowserTool::Logger.log.info "=====================\n"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|