ferrum-mcp 1.0.0 → 1.1.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +37 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +30 -9
  5. data/docs/API_REFERENCE.md +300 -21
  6. data/docs/CONFIGURATION.md +34 -1
  7. data/docs/DEPLOYMENT.md +1 -0
  8. data/docs/DOCKER_BOTBROWSER.md +4 -0
  9. data/lib/ferrum_mcp/browser_manager.rb +56 -32
  10. data/lib/ferrum_mcp/cli/command_handler.rb +4 -3
  11. data/lib/ferrum_mcp/cli/server_runner.rb +19 -6
  12. data/lib/ferrum_mcp/configuration.rb +77 -18
  13. data/lib/ferrum_mcp/image_resizer.rb +43 -0
  14. data/lib/ferrum_mcp/server.rb +58 -91
  15. data/lib/ferrum_mcp/session.rb +67 -9
  16. data/lib/ferrum_mcp/session_manager.rb +15 -15
  17. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +11 -31
  18. data/lib/ferrum_mcp/tools/base_tool.rb +79 -38
  19. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +14 -43
  20. data/lib/ferrum_mcp/tools/click_tool.rb +58 -181
  21. data/lib/ferrum_mcp/tools/close_session_tool.rb +8 -30
  22. data/lib/ferrum_mcp/tools/close_tab_tool.rb +44 -0
  23. data/lib/ferrum_mcp/tools/create_session_tool.rb +38 -110
  24. data/lib/ferrum_mcp/tools/definition.rb +106 -0
  25. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +43 -145
  26. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +6 -28
  27. data/lib/ferrum_mcp/tools/execute_script_tool.rb +6 -30
  28. data/lib/ferrum_mcp/tools/fill_form_tool.rb +37 -52
  29. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +39 -114
  30. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +10 -39
  31. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +23 -52
  32. data/lib/ferrum_mcp/tools/get_html_tool.rb +22 -35
  33. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +5 -21
  34. data/lib/ferrum_mcp/tools/get_text_tool.rb +22 -46
  35. data/lib/ferrum_mcp/tools/get_title_tool.rb +5 -28
  36. data/lib/ferrum_mcp/tools/get_url_tool.rb +5 -25
  37. data/lib/ferrum_mcp/tools/go_back_tool.rb +7 -30
  38. data/lib/ferrum_mcp/tools/go_forward_tool.rb +7 -30
  39. data/lib/ferrum_mcp/tools/hover_tool.rb +10 -51
  40. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +5 -19
  41. data/lib/ferrum_mcp/tools/list_tabs_tool.rb +20 -0
  42. data/lib/ferrum_mcp/tools/navigate_tool.rb +17 -37
  43. data/lib/ferrum_mcp/tools/new_tab_tool.rb +36 -0
  44. data/lib/ferrum_mcp/tools/press_key_tool.rb +18 -63
  45. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +50 -205
  46. data/lib/ferrum_mcp/tools/refresh_tool.rb +7 -30
  47. data/lib/ferrum_mcp/tools/screenshot_tool.rb +29 -86
  48. data/lib/ferrum_mcp/tools/scroll_tool.rb +64 -0
  49. data/lib/ferrum_mcp/tools/select_option_tool.rb +56 -0
  50. data/lib/ferrum_mcp/tools/session_tool.rb +10 -9
  51. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +15 -56
  52. data/lib/ferrum_mcp/tools/set_viewport_tool.rb +32 -0
  53. data/lib/ferrum_mcp/tools/snapshot_tool.rb +175 -0
  54. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +17 -35
  55. data/lib/ferrum_mcp/tools/switch_tab_tool.rb +29 -0
  56. data/lib/ferrum_mcp/tools/tab_tool.rb +40 -0
  57. data/lib/ferrum_mcp/tools/upload_file_tool.rb +50 -0
  58. data/lib/ferrum_mcp/tools/wait_for_network_idle_tool.rb +32 -0
  59. data/lib/ferrum_mcp/tools/wait_for_selector_tool.rb +59 -0
  60. data/lib/ferrum_mcp/tools/wait_for_text_tool.rb +53 -0
  61. data/lib/ferrum_mcp/transport/api_key_authenticator.rb +112 -0
  62. data/lib/ferrum_mcp/transport/client_address.rb +23 -0
  63. data/lib/ferrum_mcp/transport/http_server.rb +11 -1
  64. data/lib/ferrum_mcp/transport/rate_limiter.rb +2 -6
  65. data/lib/ferrum_mcp/transport/stdio_server.rb +8 -30
  66. data/lib/ferrum_mcp/url_policy.rb +88 -0
  67. data/lib/ferrum_mcp/version.rb +1 -1
  68. metadata +21 -3
@@ -29,16 +29,22 @@ module FerrumMCP
29
29
  exit 1
30
30
  end
31
31
 
32
+ # Runs the server until STDIN closes (stdio) or a signal arrives (http).
33
+ # Shutdown always happens here, on the main thread, never inside a trap.
32
34
  def start
33
35
  validate!
34
36
  setup_servers
35
37
  setup_signal_handlers
36
38
  log_startup_info
37
39
  run
40
+ rescue SignalException
41
+ config.logger.info 'Signal received, shutting down'
38
42
  rescue StandardError => e
39
43
  config.logger.error "ERROR: #{e.message}"
40
44
  config.logger.error e.backtrace.join("\n")
41
45
  exit 1
46
+ ensure
47
+ shutdown
42
48
  end
43
49
 
44
50
  private
@@ -59,16 +65,23 @@ module FerrumMCP
59
65
  end
60
66
  end
61
67
 
68
+ # Trap handlers must not log or take locks (Ruby forbids Mutex use in
69
+ # trap context), so they only interrupt the main thread; #start does the
70
+ # actual shutdown.
62
71
  def setup_signal_handlers
63
- trap('INT') { shutdown }
64
- trap('TERM') { shutdown }
72
+ %w[INT TERM].each do |signal|
73
+ trap(signal) { raise Interrupt, signal }
74
+ end
65
75
  end
66
76
 
67
77
  def shutdown
78
+ return if @shutdown_done
79
+
80
+ @shutdown_done = true
68
81
  config.logger.info 'Shutting down...'
69
- transport_server.stop
70
- mcp_server.stop_browser
71
- exit 0
82
+ transport_server&.stop
83
+ mcp_server&.shutdown
84
+ config.logger.info 'Shutdown complete'
72
85
  end
73
86
 
74
87
  def log_startup_info
@@ -158,7 +171,7 @@ module FerrumMCP
158
171
 
159
172
  def run
160
173
  transport_server.start
161
- # Keep main thread alive for HTTP (stdio blocks automatically)
174
+ # Keep main thread alive for HTTP (stdio blocks until STDIN closes)
162
175
  sleep if @options[:transport] == 'http'
163
176
  end
164
177
  end
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+
3
6
  module FerrumMCP
4
7
  # Configuration class for Ferrum MCP Server
5
8
  class Configuration
6
- attr_accessor :headless, :timeout, :server_host, :server_port, :log_level, :transport, :max_sessions,
7
- :rate_limit_enabled, :rate_limit_max_requests, :rate_limit_window
8
- attr_reader :browsers, :user_profiles, :bot_profiles
9
+ attr_accessor :headless, :timeout, :server_host, :server_port, :log_level, :log_file, :transport,
10
+ :max_sessions, :rate_limit_enabled, :rate_limit_max_requests, :rate_limit_window,
11
+ :api_key_enabled, :api_keys, :trust_proxy
12
+ attr_reader :browsers, :user_profiles, :bot_profiles, :url_policy, :upload_allowed_dirs
9
13
 
10
14
  # Browser configuration structure
11
15
  BrowserConfig = Struct.new(:id, :name, :path, :type, :description, keyword_init: true) do
@@ -34,19 +38,30 @@ module FerrumMCP
34
38
  @timeout = ENV.fetch('BROWSER_TIMEOUT', '60').to_i
35
39
  @server_host = ENV.fetch('MCP_SERVER_HOST', '0.0.0.0')
36
40
  @server_port = ENV.fetch('MCP_SERVER_PORT', '3000').to_i
37
- @log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
41
+ @log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym
42
+ @log_file = ENV.fetch('LOG_FILE', nil)
38
43
  @transport = transport
39
44
  @max_sessions = ENV.fetch('MAX_CONCURRENT_SESSIONS', '10').to_i
40
45
 
46
+ # Only honour X-Forwarded-For when the server sits behind a trusted proxy
47
+ @trust_proxy = ENV.fetch('TRUST_PROXY', 'false') == 'true'
48
+
49
+ # Optional navigation restrictions (ALLOWED_HOSTS / BLOCKED_HOSTS)
50
+ @url_policy = UrlPolicy.from_env
51
+
52
+ # Directories the upload_file tool may read from (UPLOAD_ALLOWED_DIRS)
53
+ @upload_allowed_dirs = load_upload_allowed_dirs
54
+
41
55
  # Rate limiting configuration
42
56
  @rate_limit_enabled = ENV.fetch('RATE_LIMIT_ENABLED', 'true') == 'true'
43
57
  @rate_limit_max_requests = ENV.fetch('RATE_LIMIT_MAX_REQUESTS', '100').to_i
44
58
  @rate_limit_window = ENV.fetch('RATE_LIMIT_WINDOW', '60').to_i
45
59
 
46
- # Load multi-browser configurations
47
- @browsers = load_browsers
48
- @user_profiles = load_user_profiles
49
- @bot_profiles = load_bot_profiles
60
+ # API key authentication configuration
61
+ @api_key_enabled = ENV.fetch('API_KEY_ENABLED', 'false') == 'true'
62
+ @api_keys = load_api_keys
63
+
64
+ load_browser_configurations
50
65
  end
51
66
 
52
67
  def valid?
@@ -80,14 +95,48 @@ module FerrumMCP
80
95
  end
81
96
 
82
97
  def logger
83
- @logger ||= create_multi_logger
98
+ @logger ||= create_logger
84
99
  end
85
100
 
86
101
  # Environment variable keys to skip when loading browsers
87
102
  RESERVED_BROWSER_ENV_KEYS = %w[BROWSER_PATH BROWSER_HEADLESS BROWSER_TIMEOUT].freeze
88
103
 
104
+ # Check if API key authentication is properly configured
105
+ def api_key_configured?
106
+ api_key_enabled && api_keys.any?
107
+ end
108
+
89
109
  private
90
110
 
111
+ def load_browser_configurations
112
+ @browsers = load_browsers
113
+ @user_profiles = load_user_profiles
114
+ @bot_profiles = load_bot_profiles
115
+ end
116
+
117
+ # Defaults to the current directory and the system temp dir
118
+ def load_upload_allowed_dirs
119
+ configured = ENV.fetch('UPLOAD_ALLOWED_DIRS', '').split(',').map(&:strip).reject(&:empty?)
120
+ dirs = configured.empty? ? [Dir.pwd, Dir.tmpdir] : configured
121
+ dirs.map { |d| File.expand_path(d) }
122
+ end
123
+
124
+ # Load API keys from environment variables
125
+ # Supports single key (API_KEY) or multiple keys (API_KEYS=key1,key2,key3)
126
+ def load_api_keys
127
+ keys = []
128
+
129
+ # Load single API key
130
+ single_key = ENV.fetch('API_KEY', nil)
131
+ keys << single_key if single_key && !single_key.empty?
132
+
133
+ # Load multiple API keys (comma-separated)
134
+ multiple_keys = ENV.fetch('API_KEYS', nil)
135
+ keys.concat(multiple_keys.split(',').map(&:strip).reject(&:empty?)) if multiple_keys && !multiple_keys.empty?
136
+
137
+ keys.uniq
138
+ end
139
+
91
140
  # Load browser configurations from environment variables
92
141
  # Format: BROWSER_<ID>=type:path:name:description
93
142
  # Example: BROWSER_CHROME=chrome:/usr/bin/google-chrome:Google Chrome:Standard Chrome browser
@@ -213,17 +262,27 @@ module FerrumMCP
213
262
  profiles
214
263
  end
215
264
 
216
- def create_multi_logger
217
- # Create log directory relative to the project root
218
- # Use __FILE__ to get the gem's location, then go up to project root
219
- project_root = File.expand_path('../..', __dir__)
220
- log_dir = File.join(project_root, 'logs')
221
- FileUtils.mkdir_p(log_dir) unless File.directory?(log_dir)
265
+ # Logs never go to STDOUT: in stdio transport STDOUT carries the protocol.
266
+ # LOG_FILE=<path> writes to that file, LOG_FILE=stderr writes to STDERR,
267
+ # unset writes to ./logs/ferrum_mcp.log under the current directory (never
268
+ # inside the installed gem), falling back to the system temp dir.
269
+ def create_logger
270
+ Logger.new(log_device, level: log_level)
271
+ end
272
+
273
+ def log_device
274
+ target = log_file.to_s.strip
275
+ return $stderr if target.casecmp('stderr').zero?
276
+ return prepare_log_path(target) unless target.empty?
222
277
 
223
- log_file = File.join(log_dir, 'ferrum_mcp.log')
278
+ prepare_log_path(File.join(Dir.pwd, 'logs', 'ferrum_mcp.log'))
279
+ rescue SystemCallError
280
+ File.join(Dir.tmpdir, 'ferrum_mcp.log')
281
+ end
224
282
 
225
- # Only write to file, no console output
226
- Logger.new(log_file, level: log_level)
283
+ def prepare_log_path(path)
284
+ FileUtils.mkdir_p(File.dirname(path))
285
+ path
227
286
  end
228
287
  end
229
288
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ # Optional libvips-backed resizing. ruby-vips raises LoadError when the
5
+ # native library is missing; screenshots are then returned at full size.
6
+ module ImageResizer
7
+ module_function
8
+
9
+ def available?
10
+ return @available unless @available.nil?
11
+
12
+ @available = begin
13
+ require 'vips'
14
+ true
15
+ rescue LoadError => e
16
+ warn_once("libvips not available (#{e.message}); large screenshots will not be resized")
17
+ false
18
+ end
19
+ end
20
+
21
+ # Scale the image down so both sides fit within max_dimension
22
+ def fit(image_data, max_dimension, format, logger: nil)
23
+ image = Vips::Image.new_from_buffer(image_data, '')
24
+ width = image.width
25
+ height = image.height
26
+ return image_data if width <= max_dimension && height <= max_dimension
27
+
28
+ scale = [max_dimension.to_f / width, max_dimension.to_f / height].min
29
+ new_width = (width * scale).to_i
30
+ new_height = (height * scale).to_i
31
+ logger&.info "Resizing screenshot from #{width}x#{height} to #{new_width}x#{new_height}"
32
+
33
+ image.thumbnail_image(new_width, height: new_height, size: :force).write_to_buffer(".#{format}")
34
+ end
35
+
36
+ def warn_once(message)
37
+ return if @warned
38
+
39
+ @warned = true
40
+ Kernel.warn "[ferrum-mcp] #{message}"
41
+ end
42
+ end
43
+ end
@@ -24,13 +24,27 @@ module FerrumMCP
24
24
  Tools::DragAndDropTool,
25
25
  Tools::AcceptCookiesTool,
26
26
  Tools::SolveCaptchaTool,
27
+ Tools::ScrollTool,
28
+ Tools::SelectOptionTool,
29
+ Tools::UploadFileTool,
27
30
  # Extraction
31
+ Tools::SnapshotTool,
28
32
  Tools::GetTextTool,
29
33
  Tools::GetHTMLTool,
30
34
  Tools::ScreenshotTool,
31
35
  Tools::GetTitleTool,
32
36
  Tools::GetURLTool,
33
37
  Tools::FindByTextTool,
38
+ # Waiting
39
+ Tools::WaitForSelectorTool,
40
+ Tools::WaitForTextTool,
41
+ Tools::WaitForNetworkIdleTool,
42
+ # Tabs & viewport
43
+ Tools::ListTabsTool,
44
+ Tools::NewTabTool,
45
+ Tools::SwitchTabTool,
46
+ Tools::CloseTabTool,
47
+ Tools::SetViewportTool,
34
48
  # Advanced
35
49
  Tools::ExecuteScriptTool,
36
50
  Tools::EvaluateJSTool,
@@ -46,7 +60,6 @@ module FerrumMCP
46
60
  @logger = config.logger
47
61
  @session_manager = SessionManager.new(config)
48
62
  @resource_manager = ResourceManager.new(config)
49
- @tool_instances = {}
50
63
  @mcp_server = create_mcp_server
51
64
 
52
65
  setup_tools
@@ -54,20 +67,6 @@ module FerrumMCP
54
67
  setup_error_handling
55
68
  end
56
69
 
57
- # Deprecated: For backward compatibility
58
- # Sessions must be created explicitly using create_session tool
59
- def start_browser
60
- raise NotImplementedError, 'start_browser is deprecated. Use create_session tool to create a session.'
61
- end
62
-
63
- # Deprecated: For backward compatibility
64
- def stop_browser
65
- logger.warn 'stop_browser is deprecated, use session_manager.close_all_sessions'
66
- session_manager.close_all_sessions
67
- @tool_instances = {}
68
- logger.info 'All sessions stopped'
69
- end
70
-
71
70
  # Shutdown server and cleanup all sessions
72
71
  def shutdown
73
72
  logger.info 'Shutting down server...'
@@ -140,101 +139,69 @@ module FerrumMCP
140
139
  end
141
140
 
142
141
  def execute_tool(tool_class, params)
142
+ params = params.except(:server_context)
143
143
  logger.debug "Executing tool: #{tool_class.tool_name} with params: #{params.inspect}"
144
144
 
145
- # Session management tools don't need a browser session
146
- if session_management_tool?(tool_class)
147
- logger.debug "Executing session management tool: #{tool_class.tool_name}"
148
- tool = tool_class.new(session_manager)
149
- result = tool.execute(params)
150
- else
151
- # Extract session_id from params (required)
152
- session_id = params[:session_id] || params['session_id']
153
-
154
- unless session_id
155
- logger.error "session_id is required for #{tool_class.tool_name}"
156
- return error_tool_response('session_id is required. Create a session first using create_session tool.')
157
- end
145
+ result = if tool_class.requires_session?
146
+ execute_browser_tool(tool_class, params)
147
+ else
148
+ tool_class.new(session_manager).execute(params)
149
+ end
158
150
 
159
- logger.debug "Using session_id: #{session_id}"
151
+ to_mcp_response(tool_class, result)
152
+ rescue StandardError => e
153
+ logger.error "Tool execution error (#{tool_class.tool_name}): #{e.class} - #{e.message}"
154
+ logger.error e.backtrace.first(10).join("\n")
155
+ error_tool_response("#{e.class}: #{e.message}")
156
+ end
160
157
 
161
- # Execute tool within session context
162
- result = session_manager.with_session(session_id) do |browser_manager|
163
- logger.debug "Creating tool instance for #{tool_class.tool_name}"
164
- tool = tool_class.new(browser_manager)
165
- logger.debug "Calling execute on #{tool_class.tool_name}"
166
- tool.execute(params)
167
- end
158
+ def execute_browser_tool(tool_class, params)
159
+ session_id = params[:session_id] || params['session_id']
160
+ if session_id.nil? || session_id.to_s.empty?
161
+ logger.error "session_id is required for #{tool_class.tool_name}"
162
+ return { success: false, error: 'session_id is required. Create a session first using create_session tool.' }
168
163
  end
169
164
 
170
- logger.debug "Tool #{tool_class.tool_name} result: #{result.inspect}"
171
-
172
- # MCP expects a Tool::Response object
173
- # Convert our tool result to MCP format
174
- if result[:success]
175
- logger.debug "Tool succeeded, creating MCP::Tool::Response with data: #{result[:data].inspect}"
176
-
177
- # Check if this is an image response
178
- if result[:type] == 'image'
179
- logger.debug "Creating image response with mime_type: #{result[:mime_type]}"
180
- # Return MCP Tool::Response with image content
181
- MCP::Tool::Response.new([{
182
- type: 'image',
183
- data: result[:data],
184
- mimeType: result[:mime_type]
185
- }])
186
- else
187
- # Return a proper MCP Tool::Response with the data as text content
188
- MCP::Tool::Response.new([{ type: 'text', text: result[:data].to_json }])
189
- end
190
- else
191
- logger.error "Tool failed with error: #{result[:error]}"
192
- # Return an error response
193
- MCP::Tool::Response.new([{ type: 'text', text: result[:error] }], error: true)
165
+ session_manager.with_session(session_id) do |browser_manager|
166
+ tool_class.new(browser_manager).execute(params)
194
167
  end
195
- rescue StandardError => e
196
- logger.error "Tool execution error (#{tool_class.tool_name}): #{e.class} - #{e.message}"
197
- logger.error 'Backtrace:'
198
- logger.error e.backtrace.first(10).join("\n")
199
- # Return an error response for unexpected exceptions
200
- MCP::Tool::Response.new([{ type: 'text', text: "#{e.class}: #{e.message}" }], error: true)
201
168
  end
202
169
 
203
- # Check if tool is a session management tool
204
- def session_management_tool?(tool_class)
205
- [
206
- Tools::CreateSessionTool,
207
- Tools::ListSessionsTool,
208
- Tools::CloseSessionTool,
209
- Tools::GetSessionInfoTool
210
- ].include?(tool_class)
170
+ def to_mcp_response(tool_class, result)
171
+ unless result[:success]
172
+ logger.error "Tool #{tool_class.tool_name} failed: #{result[:error]}"
173
+ return error_tool_response(result[:error])
174
+ end
175
+
176
+ if result[:type] == 'image'
177
+ MCP::Tool::Response.new([{ type: 'image', data: result[:data], mimeType: result[:mime_type] }])
178
+ else
179
+ MCP::Tool::Response.new([{ type: 'text', text: result[:data].to_json }])
180
+ end
211
181
  end
212
182
 
213
183
  def setup_error_handling
214
184
  MCP.configure do |mcp_config|
215
- mcp_config.exception_reporter = lambda { |exception, context|
216
- logger.error '=' * 80
217
- logger.error "MCP Exception: #{exception.class} - #{exception.message}"
218
- logger.error "Context: #{context.inspect}"
219
-
220
- # Log the original error if there is one
221
- if exception.respond_to?(:original_error) && exception.original_error
222
- logger.error "ORIGINAL ERROR: #{exception.original_error.class} - #{exception.original_error.message}"
223
- logger.error 'ORIGINAL BACKTRACE:'
224
- logger.error exception.original_error.backtrace.first(15).join("\n")
225
- end
226
-
227
- logger.error 'Exception backtrace:'
228
- logger.error exception.backtrace.join("\n")
229
- logger.error '=' * 80
230
- }
231
-
185
+ mcp_config.exception_reporter = ->(exception, context) { report_exception(exception, context) }
232
186
  mcp_config.instrumentation_callback = lambda { |data|
233
187
  logger.debug "MCP Method: #{data[:method]}, Duration: #{data[:duration]}s"
234
188
  }
235
189
  end
236
190
  end
237
191
 
192
+ def report_exception(exception, context)
193
+ logger.error '=' * 80
194
+ logger.error "MCP Exception: #{exception.class} - #{exception.message}"
195
+ logger.error "Context: #{context.inspect}"
196
+ original = exception.respond_to?(:original_error) ? exception.original_error : nil
197
+ if original
198
+ logger.error "ORIGINAL ERROR: #{original.class} - #{original.message}"
199
+ logger.error original.backtrace.first(15).join("\n")
200
+ end
201
+ logger.error exception.backtrace.join("\n")
202
+ logger.error '=' * 80
203
+ end
204
+
238
205
  def error_response(message)
239
206
  {
240
207
  jsonrpc: '2.0',
@@ -15,14 +15,23 @@ module FerrumMCP
15
15
  @last_used_at = Time.now
16
16
  @metadata = options[:metadata] || {}
17
17
  @mutex = Mutex.new
18
+ @closed = false
18
19
  @session_config, @browser_manager = create_browser_manager
19
20
  end
20
21
 
21
- # Execute a block with thread-safe access to the browser
22
+ # Execute a block with thread-safe access to the browser.
23
+ # A dead browser detected during the block leaves the session inactive so
24
+ # the next call restarts it instead of failing forever.
22
25
  def with_browser
23
26
  @mutex.synchronize do
24
27
  @last_used_at = Time.now
25
- yield @browser_manager
28
+ begin
29
+ yield @browser_manager
30
+ rescue Ferrum::DeadBrowserError
31
+ logger.warn "Browser for session #{@id} died during a tool call"
32
+ @browser_manager.stop
33
+ raise
34
+ end
26
35
  end
27
36
  end
28
37
 
@@ -31,21 +40,41 @@ module FerrumMCP
31
40
  @browser_manager.active?
32
41
  end
33
42
 
43
+ def closed?
44
+ @closed
45
+ end
46
+
34
47
  # Start the browser for this session
35
48
  def start
49
+ @mutex.synchronize { start_browser }
50
+ end
51
+
52
+ # Make sure a live browser is available, restarting it if its process died.
53
+ def ensure_started
36
54
  @mutex.synchronize do
37
- @browser_manager.start unless @browser_manager.active?
38
- @last_used_at = Time.now
55
+ if @browser_manager.active? && !@browser_manager.healthy?
56
+ logger.warn "Restarting dead browser for session #{@id}"
57
+ @browser_manager.stop
58
+ end
59
+ start_browser
39
60
  end
40
61
  end
41
62
 
42
- # Stop the browser for this session
63
+ # Stop the browser for this session (the session can be started again)
43
64
  def stop
44
65
  @mutex.synchronize do
45
66
  @browser_manager.stop if @browser_manager.active?
46
67
  end
47
68
  end
48
69
 
70
+ # Close the session for good: stops the browser and refuses any restart
71
+ def close
72
+ @mutex.synchronize do
73
+ @closed = true
74
+ @browser_manager.stop if @browser_manager.active?
75
+ end
76
+ end
77
+
49
78
  # Check if session is idle (not used for a while)
50
79
  def idle?(timeout_seconds)
51
80
  Time.now - @last_used_at > timeout_seconds
@@ -56,6 +85,7 @@ module FerrumMCP
56
85
  {
57
86
  id: @id,
58
87
  active: active?,
88
+ closed: closed?,
59
89
  created_at: @created_at.iso8601,
60
90
  last_used_at: @last_used_at.iso8601,
61
91
  idle_seconds: (Time.now - @last_used_at).to_i,
@@ -85,6 +115,18 @@ module FerrumMCP
85
115
 
86
116
  private
87
117
 
118
+ def logger
119
+ @config.logger
120
+ end
121
+
122
+ # Caller must hold @mutex
123
+ def start_browser
124
+ raise SessionError, "Session #{@id} is closed" if @closed
125
+
126
+ @browser_manager.start unless @browser_manager.active?
127
+ @last_used_at = Time.now
128
+ end
129
+
88
130
  def normalize_options(options)
89
131
  {
90
132
  browser_id: options[:browser_id] || options['browser_id'],
@@ -157,6 +199,21 @@ module FerrumMCP
157
199
  @base_config.logger
158
200
  end
159
201
 
202
+ def url_policy
203
+ @base_config.url_policy
204
+ end
205
+
206
+ def upload_allowed_dirs
207
+ @base_config.upload_allowed_dirs
208
+ end
209
+
210
+ # Browser flags handed to Ferrum: defaults merged with session-specific
211
+ # options. Ferrum prefixes every key with "--" itself, so keys are stored
212
+ # without dashes ("window-size", not "--window-size").
213
+ def merged_browser_options
214
+ default_browser_options.merge(normalized_browser_options)
215
+ end
216
+
160
217
  private
161
218
 
162
219
  def resolve_browser(overrides, base_config)
@@ -200,10 +257,10 @@ module FerrumMCP
200
257
  end
201
258
  end
202
259
 
203
- # Merge session-specific browser options with base options
204
- def merged_browser_options
205
- base_options = default_browser_options
206
- base_options.merge(@browser_options)
260
+ def normalized_browser_options
261
+ @browser_options.to_h.transform_keys do |key|
262
+ key.to_s.sub(/\A-+/, '')
263
+ end
207
264
  end
208
265
 
209
266
  def default_browser_options
@@ -215,6 +272,7 @@ module FerrumMCP
215
272
  }
216
273
 
217
274
  options['disable-setuid-sandbox'] = nil if ENV['CI']
275
+ options['user-data-dir'] = user_profile.path if user_profile
218
276
 
219
277
  # Add BotBrowser profile if configured
220
278
  if using_botbrowser? && botbrowser_profile && File.exist?(botbrowser_profile)
@@ -62,17 +62,15 @@ module FerrumMCP
62
62
  # Close a specific session
63
63
  # @param session_id [String] Session ID
64
64
  # @return [Boolean] Success
65
- def close_session(session_id)
66
- @mutex.synchronize do
67
- session = @sessions[session_id]
68
- return false unless session
69
-
70
- logger.info "Closing session #{session_id}"
71
- session.stop
72
- @sessions.delete(session_id)
73
-
74
- true
75
- end
65
+ def close_session(session_id) # rubocop:disable Naming/PredicateMethod
66
+ # Remove under the global lock, stop outside of it: stopping Chrome can
67
+ # take seconds and must not block every other session meanwhile.
68
+ session = @mutex.synchronize { @sessions.delete(session_id) }
69
+ return false unless session
70
+
71
+ logger.info "Closing session #{session_id}"
72
+ session.close
73
+ true
76
74
  end
77
75
 
78
76
  # List all active sessions
@@ -91,11 +89,13 @@ module FerrumMCP
91
89
 
92
90
  # Close all sessions
93
91
  def close_all_sessions
94
- @mutex.synchronize do
92
+ sessions = @mutex.synchronize do
95
93
  logger.info "Closing all #{@sessions.size} sessions"
96
- @sessions.each_value(&:stop)
94
+ removed = @sessions.values
97
95
  @sessions.clear
96
+ removed
98
97
  end
98
+ sessions.each(&:close)
99
99
  end
100
100
 
101
101
  # Execute a block with a session (thread-safe)
@@ -107,8 +107,8 @@ module FerrumMCP
107
107
  session = get_session(session_id)
108
108
  raise SessionError, "Session not found: #{session_id}" unless session
109
109
 
110
- # Start browser if not active
111
- session.start unless session.active?
110
+ # Start the browser if needed (or restart it if its process died)
111
+ session.ensure_started
112
112
 
113
113
  # Execute with thread-safe access
114
114
  session.with_browser(&)