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.
- checksums.yaml +7 -0
- data/.env.example +90 -0
- data/CHANGELOG.md +229 -0
- data/CONTRIBUTING.md +469 -0
- data/LICENSE +21 -0
- data/README.md +334 -0
- data/SECURITY.md +286 -0
- data/bin/ferrum-mcp +66 -0
- data/bin/lint +10 -0
- data/bin/serve +3 -0
- data/bin/test +4 -0
- data/docs/API_REFERENCE.md +1410 -0
- data/docs/CONFIGURATION.md +254 -0
- data/docs/DEPLOYMENT.md +846 -0
- data/docs/DOCKER.md +836 -0
- data/docs/DOCKER_BOTBROWSER.md +455 -0
- data/docs/GETTING_STARTED.md +249 -0
- data/docs/TROUBLESHOOTING.md +677 -0
- data/lib/ferrum_mcp/browser_manager.rb +101 -0
- data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
- data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
- data/lib/ferrum_mcp/configuration.rb +229 -0
- data/lib/ferrum_mcp/resource_manager.rb +223 -0
- data/lib/ferrum_mcp/server.rb +254 -0
- data/lib/ferrum_mcp/session.rb +227 -0
- data/lib/ferrum_mcp/session_manager.rb +183 -0
- data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
- data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
- data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
- data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
- data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
- data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
- data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
- data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
- data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
- data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
- data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
- data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
- data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
- data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
- data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
- data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
- data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
- data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
- data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
- data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
- data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
- data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
- data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
- data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
- data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
- data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
- data/lib/ferrum_mcp/transport/http_server.rb +93 -0
- data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
- data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
- data/lib/ferrum_mcp/version.rb +5 -0
- data/lib/ferrum_mcp/whisper_service.rb +222 -0
- data/lib/ferrum_mcp.rb +35 -0
- 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
|