ferrum-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +90 -0
  3. data/CHANGELOG.md +229 -0
  4. data/CONTRIBUTING.md +469 -0
  5. data/LICENSE +21 -0
  6. data/README.md +334 -0
  7. data/SECURITY.md +286 -0
  8. data/bin/ferrum-mcp +66 -0
  9. data/bin/lint +10 -0
  10. data/bin/serve +3 -0
  11. data/bin/test +4 -0
  12. data/docs/API_REFERENCE.md +1410 -0
  13. data/docs/CONFIGURATION.md +254 -0
  14. data/docs/DEPLOYMENT.md +846 -0
  15. data/docs/DOCKER.md +836 -0
  16. data/docs/DOCKER_BOTBROWSER.md +455 -0
  17. data/docs/GETTING_STARTED.md +249 -0
  18. data/docs/TROUBLESHOOTING.md +677 -0
  19. data/lib/ferrum_mcp/browser_manager.rb +101 -0
  20. data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
  21. data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
  22. data/lib/ferrum_mcp/configuration.rb +229 -0
  23. data/lib/ferrum_mcp/resource_manager.rb +223 -0
  24. data/lib/ferrum_mcp/server.rb +254 -0
  25. data/lib/ferrum_mcp/session.rb +227 -0
  26. data/lib/ferrum_mcp/session_manager.rb +183 -0
  27. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
  28. data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
  29. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
  30. data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
  31. data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
  32. data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
  33. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
  34. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
  35. data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
  36. data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
  37. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
  38. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
  39. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
  40. data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
  41. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
  42. data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
  43. data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
  44. data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
  45. data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
  46. data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
  47. data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
  48. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
  49. data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
  50. data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
  51. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
  52. data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
  53. data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
  54. data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
  55. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
  56. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
  57. data/lib/ferrum_mcp/transport/http_server.rb +93 -0
  58. data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
  59. data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
  60. data/lib/ferrum_mcp/version.rb +5 -0
  61. data/lib/ferrum_mcp/whisper_service.rb +222 -0
  62. data/lib/ferrum_mcp.rb +35 -0
  63. metadata +248 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to perform drag and drop operations
6
+ class DragAndDropTool < BaseTool
7
+ def self.tool_name
8
+ 'drag_and_drop'
9
+ end
10
+
11
+ def self.description
12
+ 'Drag an element and drop it onto another element or coordinates'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ source_selector: {
20
+ type: 'string',
21
+ description: 'CSS selector or XPath of the element to drag (use xpath: prefix for XPath)'
22
+ },
23
+ target_selector: {
24
+ type: 'string',
25
+ description: 'CSS selector or XPath of the drop target (optional if using coordinates)'
26
+ },
27
+ target_x: {
28
+ type: 'number',
29
+ description: 'X coordinate to drop at (alternative to target_selector)'
30
+ },
31
+ target_y: {
32
+ type: 'number',
33
+ description: 'Y coordinate to drop at (alternative to target_selector)'
34
+ },
35
+ steps: {
36
+ type: 'number',
37
+ description: 'Number of steps for smooth dragging (default: 10)',
38
+ default: 10
39
+ },
40
+ session_id: {
41
+ type: 'string',
42
+ description: 'Session ID to use for this operation'
43
+ }
44
+ },
45
+ required: %w[source_selector session_id]
46
+ }
47
+ end
48
+
49
+ def execute(params) # rubocop:disable Metrics/MethodLength
50
+ source_selector = param(params, :source_selector)
51
+ target_selector = param(params, :target_selector)
52
+ target_x = param(params, :target_x)
53
+ target_y = param(params, :target_y)
54
+ steps = param(params, :steps) || 10
55
+
56
+ logger.info "Dragging #{source_selector} to #{target_selector || "(#{target_x}, #{target_y})"}"
57
+
58
+ # Wait for source element to exist using find_element
59
+ begin
60
+ find_element_for_drag(source_selector)
61
+ rescue StandardError => e
62
+ raise ToolError, "Source element not found: #{source_selector} - #{e.message}"
63
+ end
64
+
65
+ # Get position using JavaScript
66
+ selector_js = source_selector.inspect
67
+ script = <<~JS.strip
68
+ (function() {
69
+ var el = document.querySelector(#{selector_js});
70
+ if (!el) return null;
71
+ var rect = el.getBoundingClientRect();
72
+ var result = {};
73
+ result.x = rect.left + rect.width / 2;
74
+ result.y = rect.top + rect.height / 2;
75
+ return result;
76
+ })()
77
+ JS
78
+
79
+ source_pos = browser.evaluate(script)
80
+ raise ToolError, "Failed to get position for: #{source_selector}" if source_pos.nil?
81
+
82
+ source_x = source_pos['x']
83
+ source_y = source_pos['y']
84
+
85
+ # Determine target coordinates
86
+ if target_selector
87
+ selector_js = target_selector.inspect
88
+ script = <<~JS.strip
89
+ (function() {
90
+ var el = document.querySelector(#{selector_js});
91
+ if (!el) return null;
92
+ var rect = el.getBoundingClientRect();
93
+ var result = {};
94
+ result.x = rect.left + rect.width / 2;
95
+ result.y = rect.top + rect.height / 2;
96
+ return result;
97
+ })()
98
+ JS
99
+
100
+ target_pos = browser.evaluate(script)
101
+ raise ToolError, "Target element not found: #{target_selector}" if target_pos.nil?
102
+
103
+ final_x = target_pos['x']
104
+ final_y = target_pos['y']
105
+ elsif target_x && target_y
106
+ final_x = target_x
107
+ final_y = target_y
108
+ else
109
+ raise ToolError, 'Either target_selector or both target_x and target_y must be provided'
110
+ end
111
+
112
+ # Perform drag and drop using mouse operations
113
+ perform_drag_and_drop(source_x, source_y, final_x, final_y, steps)
114
+
115
+ success_response(
116
+ message: "Dragged from (#{source_x.round}, #{source_y.round}) to (#{final_x.round}, #{final_y.round})"
117
+ )
118
+ rescue StandardError => e
119
+ logger.error "Drag and drop failed: #{e.message}"
120
+ error_response("Failed to drag and drop: #{e.message}")
121
+ end
122
+
123
+ private
124
+
125
+ def find_element_for_drag(selector)
126
+ if selector.start_with?('xpath:', '//')
127
+ xpath = selector.sub(/^xpath:/, '')
128
+ elements = browser.xpath(xpath)
129
+ raise "Element not found with XPath: #{xpath}" if elements.empty?
130
+
131
+ elements.first
132
+ else
133
+ find_element(selector)
134
+ end
135
+ end
136
+
137
+ def perform_drag_and_drop(from_x, from_y, to_x, to_y, steps)
138
+ mouse = browser.mouse
139
+
140
+ # Move to source element
141
+ mouse.move(x: from_x, y: from_y)
142
+ sleep 0.05
143
+
144
+ # Press mouse button
145
+ mouse.down
146
+ sleep 0.05 # Small delay to ensure mousedown registers
147
+
148
+ # Calculate step size for smooth drag
149
+ step_x = (to_x - from_x) / steps.to_f
150
+ step_y = (to_y - from_y) / steps.to_f
151
+
152
+ # Calculate delay per step (aim for ~300ms total drag time)
153
+ delay_per_step = [0.3 / steps, 0.01].max # At least 10ms per step
154
+
155
+ # Perform smooth drag
156
+ (1..steps).each do |step|
157
+ current_x = from_x + (step_x * step)
158
+ current_y = from_y + (step_y * step)
159
+ mouse.move(x: current_x, y: current_y)
160
+ sleep delay_per_step
161
+ end
162
+
163
+ # Release mouse button
164
+ sleep 0.05 # Small delay before release
165
+ mouse.up
166
+
167
+ logger.debug 'Drag and drop completed'
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to evaluate JavaScript
6
+ class EvaluateJSTool < BaseTool
7
+ def self.tool_name
8
+ 'evaluate_js'
9
+ end
10
+
11
+ def self.description
12
+ 'Evaluate JavaScript expression and return the result'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ expression: {
20
+ type: 'string',
21
+ description: 'JavaScript expression to evaluate'
22
+ },
23
+ session_id: {
24
+ type: 'string',
25
+ description: 'Session ID to use for this operation'
26
+ }
27
+ },
28
+ required: %w[expression session_id]
29
+ }
30
+ end
31
+
32
+ def execute(params)
33
+ ensure_browser_active
34
+ expression = params['expression'] || params[:expression]
35
+
36
+ logger.info 'Evaluating JavaScript'
37
+ result = browser.evaluate(expression)
38
+
39
+ success_response(result: result)
40
+ rescue StandardError => e
41
+ logger.error "Evaluate JS failed: #{e.message}"
42
+ error_response("Failed to evaluate JS: #{e.message}")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to execute JavaScript code
6
+ class ExecuteScriptTool < BaseTool
7
+ def self.tool_name
8
+ 'execute_script'
9
+ end
10
+
11
+ def self.description
12
+ 'Execute JavaScript code in the browser context'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ script: {
20
+ type: 'string',
21
+ description: 'JavaScript code to execute'
22
+ },
23
+ session_id: {
24
+ type: 'string',
25
+ description: 'Session ID to use for this operation'
26
+ }
27
+ },
28
+ required: %w[script session_id]
29
+ }
30
+ end
31
+
32
+ def execute(params)
33
+ ensure_browser_active
34
+ script = param(params, :script)
35
+
36
+ logger.info 'Executing JavaScript'
37
+ # Use execute for side effects (doesn't return value)
38
+ # For getting return values, users should use EvaluateJSTool
39
+ browser.execute(script)
40
+
41
+ success_response(message: 'Script executed successfully')
42
+ rescue StandardError => e
43
+ logger.error "Execute script failed: #{e.message}"
44
+ error_response("Failed to execute script: #{e.message}")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to fill form fields
6
+ class FillFormTool < BaseTool
7
+ def self.tool_name
8
+ 'fill_form'
9
+ end
10
+
11
+ def self.description
12
+ 'Fill one or more form fields with values'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ fields: {
20
+ type: 'array',
21
+ description: 'Array of fields to fill',
22
+ items: {
23
+ type: 'object',
24
+ properties: {
25
+ selector: { type: 'string', description: 'CSS selector' },
26
+ value: { type: 'string', description: 'Value to fill' }
27
+ },
28
+ required: %w[selector value]
29
+ }
30
+ },
31
+ session_id: {
32
+ type: 'string',
33
+ description: 'Session ID to use for this operation'
34
+ }
35
+ },
36
+ required: %w[fields session_id]
37
+ }
38
+ end
39
+
40
+ def execute(params)
41
+ fields = param(params, :fields)
42
+ results = []
43
+
44
+ fields.each_with_index do |field, index|
45
+ selector = field['selector'] || field[:selector]
46
+ value = field['value'] || field[:value]
47
+
48
+ logger.info "Filling field: #{selector}"
49
+
50
+ # Use retry logic for stale elements
51
+ with_retry do
52
+ element = find_element(selector)
53
+
54
+ # Scroll into view to ensure element is visible
55
+ element.scroll_into_view if element.respond_to?(:scroll_into_view)
56
+
57
+ # Focus with small delay to allow focus event to register
58
+ element.focus
59
+ sleep 0.05
60
+
61
+ # Type the value
62
+ element.type(value)
63
+ end
64
+
65
+ results << { selector: selector, filled: true }
66
+
67
+ # Small delay between fields to allow validation/autocomplete/onChange handlers
68
+ sleep 0.1 unless index == fields.length - 1
69
+ end
70
+
71
+ success_response(fields: results)
72
+ rescue StandardError => e
73
+ logger.error "Fill form failed: #{e.message}"
74
+ error_response("Failed to fill form: #{e.message}")
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to find elements by their text content
6
+ class FindByTextTool < BaseTool
7
+ def self.tool_name
8
+ 'find_by_text'
9
+ end
10
+
11
+ def self.description
12
+ 'Find elements by their text content using XPath'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ text: {
20
+ type: 'string',
21
+ description: 'Text to search for (exact match or contains)'
22
+ },
23
+ tag: {
24
+ type: 'string',
25
+ description: 'HTML tag to search within (e.g., "button", "a", "div"). Use "*" for any tag (default)',
26
+ default: '*'
27
+ },
28
+ exact: {
29
+ type: 'boolean',
30
+ description: 'Whether to match exact text (true) or partial text (false, default)',
31
+ default: false
32
+ },
33
+ multiple: {
34
+ type: 'boolean',
35
+ description: 'Return all matching elements (true) or just the first visible one (false, default)',
36
+ default: false
37
+ },
38
+ session_id: {
39
+ type: 'string',
40
+ description: 'Session ID to use for this operation'
41
+ }
42
+ },
43
+ required: %w[text session_id]
44
+ }
45
+ end
46
+
47
+ def execute(params)
48
+ ensure_browser_active
49
+
50
+ text = param(params, :text)
51
+ tag = param(params, :tag) || '*'
52
+ exact = param(params, :exact) || false
53
+ multiple = param(params, :multiple) || false
54
+
55
+ logger.info "Finding elements with text: '#{text}' in <#{tag}> tags (exact: #{exact}, multiple: #{multiple})"
56
+
57
+ # Escape text for XPath using concat() to handle quotes safely
58
+ # This prevents XPath injection while maintaining the original text
59
+ escaped_text = escape_xpath_string(text)
60
+
61
+ # Build XPath query with escaped text
62
+ xpath = if exact
63
+ "//#{tag}[normalize-space(text())=#{escaped_text}]"
64
+ else
65
+ "//#{tag}[contains(normalize-space(.), #{escaped_text})]"
66
+ end
67
+
68
+ logger.debug "Using XPath: #{xpath}"
69
+
70
+ elements = browser.xpath(xpath)
71
+ return error_response("No elements found with text: '#{text}'") if elements.empty?
72
+
73
+ if multiple
74
+ # Find all matching elements
75
+
76
+ results = elements.map.with_index do |element, index|
77
+ {
78
+ index: index,
79
+ tag: element.tag_name,
80
+ text: element.text.strip,
81
+ visible: element_visible?(element),
82
+ selector: generate_css_selector(element)
83
+ }
84
+ end
85
+
86
+ success_response(
87
+ found: results.length,
88
+ elements: results,
89
+ xpath: xpath
90
+ )
91
+ else
92
+ # Find first visible element
93
+
94
+ # Find first visible element
95
+ visible_element = elements.find { |el| element_visible?(el) }
96
+ element = visible_element || elements.first
97
+
98
+ success_response(
99
+ tag: element.tag_name,
100
+ text: element.text.strip,
101
+ visible: element_visible?(element),
102
+ selector: generate_css_selector(element),
103
+ xpath: xpath,
104
+ total_found: elements.length
105
+ )
106
+ end
107
+ rescue StandardError => e
108
+ logger.error "Find by text failed: #{e.message}"
109
+ error_response("Failed to find elements: #{e.message}")
110
+ end
111
+
112
+ private
113
+
114
+ # Escape string for XPath using concat() to handle quotes
115
+ # This prevents injection while preserving the original text
116
+ def escape_xpath_string(text)
117
+ # If text contains no quotes, simple quoting works
118
+ return "'#{text}'" unless text.include?("'")
119
+
120
+ # If text contains quotes, use concat() function
121
+ # Split on single quotes and join with concat
122
+ parts = text.split("'")
123
+ quoted_parts = parts.map { |part| "'#{part}'" }
124
+ "concat(#{quoted_parts.join(", \"'\", ")})"
125
+ end
126
+
127
+ def element_visible?(element)
128
+ rect = element.evaluate('el => el.getBoundingClientRect()')
129
+ rect['width'].positive? && rect['height'].positive?
130
+ rescue StandardError
131
+ false
132
+ end
133
+
134
+ def generate_css_selector(element)
135
+ # Try to generate a useful CSS selector
136
+ tag = element.tag_name
137
+ id = element.property('id')
138
+ classes = element.property('className')
139
+
140
+ if id && !id.empty?
141
+ "##{id}"
142
+ elsif classes && !classes.empty?
143
+ class_list = classes.split.join('.')
144
+ "#{tag}.#{class_list}"
145
+ else
146
+ tag
147
+ end
148
+ rescue StandardError
149
+ element.tag_name
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to get element attributes
6
+ class GetAttributeTool < BaseTool
7
+ def self.tool_name
8
+ 'get_attribute'
9
+ end
10
+
11
+ def self.description
12
+ 'Get attribute value(s) from an element'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ selector: {
20
+ type: 'string',
21
+ description: 'CSS selector of the element'
22
+ },
23
+ attribute: {
24
+ type: 'string',
25
+ description: 'Attribute name to get'
26
+ },
27
+ session_id: {
28
+ type: 'string',
29
+ description: 'Session ID to use for this operation'
30
+ }
31
+ },
32
+ required: %w[selector attribute session_id]
33
+ }
34
+ end
35
+
36
+ def execute(params)
37
+ ensure_browser_active
38
+ selector = params['selector'] || params[:selector]
39
+ attribute = params['attribute'] || params[:attribute]
40
+
41
+ logger.info "Getting attribute '#{attribute}' from: #{selector}"
42
+ element = find_element(selector)
43
+ value = element.attribute(attribute)
44
+
45
+ success_response(
46
+ selector: selector,
47
+ attribute: attribute,
48
+ value: value
49
+ )
50
+ rescue StandardError => e
51
+ logger.error "Get attribute failed: #{e.message}"
52
+ error_response("Failed to get attribute: #{e.message}")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to get cookies
6
+ class GetCookiesTool < BaseTool
7
+ def self.tool_name
8
+ 'get_cookies'
9
+ end
10
+
11
+ def self.description
12
+ 'Get all cookies or cookies for a specific domain'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ domain: {
20
+ type: 'string',
21
+ description: 'Optional: Filter cookies by 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
31
+
32
+ def execute(params)
33
+ ensure_browser_active
34
+ domain = param(params, :domain)
35
+
36
+ logger.info "Getting cookies#{" for #{domain}" if domain}"
37
+ all_cookies = browser.cookies.all
38
+
39
+ # Convert cookies to structured format
40
+ cookies_array = []
41
+
42
+ all_cookies.each do |name, cookie|
43
+ # Get structured cookie data if available
44
+ cookie_data = if cookie.respond_to?(:to_h)
45
+ cookie.to_h
46
+ elsif cookie.is_a?(Hash)
47
+ cookie
48
+ else
49
+ # Fallback to basic format
50
+ { name: name, value: cookie.to_s }
51
+ end
52
+
53
+ # Ensure name is set
54
+ cookie_data[:name] ||= name
55
+
56
+ # Filter by domain if specified
57
+ cookies_array << cookie_data if domain.nil? || cookie_data[:domain]&.include?(domain)
58
+ end
59
+
60
+ success_response(
61
+ cookies: cookies_array,
62
+ count: cookies_array.length
63
+ )
64
+ rescue StandardError => e
65
+ logger.error "Get cookies failed: #{e.message}"
66
+ error_response("Failed to get cookies: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Tool to get HTML content
6
+ class GetHTMLTool < BaseTool
7
+ def self.tool_name
8
+ 'get_html'
9
+ end
10
+
11
+ def self.description
12
+ 'Get HTML content of the page or a specific element'
13
+ end
14
+
15
+ def self.input_schema
16
+ {
17
+ type: 'object',
18
+ properties: {
19
+ selector: {
20
+ type: 'string',
21
+ description: 'Optional: CSS selector to get HTML of specific element'
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
31
+
32
+ def execute(params)
33
+ ensure_browser_active
34
+ selector = params['selector'] || params[:selector]
35
+
36
+ if selector
37
+ logger.info "Getting HTML of element: #{selector}"
38
+ element = find_element(selector)
39
+ html = element.property('outerHTML')
40
+ success_response(html: html, selector: selector)
41
+ else
42
+ logger.info 'Getting page HTML'
43
+ html = browser.body
44
+ success_response(html: html, url: browser.url)
45
+ end
46
+ rescue StandardError => e
47
+ logger.error "Get HTML failed: #{e.message}"
48
+ error_response("Failed to get HTML: #{e.message}")
49
+ end
50
+ end
51
+ end
52
+ end