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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Base class for all MCP tools
|
|
6
|
+
class BaseTool
|
|
7
|
+
attr_reader :browser, :logger
|
|
8
|
+
|
|
9
|
+
def initialize(browser_manager)
|
|
10
|
+
@browser_manager = browser_manager
|
|
11
|
+
@browser = browser_manager.browser
|
|
12
|
+
@logger = browser_manager.logger
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(params)
|
|
16
|
+
raise NotImplementedError, 'Subclasses must implement #execute'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.tool_name
|
|
20
|
+
raise NotImplementedError, 'Subclasses must implement .tool_name'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.description
|
|
24
|
+
raise NotImplementedError, 'Subclasses must implement .description'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.input_schema
|
|
28
|
+
raise NotImplementedError, 'Subclasses must implement .input_schema'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
def ensure_browser_active
|
|
34
|
+
raise BrowserError, 'Browser is not active' unless @browser_manager.active?
|
|
35
|
+
|
|
36
|
+
@browser = @browser_manager.browser
|
|
37
|
+
end
|
|
38
|
+
|
|
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]
|
|
42
|
+
end
|
|
43
|
+
|
|
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
|
|
49
|
+
|
|
50
|
+
loop do
|
|
51
|
+
element = browser.at_css(selector)
|
|
52
|
+
return element if element
|
|
53
|
+
|
|
54
|
+
raise ToolError, "Element not found: #{selector}" if Time.now > deadline
|
|
55
|
+
|
|
56
|
+
# Use shorter sleep for better responsiveness (0.1s instead of 0.5s)
|
|
57
|
+
sleep 0.1
|
|
58
|
+
end
|
|
59
|
+
rescue Ferrum::NodeNotFoundError
|
|
60
|
+
raise ToolError, "Element not found: #{selector}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Retry logic for handling stale/moving elements
|
|
64
|
+
def with_retry(retries: 3)
|
|
65
|
+
attempts = 0
|
|
66
|
+
begin
|
|
67
|
+
attempts += 1
|
|
68
|
+
yield
|
|
69
|
+
rescue Ferrum::NodeMovingError => e
|
|
70
|
+
raise ToolError, "Element became stale after #{retries} retries" unless attempts < retries
|
|
71
|
+
|
|
72
|
+
logger.debug "Retry #{attempts}/#{retries} due to: #{e.class}"
|
|
73
|
+
sleep 0.1
|
|
74
|
+
retry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if element is actually visible (has dimensions and not hidden)
|
|
79
|
+
def element_visible?(element)
|
|
80
|
+
return false unless element
|
|
81
|
+
|
|
82
|
+
# Check both CSS visibility and actual rendered dimensions
|
|
83
|
+
script = <<~JS
|
|
84
|
+
(function(el) {
|
|
85
|
+
if (!el) return false;
|
|
86
|
+
const rect = el.getBoundingClientRect();
|
|
87
|
+
const style = window.getComputedStyle(el);
|
|
88
|
+
return rect.width > 0 &&
|
|
89
|
+
rect.height > 0 &&
|
|
90
|
+
style.visibility !== 'hidden' &&
|
|
91
|
+
style.display !== 'none';
|
|
92
|
+
})(arguments[0])
|
|
93
|
+
JS
|
|
94
|
+
|
|
95
|
+
browser.evaluate(script, element)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
logger.debug "Error checking element visibility: #{e.message}"
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def success_response(data = {})
|
|
102
|
+
{ success: true, data: data }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def image_response(base64_data, mime_type = 'image/png')
|
|
106
|
+
{ success: true, type: 'image', data: base64_data, mime_type: mime_type }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def error_response(message)
|
|
110
|
+
{ success: false, error: message }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool to clear cookies
|
|
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
|
|
14
|
+
|
|
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
|
|
31
|
+
|
|
32
|
+
def execute(params)
|
|
33
|
+
ensure_browser_active
|
|
34
|
+
domain = params['domain'] || params[:domain]
|
|
35
|
+
|
|
36
|
+
if domain
|
|
37
|
+
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
|
|
53
|
+
end
|
|
54
|
+
success_response(message: "Cleared #{cookies_to_remove.length} cookies for #{domain}")
|
|
55
|
+
else
|
|
56
|
+
logger.info 'Clearing all cookies'
|
|
57
|
+
browser.cookies.clear
|
|
58
|
+
success_response(message: 'All cookies cleared')
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
logger.error "Clear cookies failed: #{e.message}"
|
|
62
|
+
error_response("Failed to clear cookies: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool to click on an element
|
|
6
|
+
class ClickTool < BaseTool
|
|
7
|
+
def self.tool_name
|
|
8
|
+
'click'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.description
|
|
12
|
+
'Click on an element using a CSS selector or XPath'
|
|
13
|
+
end
|
|
14
|
+
|
|
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
|
|
46
|
+
|
|
47
|
+
logger.info "Clicking element: #{selector} (force: #{force})"
|
|
48
|
+
|
|
49
|
+
# Use retry logic for stale elements
|
|
50
|
+
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
|
|
57
|
+
element.click
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
success_response(message: "Clicked on #{selector}")
|
|
61
|
+
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
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
# Handle "Node does not have a layout object" and similar errors
|
|
73
|
+
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
|
|
82
|
+
else
|
|
83
|
+
logger.error "Click failed: #{e.message}"
|
|
84
|
+
error_response("Failed to click: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
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)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
element
|
|
119
|
+
end
|
|
120
|
+
|
|
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
|
+
}
|
|
134
|
+
|
|
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'
|
|
203
|
+
end
|
|
204
|
+
|
|
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
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Close a specific session
|
|
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']
|
|
30
|
+
|
|
31
|
+
return error_response('session_id is required') unless session_id
|
|
32
|
+
|
|
33
|
+
success = session_manager.close_session(session_id)
|
|
34
|
+
|
|
35
|
+
if success
|
|
36
|
+
success_response(
|
|
37
|
+
session_id: session_id,
|
|
38
|
+
message: 'Session closed successfully'
|
|
39
|
+
)
|
|
40
|
+
else
|
|
41
|
+
error_response("Session not found: #{session_id}")
|
|
42
|
+
end
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
logger.error "Failed to close session: #{e.message}"
|
|
45
|
+
error_response("Failed to close session: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
class CreateSessionTool < SessionTool
|
|
6
|
+
def self.tool_name
|
|
7
|
+
'create_session'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.description
|
|
11
|
+
<<~DESC
|
|
12
|
+
Create a new browser session with custom options.
|
|
13
|
+
Supports multiple browsers in parallel (Chrome, BotBrowser).
|
|
14
|
+
Returns a session_id to use with other tools.
|
|
15
|
+
|
|
16
|
+
Note: When running in Docker (DOCKER=true), headless mode is mandatory.
|
|
17
|
+
Attempting to create a non-headless session will result in an error.
|
|
18
|
+
DESC
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.input_schema
|
|
22
|
+
{
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
browser_id: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Optional: Browser ID to use (from ferrum://browsers resource)'
|
|
28
|
+
},
|
|
29
|
+
user_profile_id: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Optional: User profile ID to use (from ferrum://user-profiles resource)'
|
|
32
|
+
},
|
|
33
|
+
bot_profile_id: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Optional: BotBrowser profile ID to use (from ferrum://bot-profiles resource)'
|
|
36
|
+
},
|
|
37
|
+
browser_path: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Optional: Path to browser executable (legacy, prefer browser_id)'
|
|
40
|
+
},
|
|
41
|
+
botbrowser_profile: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Optional: Path to BotBrowser profile (legacy, prefer bot_profile_id)'
|
|
44
|
+
},
|
|
45
|
+
headless: {
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
description: 'Optional: Run browser in headless mode (default: false). ' \
|
|
48
|
+
'REQUIRED to be true when running in Docker.'
|
|
49
|
+
},
|
|
50
|
+
timeout: {
|
|
51
|
+
type: 'number',
|
|
52
|
+
description: 'Optional: Browser timeout in seconds (default: 60)'
|
|
53
|
+
},
|
|
54
|
+
browser_options: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
description: 'Optional: Additional browser command-line options (e.g., {"--window-size": "1920,1080"})',
|
|
57
|
+
additionalProperties: { type: 'string' }
|
|
58
|
+
},
|
|
59
|
+
metadata: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
description: 'Optional: Custom metadata for this session (e.g., {"user": "john", "project": "scraping"})',
|
|
62
|
+
additionalProperties: true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(params)
|
|
69
|
+
logger.info 'Creating new browser session'
|
|
70
|
+
|
|
71
|
+
options = build_options(params)
|
|
72
|
+
validate_docker_headless!(options)
|
|
73
|
+
|
|
74
|
+
session_id = session_manager.create_session(options)
|
|
75
|
+
|
|
76
|
+
success_response(
|
|
77
|
+
session_id: session_id,
|
|
78
|
+
message: 'Session created successfully',
|
|
79
|
+
options: options.except(:metadata)
|
|
80
|
+
)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
logger.error "Failed to create session: #{e.message}"
|
|
83
|
+
error_response("Failed to create session: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def validate_docker_headless!(options)
|
|
89
|
+
# Check if running in Docker environment
|
|
90
|
+
return unless ENV['DOCKER'] == 'true'
|
|
91
|
+
|
|
92
|
+
# In Docker, headless mode is mandatory
|
|
93
|
+
# Check if headless is explicitly set to false
|
|
94
|
+
if options.key?(:headless) && options[:headless] == false
|
|
95
|
+
raise 'Headless mode is required when running in Docker. ' \
|
|
96
|
+
'Cannot create a non-headless session in a containerized environment.'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Force headless to true in Docker if not explicitly set
|
|
100
|
+
options[:headless] = true unless options.key?(:headless)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_options(params)
|
|
104
|
+
options = {}
|
|
105
|
+
|
|
106
|
+
# New resource-based parameters (preferred)
|
|
107
|
+
if params[:browser_id] || params['browser_id']
|
|
108
|
+
options[:browser_id] =
|
|
109
|
+
params[:browser_id] || params['browser_id']
|
|
110
|
+
end
|
|
111
|
+
if params[:user_profile_id] || params['user_profile_id']
|
|
112
|
+
options[:user_profile_id] =
|
|
113
|
+
params[:user_profile_id] || params['user_profile_id']
|
|
114
|
+
end
|
|
115
|
+
if params[:bot_profile_id] || params['bot_profile_id']
|
|
116
|
+
options[:bot_profile_id] =
|
|
117
|
+
params[:bot_profile_id] || params['bot_profile_id']
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Legacy parameters (for backward compatibility)
|
|
121
|
+
if params[:browser_path] || params['browser_path']
|
|
122
|
+
options[:browser_path] =
|
|
123
|
+
params[:browser_path] || params['browser_path']
|
|
124
|
+
end
|
|
125
|
+
if params[:botbrowser_profile] || params['botbrowser_profile']
|
|
126
|
+
options[:botbrowser_profile] =
|
|
127
|
+
params[:botbrowser_profile] || params['botbrowser_profile']
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Other options
|
|
131
|
+
if params.key?(:headless) || params.key?('headless')
|
|
132
|
+
# Use fetch to handle false values correctly
|
|
133
|
+
options[:headless] = params.key?(:headless) ? params[:headless] : params['headless']
|
|
134
|
+
end
|
|
135
|
+
options[:timeout] = params[:timeout] || params['timeout'] if params[:timeout] || params['timeout']
|
|
136
|
+
if params[:browser_options] || params['browser_options']
|
|
137
|
+
options[:browser_options] =
|
|
138
|
+
params[:browser_options] || params['browser_options']
|
|
139
|
+
end
|
|
140
|
+
options[:metadata] = params[:metadata] || params['metadata'] if params[:metadata] || params['metadata']
|
|
141
|
+
|
|
142
|
+
options
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|