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
@@ -6,31 +6,11 @@ module FerrumMCP
6
6
  # Uses multiple strategies to find and click accept buttons
7
7
  # rubocop:disable Metrics/ClassLength
8
8
  class AcceptCookiesTool < BaseTool
9
- def self.tool_name
10
- 'accept_cookies'
11
- end
12
-
13
- def self.description
14
- 'Automatically detect and accept cookie consent banners using multiple detection strategies'
15
- end
9
+ tool_name 'accept_cookies'
10
+ description 'Automatically detect and accept cookie consent banners using multiple detection strategies'
16
11
 
17
- def self.input_schema
18
- {
19
- type: 'object',
20
- properties: {
21
- wait: {
22
- type: 'number',
23
- description: 'Seconds to wait for cookie banner to appear (default: 3)',
24
- default: 3
25
- },
26
- session_id: {
27
- type: 'string',
28
- description: 'Session ID to use for this operation'
29
- }
30
- },
31
- required: %w[session_id]
32
- }
33
- end
12
+ param :wait, type: :number, default: 3,
13
+ description: 'Seconds to wait for the cookie banner to appear (default: 3)'
34
14
 
35
15
  # Common text patterns for cookie accept buttons (multiple languages)
36
16
  # Patterns are ordered from most specific to least specific to avoid false positives
@@ -59,9 +39,9 @@ module FerrumMCP
59
39
  settings options manage gérer
60
40
  ].freeze
61
41
 
62
- def execute(params)
42
+ def perform(params)
63
43
  ensure_browser_active
64
- wait_time = param(params, :wait) || 3
44
+ wait_time = params[:wait]
65
45
 
66
46
  logger.info 'Attempting to accept cookies using multiple strategies...'
67
47
 
@@ -262,13 +242,13 @@ module FerrumMCP
262
242
  logger.debug 'Trying iframe detection...'
263
243
 
264
244
  # Get all iframes
265
- iframes = browser.css('iframe')
245
+ iframes = page.css('iframe')
266
246
  return { found: false } if iframes.empty?
267
247
 
268
248
  logger.debug "Found #{iframes.length} iframe(s), checking for cookie banners..."
269
249
 
270
250
  # Get frames (includes main frame + all iframes)
271
- frames = browser.frames
251
+ frames = page.frames
272
252
  return { found: false } if frames.empty?
273
253
 
274
254
  # Skip the main frame (index 0), only check iframes
@@ -374,7 +354,7 @@ module FerrumMCP
374
354
  # Helper: Try multiple CSS selectors
375
355
  def try_selectors(selectors)
376
356
  selectors.each do |selector|
377
- element = browser.at_css(selector)
357
+ element = page.at_css(selector)
378
358
  next unless element
379
359
 
380
360
  # Check if element is visible
@@ -405,7 +385,7 @@ module FerrumMCP
405
385
  xpath = "//#{tag}[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " \
406
386
  "'abcdefghijklmnopqrstuvwxyz'), #{escaped_text})]"
407
387
 
408
- browser.xpath(xpath)
388
+ page.xpath(xpath)
409
389
  rescue StandardError => e
410
390
  logger.debug "XPath search for '#{text}' failed: #{e.message}"
411
391
  []
@@ -441,7 +421,7 @@ module FerrumMCP
441
421
 
442
422
  # Fallback to JavaScript click
443
423
  begin
444
- browser.execute(<<~JAVASCRIPT, element)
424
+ page.execute(<<~JAVASCRIPT, element)
445
425
  arguments[0].scrollIntoView({ behavior: 'instant', block: 'center' });
446
426
  arguments[0].click();
447
427
  JAVASCRIPT
@@ -2,65 +2,99 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Base class for all MCP tools
5
+ # Base class for tools that operate on a browser session.
6
+ #
7
+ # Subclasses declare their interface with the Definition DSL and implement
8
+ # #perform(params). Params arrive with symbol keys and defaults applied.
9
+ #
10
+ # Selectors accepted everywhere an element is looked up:
11
+ # - CSS: "#login button"
12
+ # - XPath: "xpath://button[@type='submit']" or "//button[...]"
13
+ # - Snapshot ref: "ref:e12" (from the snapshot tool)
6
14
  class BaseTool
7
- attr_reader :browser, :logger
15
+ extend Definition
16
+
17
+ requires_session true
18
+
19
+ REF_SELECTOR = /\Aref:(e\d+)\z/
20
+ REF_ATTRIBUTE = 'data-fmcp-ref'
21
+ POLL_INTERVAL = 0.1
22
+
23
+ attr_reader :logger
8
24
 
9
25
  def initialize(browser_manager)
10
26
  @browser_manager = browser_manager
11
- @browser = browser_manager.browser
12
27
  @logger = browser_manager.logger
13
28
  end
14
29
 
15
- def execute(params)
16
- raise NotImplementedError, 'Subclasses must implement #execute'
30
+ # Entry point used by the server and the specs.
31
+ def execute(raw_params)
32
+ perform(self.class.normalize_params(raw_params))
17
33
  end
18
34
 
19
- def self.tool_name
20
- raise NotImplementedError, 'Subclasses must implement .tool_name'
35
+ def perform(_params)
36
+ raise NotImplementedError, 'Subclasses must implement #perform'
21
37
  end
22
38
 
23
- def self.description
24
- raise NotImplementedError, 'Subclasses must implement .description'
25
- end
39
+ protected
26
40
 
27
- def self.input_schema
28
- raise NotImplementedError, 'Subclasses must implement .input_schema'
41
+ # Ferrum::Browser of the session
42
+ def browser
43
+ @browser_manager.browser
29
44
  end
30
45
 
31
- protected
46
+ # Current tab (Ferrum::Page). Tools act on the page, not the browser, so
47
+ # tab switching works.
48
+ def page
49
+ @browser_manager.page
50
+ end
32
51
 
33
52
  def ensure_browser_active
34
53
  raise BrowserError, 'Browser is not active' unless @browser_manager.active?
35
-
36
- @browser = @browser_manager.browser
37
54
  end
38
55
 
39
- # Helper to access params consistently (supports both string and symbol keys)
40
- def param(params, key)
41
- params[key.to_s] || params[key.to_sym]
56
+ # Resolve a selector string into [:css, selector] or [:xpath, expression]
57
+ def resolve_selector(selector)
58
+ selector = selector.to_s.strip
59
+ if (match = REF_SELECTOR.match(selector))
60
+ [:css, %([#{REF_ATTRIBUTE}="#{match[1]}"])]
61
+ elsif selector.start_with?('xpath:')
62
+ [:xpath, selector.delete_prefix('xpath:')]
63
+ elsif selector.start_with?('//', '(//')
64
+ [:xpath, selector]
65
+ else
66
+ [:css, selector]
67
+ end
42
68
  end
43
69
 
44
- # Find element with improved timeout handling
45
- # Uses shorter polling intervals for better responsiveness
46
- def find_element(selector, timeout: 5)
47
- ensure_browser_active
48
- deadline = Time.now + timeout
70
+ # Find the first element matching the selector, polling until timeout.
71
+ def find_element(selector, timeout: 5, within: page)
72
+ deadline = monotonic_now + timeout.to_f
49
73
 
50
74
  loop do
51
- element = browser.at_css(selector)
75
+ element = first_match(selector, within)
52
76
  return element if element
53
77
 
54
- raise ToolError, "Element not found: #{selector}" if Time.now > deadline
78
+ raise ToolError, "Element not found: #{selector}" if monotonic_now > deadline
55
79
 
56
- # Use shorter sleep for better responsiveness (0.1s instead of 0.5s)
57
- sleep 0.1
80
+ sleep POLL_INTERVAL
58
81
  end
59
82
  rescue Ferrum::NodeNotFoundError
60
83
  raise ToolError, "Element not found: #{selector}"
61
84
  end
62
85
 
63
- # Retry logic for handling stale/moving elements
86
+ # All elements matching the selector (no waiting)
87
+ def find_elements(selector, within: page)
88
+ kind, expression = resolve_selector(selector)
89
+ kind == :xpath ? within.xpath(expression) : within.css(expression)
90
+ end
91
+
92
+ def first_match(selector, within = page)
93
+ kind, expression = resolve_selector(selector)
94
+ kind == :xpath ? within.at_xpath(expression) : within.at_css(expression)
95
+ end
96
+
97
+ # Retry logic for stale/moving elements
64
98
  def with_retry(retries: 3)
65
99
  attempts = 0
66
100
  begin
@@ -70,34 +104,41 @@ module FerrumMCP
70
104
  raise ToolError, "Element became stale after #{retries} retries" unless attempts < retries
71
105
 
72
106
  logger.debug "Retry #{attempts}/#{retries} due to: #{e.class}"
73
- sleep 0.1
107
+ sleep POLL_INTERVAL
74
108
  retry
75
109
  end
76
110
  end
77
111
 
78
- # Check if element is actually visible (has dimensions and not hidden)
112
+ # Visible = rendered with a size and not hidden through CSS
79
113
  def element_visible?(element)
80
114
  return false unless element
81
115
 
82
- # Check both CSS visibility and actual rendered dimensions
83
- script = <<~JS
116
+ page.evaluate(<<~JS, element)
84
117
  (function(el) {
85
118
  if (!el) return false;
86
119
  const rect = el.getBoundingClientRect();
87
120
  const style = window.getComputedStyle(el);
88
- return rect.width > 0 &&
89
- rect.height > 0 &&
90
- style.visibility !== 'hidden' &&
91
- style.display !== 'none';
121
+ return rect.width > 0 && rect.height > 0 &&
122
+ style.visibility !== 'hidden' && style.display !== 'none';
92
123
  })(arguments[0])
93
124
  JS
94
-
95
- browser.evaluate(script, element)
96
125
  rescue StandardError => e
97
126
  logger.debug "Error checking element visibility: #{e.message}"
98
127
  false
99
128
  end
100
129
 
130
+ # Escape a string for use inside an XPath expression
131
+ def xpath_literal(text)
132
+ return "'#{text}'" unless text.include?("'")
133
+
134
+ parts = text.split("'", -1).map { |part| "'#{part}'" }
135
+ "concat(#{parts.join(%(, "'", ))})"
136
+ end
137
+
138
+ def monotonic_now
139
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ end
141
+
101
142
  def success_response(data = {})
102
143
  { success: true, data: data }
103
144
  end
@@ -2,59 +2,30 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to clear cookies
5
+ # Clear cookies
6
6
  class ClearCookiesTool < BaseTool
7
- def self.tool_name
8
- 'clear_cookies'
9
- end
10
-
11
- def self.description
12
- 'Clear all cookies or cookies for a specific domain'
13
- end
7
+ tool_name 'clear_cookies'
8
+ description 'Clear all cookies, or only the cookies whose domain contains the given string'
14
9
 
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- domain: {
20
- type: 'string',
21
- description: 'Optional: Clear cookies only for this domain'
22
- },
23
- session_id: {
24
- type: 'string',
25
- description: 'Session ID to use for this operation'
26
- }
27
- },
28
- required: ['session_id']
29
- }
30
- end
10
+ param :domain, type: :string, description: 'Optional: clear cookies only for this domain (substring match)'
31
11
 
32
- def execute(params)
12
+ def perform(params)
33
13
  ensure_browser_active
34
- domain = params['domain'] || params[:domain]
14
+ domain = params[:domain]
35
15
 
36
16
  if domain
37
17
  logger.info "Clearing cookies for: #{domain}"
38
- # cookies.all returns a hash where keys are cookie names and values can be Cookie objects or strings
39
- all_cookies = browser.cookies.all
40
- cookies_to_remove = all_cookies.select do |_name, cookie|
41
- cookie_str = cookie.is_a?(String) ? cookie : cookie.to_s
42
- cookie_str&.match(/Domain=([^;]+)/i) &&
43
- Regexp.last_match(1).include?(domain)
44
- end
45
- # Remove each cookie by name with domain
46
- cookies_to_remove.each do |cookie_name, cookie|
47
- # Extract domain from cookie string
48
- cookie_str = cookie.is_a?(String) ? cookie : cookie.to_s
49
- if cookie_str =~ /Domain=([^;]+)/i
50
- cookie_domain = Regexp.last_match(1).strip
51
- browser.cookies.remove(name: cookie_name, domain: cookie_domain)
52
- end
18
+ removed = 0
19
+ page.cookies.all.each_value do |cookie|
20
+ next unless cookie.respond_to?(:domain) && cookie.domain.to_s.include?(domain)
21
+
22
+ page.cookies.remove(name: cookie.name, domain: cookie.domain, path: cookie.path)
23
+ removed += 1
53
24
  end
54
- success_response(message: "Cleared #{cookies_to_remove.length} cookies for #{domain}")
25
+ success_response(message: "Cleared #{removed} cookies for #{domain}", count: removed)
55
26
  else
56
27
  logger.info 'Clearing all cookies'
57
- browser.cookies.clear
28
+ page.cookies.clear
58
29
  success_response(message: 'All cookies cleared')
59
30
  end
60
31
  rescue StandardError => e
@@ -2,83 +2,35 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to click on an element
5
+ # Click on an element
6
6
  class ClickTool < BaseTool
7
- def self.tool_name
8
- 'click'
9
- end
7
+ tool_name 'click'
8
+ description 'Click on an element using a CSS selector, an XPath (xpath: prefix) or a snapshot ref (ref:eN)'
10
9
 
11
- def self.description
12
- 'Click on an element using a CSS selector or XPath'
13
- end
10
+ param :selector, type: :string, required: true,
11
+ description: 'CSS selector, XPath (xpath: prefix) or snapshot ref (ref:e12) of the element'
12
+ param :wait, type: :number, default: 5, description: 'Seconds to wait for the element (default: 5)'
13
+ param :force, type: :boolean, default: false,
14
+ description: 'Force click through JavaScript when the element is hidden (default: false)'
14
15
 
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- selector: {
20
- type: 'string',
21
- description: 'CSS selector or XPath of the element to click (use xpath: prefix for XPath)'
22
- },
23
- wait: {
24
- type: 'number',
25
- description: 'Seconds to wait for element (default: 5)',
26
- default: 5
27
- },
28
- force: {
29
- type: 'boolean',
30
- description: 'Force click even if element is hidden or not visible (default: false)',
31
- default: false
32
- },
33
- session_id: {
34
- type: 'string',
35
- description: 'Session ID to use for this operation'
36
- }
37
- },
38
- required: %w[selector session_id]
39
- }
40
- end
41
-
42
- def execute(params)
43
- selector = param(params, :selector)
44
- wait_time = param(params, :wait) || 5
45
- force = param(params, :force) || false
16
+ def perform(params)
17
+ selector = params[:selector]
18
+ force = params[:force]
46
19
 
47
20
  logger.info "Clicking element: #{selector} (force: #{force})"
48
21
 
49
- # Use retry logic for stale elements
50
22
  with_retry do
51
- element = find_element_robust(selector, wait_time)
52
-
53
- # Scroll element into view before clicking
54
- element.scroll_into_view if element.respond_to?(:scroll_into_view)
55
-
56
- # Use native Ferrum click
23
+ element = find_clickable(selector, params[:wait])
24
+ element.scroll_into_view
57
25
  element.click
58
26
  end
59
27
 
60
28
  success_response(message: "Clicked on #{selector}")
61
29
  rescue Ferrum::NodeNotFoundError, Ferrum::CoordinatesNotFoundError, Ferrum::NodeMovingError => e
62
- # If native click fails and force is enabled, try with JavaScript
63
- if force
64
- logger.warn "Native click failed, retrying with JavaScript: #{e.message}"
65
- click_with_javascript(selector)
66
- success_response(message: "Clicked on #{selector} (forced)")
67
- else
68
- logger.error "Click failed: #{e.message}"
69
- error_response("Failed to click: #{e.message}. Try with force: true")
70
- end
30
+ forced_click(selector, e, force)
71
31
  rescue StandardError => e
72
- # Handle "Node does not have a layout object" and similar errors
73
32
  if e.message.include?('layout object') || e.message.include?('not visible')
74
- if force
75
- logger.warn "Element not visible, forcing click with JavaScript: #{e.message}"
76
- click_with_javascript(selector)
77
- success_response(message: "Clicked on #{selector} (forced)")
78
- else
79
- logger.error "Click failed: #{e.message}"
80
- error_response("Failed to click: #{e.message}. Try with force: true")
81
- end
33
+ forced_click(selector, e, force)
82
34
  else
83
35
  logger.error "Click failed: #{e.message}"
84
36
  error_response("Failed to click: #{e.message}")
@@ -87,131 +39,56 @@ module FerrumMCP
87
39
 
88
40
  private
89
41
 
90
- def find_element_robust(selector, timeout)
91
- deadline = Time.now + timeout
92
-
93
- # Support both CSS and XPath selectors
94
- if selector.start_with?('xpath:', '//')
95
- xpath = selector.sub(/^xpath:/, '')
96
- logger.debug "Using XPath: #{xpath}"
97
-
98
- # Retry XPath search until timeout
99
- elements = []
100
- loop do
101
- elements = browser.xpath(xpath)
102
- break unless elements.empty?
103
-
104
- raise ToolError, "Element not found with XPath: #{xpath}" if Time.now > deadline
105
-
106
- sleep 0.2
107
- end
108
-
109
- # Prefer visible element
110
- element = elements.find { |el| element_visible?(el) } || elements.first
111
- visibility = element_visible?(element) ? 'visible' : 'first'
112
- logger.debug "Found #{elements.length} XPath matches, using #{visibility} one"
113
- else
114
- # For CSS selectors, use the base find_element with timeout
115
- element = find_element(selector, timeout: timeout)
42
+ def forced_click(selector, error, force)
43
+ unless force
44
+ logger.error "Click failed: #{error.message}"
45
+ return error_response("Failed to click: #{error.message}. Try with force: true")
116
46
  end
117
47
 
118
- element
48
+ logger.warn "Native click failed, retrying with JavaScript: #{error.message}"
49
+ click_with_javascript(selector)
50
+ success_response(message: "Clicked on #{selector} (forced)")
119
51
  end
120
52
 
121
- def click_with_javascript(selector) # rubocop:disable Metrics/MethodLength
122
- logger.info "Using JavaScript click for: #{selector}"
123
-
124
- # Build JavaScript to find and click element, even if hidden
125
- if selector.start_with?('xpath:', '//')
126
- xpath = selector.sub(/^xpath:/, '')
127
- script = <<~JAVASCRIPT
128
- const xpath = #{xpath.inspect};
129
- const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
130
- const elements = [];
131
- for (let i = 0; i < result.snapshotLength; i++) {
132
- elements.push(result.snapshotItem(i));
133
- }
53
+ # Prefer a visible match when several elements match
54
+ def find_clickable(selector, timeout)
55
+ element = find_element(selector, timeout: timeout)
56
+ return element if element_visible?(element)
134
57
 
135
- if (elements.length === 0) {
136
- throw new Error('No element found with XPath: ' + xpath);
137
- }
138
-
139
- // Find first visible element, or use first element if force clicking
140
- const visible = elements.find(el => el.offsetWidth > 0 && el.offsetHeight > 0);
141
- const target = visible || elements[0];
142
-
143
- // For hidden elements, temporarily show them, click, then hide again
144
- const wasHidden = target.offsetWidth === 0 && target.offsetHeight === 0;
145
- const originalDisplay = target.style.display;
146
- const originalVisibility = target.style.visibility;
147
-
148
- if (wasHidden) {
149
- target.style.display = 'block';
150
- target.style.visibility = 'visible';
151
- }
152
-
153
- try {
154
- target.scrollIntoView({ behavior: 'instant', block: 'center' });
155
- target.click();
156
- } finally {
157
- if (wasHidden) {
158
- target.style.display = originalDisplay;
159
- target.style.visibility = originalVisibility;
160
- }
161
- }
162
-
163
- return true;
164
- JAVASCRIPT
165
- else
166
- script = <<~JAVASCRIPT
167
- const elements = Array.from(document.querySelectorAll(#{selector.inspect}));
168
-
169
- if (elements.length === 0) {
170
- throw new Error('No elements found with selector: #{selector}');
171
- }
172
-
173
- // Find first visible element, or use first element if force clicking
174
- const visible = elements.find(el => el.offsetWidth > 0 && el.offsetHeight > 0);
175
- const target = visible || elements[0];
176
-
177
- // For hidden elements, temporarily show them, click, then hide again
178
- const wasHidden = target.offsetWidth === 0 && target.offsetHeight === 0;
179
- const originalDisplay = target.style.display;
180
- const originalVisibility = target.style.visibility;
181
-
182
- if (wasHidden) {
183
- target.style.display = 'block';
184
- target.style.visibility = 'visible';
185
- }
186
-
187
- try {
188
- target.scrollIntoView({ behavior: 'instant', block: 'center' });
189
- target.click();
190
- } finally {
191
- if (wasHidden) {
192
- target.style.display = originalDisplay;
193
- target.style.visibility = originalVisibility;
194
- }
195
- }
196
-
197
- return true;
198
- JAVASCRIPT
199
- end
200
-
201
- browser.execute(script)
202
- logger.debug 'JavaScript click executed successfully'
58
+ visible = find_elements(selector).find { |el| element_visible?(el) }
59
+ visible || element
203
60
  end
204
61
 
205
- def element_visible?(element)
206
- return false unless element
207
-
208
- # Use property instead of evaluate to avoid "Node does not have a layout object" errors
209
- offset_width = element.property('offsetWidth')
210
- offset_height = element.property('offsetHeight')
211
- offset_width&.positive? && offset_height&.positive?
212
- rescue StandardError => e
213
- logger.debug "Cannot check visibility: #{e.message}"
214
- false
62
+ def click_with_javascript(selector)
63
+ logger.info "Using JavaScript click for: #{selector}"
64
+ kind, expression = resolve_selector(selector)
65
+ finder = if kind == :xpath
66
+ <<~JS
67
+ const result = document.evaluate(#{expression.inspect}, document, null,
68
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
69
+ const elements = [];
70
+ for (let i = 0; i < result.snapshotLength; i++) elements.push(result.snapshotItem(i));
71
+ JS
72
+ else
73
+ "const elements = Array.from(document.querySelectorAll(#{expression.inspect}));"
74
+ end
75
+
76
+ page.execute(<<~JS)
77
+ #{finder}
78
+ if (elements.length === 0) throw new Error('No element found for ' + #{selector.inspect});
79
+ const visible = elements.find(el => el.offsetWidth > 0 && el.offsetHeight > 0);
80
+ const target = visible || elements[0];
81
+ const wasHidden = target.offsetWidth === 0 && target.offsetHeight === 0;
82
+ const originalDisplay = target.style.display;
83
+ const originalVisibility = target.style.visibility;
84
+ if (wasHidden) { target.style.display = 'block'; target.style.visibility = 'visible'; }
85
+ try {
86
+ target.scrollIntoView({ behavior: 'instant', block: 'center' });
87
+ target.click();
88
+ } finally {
89
+ if (wasHidden) { target.style.display = originalDisplay; target.style.visibility = originalVisibility; }
90
+ }
91
+ JS
215
92
  end
216
93
  end
217
94
  end
@@ -4,39 +4,17 @@ module FerrumMCP
4
4
  module Tools
5
5
  # Close a specific session
6
6
  class CloseSessionTool < SessionTool
7
- def self.tool_name
8
- 'close_session'
9
- end
10
-
11
- def self.description
12
- 'Close a specific browser session by its ID. The browser will be stopped and the session will be removed.'
13
- end
14
-
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- session_id: {
20
- type: 'string',
21
- description: 'The ID of the session to close'
22
- }
23
- },
24
- required: ['session_id']
25
- }
26
- end
27
-
28
- def execute(params)
29
- session_id = params[:session_id] || params['session_id']
7
+ tool_name 'close_session'
8
+ description 'Close a specific browser session by its ID. The browser will be stopped and the session removed.'
30
9
 
31
- return error_response('session_id is required') unless session_id
10
+ param :session_id, type: :string, required: true, description: 'The ID of the session to close'
32
11
 
33
- success = session_manager.close_session(session_id)
12
+ def perform(params)
13
+ session_id = params[:session_id]
14
+ return error_response('session_id is required') if session_id.to_s.empty?
34
15
 
35
- if success
36
- success_response(
37
- session_id: session_id,
38
- message: 'Session closed successfully'
39
- )
16
+ if session_manager.close_session(session_id)
17
+ success_response(session_id: session_id, message: 'Session closed successfully')
40
18
  else
41
19
  error_response("Session not found: #{session_id}")
42
20
  end