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.
- checksums.yaml +4 -4
- data/.env.example +37 -0
- data/CHANGELOG.md +72 -1
- data/README.md +30 -9
- data/docs/API_REFERENCE.md +300 -21
- data/docs/CONFIGURATION.md +34 -1
- data/docs/DEPLOYMENT.md +1 -0
- data/docs/DOCKER_BOTBROWSER.md +4 -0
- data/lib/ferrum_mcp/browser_manager.rb +56 -32
- data/lib/ferrum_mcp/cli/command_handler.rb +4 -3
- data/lib/ferrum_mcp/cli/server_runner.rb +19 -6
- data/lib/ferrum_mcp/configuration.rb +77 -18
- data/lib/ferrum_mcp/image_resizer.rb +43 -0
- data/lib/ferrum_mcp/server.rb +58 -91
- data/lib/ferrum_mcp/session.rb +67 -9
- data/lib/ferrum_mcp/session_manager.rb +15 -15
- data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +11 -31
- data/lib/ferrum_mcp/tools/base_tool.rb +79 -38
- data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +14 -43
- data/lib/ferrum_mcp/tools/click_tool.rb +58 -181
- data/lib/ferrum_mcp/tools/close_session_tool.rb +8 -30
- data/lib/ferrum_mcp/tools/close_tab_tool.rb +44 -0
- data/lib/ferrum_mcp/tools/create_session_tool.rb +38 -110
- data/lib/ferrum_mcp/tools/definition.rb +106 -0
- data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +43 -145
- data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +6 -28
- data/lib/ferrum_mcp/tools/execute_script_tool.rb +6 -30
- data/lib/ferrum_mcp/tools/fill_form_tool.rb +37 -52
- data/lib/ferrum_mcp/tools/find_by_text_tool.rb +39 -114
- data/lib/ferrum_mcp/tools/get_attribute_tool.rb +10 -39
- data/lib/ferrum_mcp/tools/get_cookies_tool.rb +23 -52
- data/lib/ferrum_mcp/tools/get_html_tool.rb +22 -35
- data/lib/ferrum_mcp/tools/get_session_info_tool.rb +5 -21
- data/lib/ferrum_mcp/tools/get_text_tool.rb +22 -46
- data/lib/ferrum_mcp/tools/get_title_tool.rb +5 -28
- data/lib/ferrum_mcp/tools/get_url_tool.rb +5 -25
- data/lib/ferrum_mcp/tools/go_back_tool.rb +7 -30
- data/lib/ferrum_mcp/tools/go_forward_tool.rb +7 -30
- data/lib/ferrum_mcp/tools/hover_tool.rb +10 -51
- data/lib/ferrum_mcp/tools/list_sessions_tool.rb +5 -19
- data/lib/ferrum_mcp/tools/list_tabs_tool.rb +20 -0
- data/lib/ferrum_mcp/tools/navigate_tool.rb +17 -37
- data/lib/ferrum_mcp/tools/new_tab_tool.rb +36 -0
- data/lib/ferrum_mcp/tools/press_key_tool.rb +18 -63
- data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +50 -205
- data/lib/ferrum_mcp/tools/refresh_tool.rb +7 -30
- data/lib/ferrum_mcp/tools/screenshot_tool.rb +29 -86
- data/lib/ferrum_mcp/tools/scroll_tool.rb +64 -0
- data/lib/ferrum_mcp/tools/select_option_tool.rb +56 -0
- data/lib/ferrum_mcp/tools/session_tool.rb +10 -9
- data/lib/ferrum_mcp/tools/set_cookie_tool.rb +15 -56
- data/lib/ferrum_mcp/tools/set_viewport_tool.rb +32 -0
- data/lib/ferrum_mcp/tools/snapshot_tool.rb +175 -0
- data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +17 -35
- data/lib/ferrum_mcp/tools/switch_tab_tool.rb +29 -0
- data/lib/ferrum_mcp/tools/tab_tool.rb +40 -0
- data/lib/ferrum_mcp/tools/upload_file_tool.rb +50 -0
- data/lib/ferrum_mcp/tools/wait_for_network_idle_tool.rb +32 -0
- data/lib/ferrum_mcp/tools/wait_for_selector_tool.rb +59 -0
- data/lib/ferrum_mcp/tools/wait_for_text_tool.rb +53 -0
- data/lib/ferrum_mcp/transport/api_key_authenticator.rb +112 -0
- data/lib/ferrum_mcp/transport/client_address.rb +23 -0
- data/lib/ferrum_mcp/transport/http_server.rb +11 -1
- data/lib/ferrum_mcp/transport/rate_limiter.rb +2 -6
- data/lib/ferrum_mcp/transport/stdio_server.rb +8 -30
- data/lib/ferrum_mcp/url_policy.rb +88 -0
- data/lib/ferrum_mcp/version.rb +1 -1
- 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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
42
|
+
def perform(params)
|
|
63
43
|
ensure_browser_active
|
|
64
|
-
wait_time =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
20
|
-
raise NotImplementedError, 'Subclasses must implement
|
|
35
|
+
def perform(_params)
|
|
36
|
+
raise NotImplementedError, 'Subclasses must implement #perform'
|
|
21
37
|
end
|
|
22
38
|
|
|
23
|
-
|
|
24
|
-
raise NotImplementedError, 'Subclasses must implement .description'
|
|
25
|
-
end
|
|
39
|
+
protected
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
# Ferrum::Browser of the session
|
|
42
|
+
def browser
|
|
43
|
+
@browser_manager.browser
|
|
29
44
|
end
|
|
30
45
|
|
|
31
|
-
|
|
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
|
-
#
|
|
40
|
-
def
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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 =
|
|
75
|
+
element = first_match(selector, within)
|
|
52
76
|
return element if element
|
|
53
77
|
|
|
54
|
-
raise ToolError, "Element not found: #{selector}" if
|
|
78
|
+
raise ToolError, "Element not found: #{selector}" if monotonic_now > deadline
|
|
55
79
|
|
|
56
|
-
|
|
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
|
-
#
|
|
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
|
|
107
|
+
sleep POLL_INTERVAL
|
|
74
108
|
retry
|
|
75
109
|
end
|
|
76
110
|
end
|
|
77
111
|
|
|
78
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
+
# Clear cookies
|
|
6
6
|
class ClearCookiesTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
12
|
+
def perform(params)
|
|
33
13
|
ensure_browser_active
|
|
34
|
-
domain = params[
|
|
14
|
+
domain = params[:domain]
|
|
35
15
|
|
|
36
16
|
if domain
|
|
37
17
|
logger.info "Clearing cookies for: #{domain}"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 #{
|
|
25
|
+
success_response(message: "Cleared #{removed} cookies for #{domain}", count: removed)
|
|
55
26
|
else
|
|
56
27
|
logger.info 'Clearing all cookies'
|
|
57
|
-
|
|
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
|
-
#
|
|
5
|
+
# Click on an element
|
|
6
6
|
class ClickTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
10
|
+
param :session_id, type: :string, required: true, description: 'The ID of the session to close'
|
|
32
11
|
|
|
33
|
-
|
|
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
|
|
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
|