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
|
@@ -2,223 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module FerrumMCP
|
|
4
4
|
module Tools
|
|
5
|
-
#
|
|
5
|
+
# Interact with Shadow DOM elements
|
|
6
6
|
class QueryShadowDOMTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
description: 'Action to perform: click, get_text, get_html, or get_attribute',
|
|
30
|
-
enum: %w[click get_text get_html get_attribute]
|
|
31
|
-
},
|
|
32
|
-
attribute: {
|
|
33
|
-
type: 'string',
|
|
34
|
-
description: 'Attribute name (required when action is get_attribute)'
|
|
35
|
-
},
|
|
36
|
-
multiple: {
|
|
37
|
-
type: 'boolean',
|
|
38
|
-
description: 'Return all matching elements (default: false)',
|
|
39
|
-
default: false
|
|
40
|
-
},
|
|
41
|
-
session_id: {
|
|
42
|
-
type: 'string',
|
|
43
|
-
description: 'Session ID to use for this operation'
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
required: %w[host_selector shadow_selector action session_id]
|
|
47
|
-
}
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def execute(params)
|
|
51
|
-
host_selector = params['host_selector'] || params[:host_selector]
|
|
52
|
-
shadow_selector = params['shadow_selector'] || params[:shadow_selector]
|
|
53
|
-
action = params['action'] || params[:action]
|
|
54
|
-
attribute = params['attribute'] || params[:attribute]
|
|
55
|
-
multiple = params['multiple'] || params[:multiple] || false
|
|
56
|
-
|
|
57
|
-
logger.info "Querying Shadow DOM: #{host_selector} -> #{shadow_selector}, action: #{action}"
|
|
58
|
-
|
|
59
|
-
result = case action
|
|
60
|
-
when 'click'
|
|
61
|
-
click_in_shadow_dom(host_selector, shadow_selector)
|
|
62
|
-
when 'get_text'
|
|
63
|
-
get_text_from_shadow_dom(host_selector, shadow_selector, multiple)
|
|
64
|
-
when 'get_html'
|
|
65
|
-
get_html_from_shadow_dom(host_selector, shadow_selector, multiple)
|
|
66
|
-
when 'get_attribute'
|
|
67
|
-
raise ToolError, 'attribute parameter required for get_attribute action' unless attribute
|
|
68
|
-
|
|
69
|
-
get_attribute_from_shadow_dom(host_selector, shadow_selector, attribute, multiple)
|
|
70
|
-
else
|
|
71
|
-
raise ToolError, "Unknown action: #{action}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
success_response(result)
|
|
7
|
+
tool_name 'query_shadow_dom'
|
|
8
|
+
description 'Query and interact with elements inside a Shadow DOM'
|
|
9
|
+
|
|
10
|
+
param :host_selector, type: :string, required: true, description: 'CSS selector of the Shadow DOM host element'
|
|
11
|
+
param :shadow_selector, type: :string, required: true,
|
|
12
|
+
description: 'CSS selector to find element(s) within the Shadow DOM'
|
|
13
|
+
param :action, type: :string, required: true, enum: %w[click get_text get_html get_attribute],
|
|
14
|
+
description: 'Action to perform: click, get_text, get_html, or get_attribute'
|
|
15
|
+
param :attribute, type: :string, description: 'Attribute name (required when action is get_attribute)'
|
|
16
|
+
param :multiple, type: :boolean, default: false, description: 'Return all matching elements (default: false)'
|
|
17
|
+
|
|
18
|
+
def perform(params)
|
|
19
|
+
action = params[:action]
|
|
20
|
+
logger.info "Querying Shadow DOM: #{params[:host_selector]} -> #{params[:shadow_selector]}, action: #{action}"
|
|
21
|
+
|
|
22
|
+
if action == 'get_attribute' && params[:attribute].to_s.empty?
|
|
23
|
+
raise ToolError, 'attribute parameter required for get_attribute action'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
result = page.evaluate(script_for(action, params[:multiple]),
|
|
27
|
+
params[:host_selector], params[:shadow_selector], params[:attribute])
|
|
28
|
+
success_response(format_result(action, params, result))
|
|
75
29
|
rescue StandardError => e
|
|
76
30
|
logger.error "Shadow DOM query failed: #{e.message}"
|
|
77
31
|
error_response("Failed to query Shadow DOM: #{e.message}")
|
|
78
32
|
end
|
|
79
33
|
|
|
80
|
-
|
|
34
|
+
EXTRACTORS = {
|
|
35
|
+
'click' => 'el.scrollIntoView({ behavior: "instant", block: "center" }); el.click(); return true;',
|
|
36
|
+
'get_text' => 'return el.textContent;',
|
|
37
|
+
'get_html' => 'return el.innerHTML;',
|
|
38
|
+
'get_attribute' => 'return el.getAttribute(attribute);'
|
|
39
|
+
}.freeze
|
|
81
40
|
|
|
82
|
-
|
|
83
|
-
host_js = host_selector.inspect
|
|
84
|
-
shadow_js = shadow_selector.inspect
|
|
41
|
+
private
|
|
85
42
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
43
|
+
def script_for(action, multiple)
|
|
44
|
+
extractor = EXTRACTORS.fetch(action) { raise ToolError, "Unknown action: #{action}" }
|
|
45
|
+
<<~JS
|
|
46
|
+
(function(hostSelector, shadowSelector, attribute) {
|
|
47
|
+
const host = document.querySelector(hostSelector);
|
|
48
|
+
if (!host || !host.shadowRoot) throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
49
|
+
const extract = (el) => { #{extractor} };
|
|
50
|
+
if (#{multiple ? 'true' : 'false'}) {
|
|
51
|
+
return Array.from(host.shadowRoot.querySelectorAll(shadowSelector)).map(extract);
|
|
91
52
|
}
|
|
92
|
-
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
element.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
97
|
-
element.click();
|
|
98
|
-
return true;
|
|
99
|
-
})()
|
|
53
|
+
const el = host.shadowRoot.querySelector(shadowSelector);
|
|
54
|
+
if (!el) throw new Error('Element not found in Shadow DOM');
|
|
55
|
+
return extract(el);
|
|
56
|
+
})(arguments[0], arguments[1], arguments[2])
|
|
100
57
|
JS
|
|
101
|
-
|
|
102
|
-
browser.execute(script)
|
|
103
|
-
{ message: "Clicked element in Shadow DOM: #{shadow_selector}" }
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def get_text_from_shadow_dom(host_selector, shadow_selector, multiple)
|
|
107
|
-
host_js = host_selector.inspect
|
|
108
|
-
shadow_js = shadow_selector.inspect
|
|
109
|
-
|
|
110
|
-
script = if multiple
|
|
111
|
-
<<~JS.strip
|
|
112
|
-
(function() {
|
|
113
|
-
var host = document.querySelector(#{host_js});
|
|
114
|
-
if (!host || !host.shadowRoot) {
|
|
115
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
116
|
-
}
|
|
117
|
-
var elements = Array.from(host.shadowRoot.querySelectorAll(#{shadow_js}));
|
|
118
|
-
var texts = [];
|
|
119
|
-
for (var i = 0; i < elements.length; i++) {
|
|
120
|
-
texts.push(elements[i].textContent);
|
|
121
|
-
}
|
|
122
|
-
return texts;
|
|
123
|
-
})()
|
|
124
|
-
JS
|
|
125
|
-
else
|
|
126
|
-
<<~JS.strip
|
|
127
|
-
(function() {
|
|
128
|
-
var host = document.querySelector(#{host_js});
|
|
129
|
-
if (!host || !host.shadowRoot) {
|
|
130
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
131
|
-
}
|
|
132
|
-
var element = host.shadowRoot.querySelector(#{shadow_js});
|
|
133
|
-
if (!element) {
|
|
134
|
-
throw new Error('Element not found in Shadow DOM');
|
|
135
|
-
}
|
|
136
|
-
return element.textContent;
|
|
137
|
-
})()
|
|
138
|
-
JS
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
result = browser.evaluate(script)
|
|
142
|
-
multiple ? { texts: result, count: result.length } : { text: result }
|
|
143
58
|
end
|
|
144
59
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
var host = document.querySelector(#{host_js});
|
|
153
|
-
if (!host || !host.shadowRoot) {
|
|
154
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
155
|
-
}
|
|
156
|
-
var elements = Array.from(host.shadowRoot.querySelectorAll(#{shadow_js}));
|
|
157
|
-
var htmls = [];
|
|
158
|
-
for (var i = 0; i < elements.length; i++) {
|
|
159
|
-
htmls.push(elements[i].innerHTML);
|
|
160
|
-
}
|
|
161
|
-
return htmls;
|
|
162
|
-
})()
|
|
163
|
-
JS
|
|
164
|
-
else
|
|
165
|
-
<<~JS.strip
|
|
166
|
-
(function() {
|
|
167
|
-
var host = document.querySelector(#{host_js});
|
|
168
|
-
if (!host || !host.shadowRoot) {
|
|
169
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
170
|
-
}
|
|
171
|
-
var element = host.shadowRoot.querySelector(#{shadow_js});
|
|
172
|
-
if (!element) {
|
|
173
|
-
throw new Error('Element not found in Shadow DOM');
|
|
174
|
-
}
|
|
175
|
-
return element.innerHTML;
|
|
176
|
-
})()
|
|
177
|
-
JS
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
result = browser.evaluate(script)
|
|
181
|
-
multiple ? { html: result, count: result.length } : { html: result }
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def get_attribute_from_shadow_dom(host_selector, shadow_selector, attribute, multiple)
|
|
185
|
-
host_js = host_selector.inspect
|
|
186
|
-
shadow_js = shadow_selector.inspect
|
|
187
|
-
attr_js = attribute.inspect
|
|
188
|
-
|
|
189
|
-
script = if multiple
|
|
190
|
-
<<~JS.strip
|
|
191
|
-
(function() {
|
|
192
|
-
var host = document.querySelector(#{host_js});
|
|
193
|
-
if (!host || !host.shadowRoot) {
|
|
194
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
195
|
-
}
|
|
196
|
-
var elements = Array.from(host.shadowRoot.querySelectorAll(#{shadow_js}));
|
|
197
|
-
var values = [];
|
|
198
|
-
for (var i = 0; i < elements.length; i++) {
|
|
199
|
-
values.push(elements[i].getAttribute(#{attr_js}));
|
|
200
|
-
}
|
|
201
|
-
return values;
|
|
202
|
-
})()
|
|
203
|
-
JS
|
|
204
|
-
else
|
|
205
|
-
<<~JS.strip
|
|
206
|
-
(function() {
|
|
207
|
-
var host = document.querySelector(#{host_js});
|
|
208
|
-
if (!host || !host.shadowRoot) {
|
|
209
|
-
throw new Error('Shadow DOM host not found or has no shadowRoot');
|
|
210
|
-
}
|
|
211
|
-
var element = host.shadowRoot.querySelector(#{shadow_js});
|
|
212
|
-
if (!element) {
|
|
213
|
-
throw new Error('Element not found in Shadow DOM');
|
|
214
|
-
}
|
|
215
|
-
return element.getAttribute(#{attr_js});
|
|
216
|
-
})()
|
|
217
|
-
JS
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
result = browser.evaluate(script)
|
|
221
|
-
multiple ? { values: result, count: result.length } : { value: result }
|
|
60
|
+
def format_result(action, params, result)
|
|
61
|
+
case action
|
|
62
|
+
when 'click' then { message: "Clicked element in Shadow DOM: #{params[:shadow_selector]}" }
|
|
63
|
+
when 'get_text' then params[:multiple] ? { texts: result, count: result.length } : { text: result }
|
|
64
|
+
when 'get_html' then params[:multiple] ? { html: result, count: result.length } : { html: result }
|
|
65
|
+
else params[:multiple] ? { values: result, count: result.length } : { value: result }
|
|
66
|
+
end
|
|
222
67
|
end
|
|
223
68
|
end
|
|
224
69
|
end
|
|
@@ -2,41 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module FerrumMCP
|
|
4
4
|
module Tools
|
|
5
|
-
#
|
|
5
|
+
# Refresh the current page
|
|
6
6
|
class RefreshTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def self.description
|
|
12
|
-
'Refresh the current page'
|
|
13
|
-
end
|
|
7
|
+
tool_name 'refresh'
|
|
8
|
+
description 'Refresh the current page'
|
|
14
9
|
|
|
15
|
-
def
|
|
16
|
-
{
|
|
17
|
-
type: 'object',
|
|
18
|
-
properties: {
|
|
19
|
-
session_id: {
|
|
20
|
-
type: 'string',
|
|
21
|
-
description: 'Session ID to use for this operation'
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
required: ['session_id']
|
|
25
|
-
}
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def execute(_params)
|
|
10
|
+
def perform(_params)
|
|
29
11
|
ensure_browser_active
|
|
30
12
|
logger.info 'Refreshing page'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# Wait for network to be idle to ensure page is reloaded
|
|
34
|
-
browser.network.wait_for_idle(timeout: 30)
|
|
13
|
+
page.refresh
|
|
14
|
+
page.network.wait_for_idle(timeout: 30)
|
|
35
15
|
|
|
36
|
-
success_response(
|
|
37
|
-
url: browser.url,
|
|
38
|
-
title: browser.title
|
|
39
|
-
)
|
|
16
|
+
success_response(url: page.url, title: page.title)
|
|
40
17
|
rescue Ferrum::TimeoutError => e
|
|
41
18
|
logger.error "Refresh timeout: #{e.message}"
|
|
42
19
|
error_response("Refresh timed out: #{e.message}")
|
|
@@ -1,83 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'base64'
|
|
4
4
|
|
|
5
5
|
module FerrumMCP
|
|
6
6
|
module Tools
|
|
7
|
-
#
|
|
7
|
+
# Take screenshots
|
|
8
8
|
class ScreenshotTool < BaseTool
|
|
9
9
|
# Claude API has a maximum dimension of 8000 pixels per side
|
|
10
10
|
MAX_DIMENSION = 8000
|
|
11
|
-
def self.tool_name
|
|
12
|
-
'screenshot'
|
|
13
|
-
end
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
12
|
+
tool_name 'screenshot'
|
|
13
|
+
description 'Take a screenshot of the page or of a specific element (returned as an image)'
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
properties: {
|
|
23
|
-
selector: {
|
|
24
|
-
type: 'string',
|
|
25
|
-
description: 'Optional: CSS selector to screenshot specific element'
|
|
26
|
-
},
|
|
27
|
-
full_page: {
|
|
28
|
-
type: 'boolean',
|
|
29
|
-
description: 'Capture full scrollable page (default: false)',
|
|
30
|
-
default: false
|
|
31
|
-
},
|
|
32
|
-
format: {
|
|
33
|
-
type: 'string',
|
|
34
|
-
enum: %w[png jpeg],
|
|
35
|
-
description: 'Image format (default: png)',
|
|
36
|
-
default: 'png'
|
|
37
|
-
},
|
|
38
|
-
session_id: {
|
|
39
|
-
type: 'string',
|
|
40
|
-
description: 'Session ID to use for this operation'
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
required: ['session_id']
|
|
44
|
-
}
|
|
45
|
-
end
|
|
15
|
+
param :selector, type: :string, description: 'Optional: selector of the element to screenshot'
|
|
16
|
+
param :full_page, type: :boolean, default: false, description: 'Capture the full scrollable page (default: false)'
|
|
17
|
+
param :format, type: :string, default: 'png', enum: %w[png jpeg], description: 'Image format (default: png)'
|
|
46
18
|
|
|
47
|
-
def
|
|
19
|
+
def perform(params)
|
|
48
20
|
ensure_browser_active
|
|
49
|
-
selector =
|
|
50
|
-
|
|
51
|
-
format = param(params, :format) || 'png'
|
|
52
|
-
|
|
21
|
+
selector = params[:selector]
|
|
22
|
+
format = params[:format]
|
|
53
23
|
logger.info 'Taking screenshot'
|
|
54
24
|
|
|
55
|
-
|
|
25
|
+
options = { format: format, full: params[:full_page], encoding: :binary }
|
|
56
26
|
if selector
|
|
57
27
|
element = find_element(selector)
|
|
58
|
-
element.scroll_into_view
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
sleep 0.1
|
|
28
|
+
element.scroll_into_view
|
|
29
|
+
sleep 0.1 # let the element render after scrolling
|
|
30
|
+
options[:selector] = css_selector_for(selector, element)
|
|
62
31
|
end
|
|
63
32
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# Add selector to options if provided
|
|
68
|
-
options[:selector] = selector if selector
|
|
69
|
-
|
|
70
|
-
screenshot_data = browser.screenshot(**options)
|
|
71
|
-
|
|
72
|
-
# Resize if dimensions exceed Claude API limits
|
|
73
|
-
screenshot_data = resize_if_needed(screenshot_data, format)
|
|
74
|
-
|
|
75
|
-
# Now encode the binary data to base64 for MCP
|
|
76
|
-
base64_data = Base64.strict_encode64(screenshot_data)
|
|
77
|
-
mime_type = format == 'png' ? 'image/png' : 'image/jpeg'
|
|
78
|
-
|
|
79
|
-
# Use image_response for MCP image injection
|
|
80
|
-
image_response(base64_data, mime_type)
|
|
33
|
+
data = resize_if_needed(page.screenshot(**options), format)
|
|
34
|
+
image_response(Base64.strict_encode64(data), format == 'png' ? 'image/png' : 'image/jpeg')
|
|
81
35
|
rescue StandardError => e
|
|
82
36
|
logger.error "Screenshot failed: #{e.message}"
|
|
83
37
|
error_response("Failed to take screenshot: #{e.message}")
|
|
@@ -85,33 +39,22 @@ module FerrumMCP
|
|
|
85
39
|
|
|
86
40
|
private
|
|
87
41
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
image = Vips::Image.new_from_buffer(image_data, '')
|
|
94
|
-
width = image.width
|
|
95
|
-
height = image.height
|
|
96
|
-
|
|
97
|
-
# Check if resize is needed
|
|
98
|
-
if width <= MAX_DIMENSION && height <= MAX_DIMENSION
|
|
99
|
-
logger.debug "Screenshot dimensions (#{width}x#{height}) within limits, no resize needed"
|
|
100
|
-
return image_data
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Calculate scaling factor to fit within MAX_DIMENSION
|
|
104
|
-
scale = [MAX_DIMENSION.to_f / width, MAX_DIMENSION.to_f / height].min
|
|
105
|
-
new_width = (width * scale).to_i
|
|
106
|
-
new_height = (height * scale).to_i
|
|
42
|
+
# Ferrum's screenshot(selector:) only takes CSS; map refs/xpath to a CSS
|
|
43
|
+
# selector by tagging the resolved element.
|
|
44
|
+
def css_selector_for(selector, element)
|
|
45
|
+
kind, expression = resolve_selector(selector)
|
|
46
|
+
return expression if kind == :css
|
|
107
47
|
|
|
108
|
-
|
|
48
|
+
page.execute("arguments[0].setAttribute('data-fmcp-shot', '1')", element)
|
|
49
|
+
'[data-fmcp-shot="1"]'
|
|
50
|
+
end
|
|
109
51
|
|
|
110
|
-
|
|
111
|
-
|
|
52
|
+
# Resize image if any dimension exceeds MAX_DIMENSION. Needs libvips; when
|
|
53
|
+
# it is not installed the original image is returned untouched.
|
|
54
|
+
def resize_if_needed(image_data, format)
|
|
55
|
+
return image_data unless ImageResizer.available?
|
|
112
56
|
|
|
113
|
-
|
|
114
|
-
resized.write_to_buffer(".#{format}")
|
|
57
|
+
ImageResizer.fit(image_data, MAX_DIMENSION, format, logger: logger)
|
|
115
58
|
rescue StandardError => e
|
|
116
59
|
logger.warn "Failed to resize screenshot: #{e.message}, returning original"
|
|
117
60
|
image_data
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Scroll the window, a scrollable element, or an element into view
|
|
6
|
+
class ScrollTool < BaseTool
|
|
7
|
+
tool_name 'scroll'
|
|
8
|
+
description 'Scroll the page (or a scrollable element) by direction/amount, to absolute coordinates, ' \
|
|
9
|
+
'or bring an element into view'
|
|
10
|
+
|
|
11
|
+
param :selector, type: :string,
|
|
12
|
+
description: 'Optional: element to scroll into view, or the scrollable container to scroll ' \
|
|
13
|
+
'when a direction is given'
|
|
14
|
+
param :direction, type: :string, enum: %w[up down left right top bottom],
|
|
15
|
+
description: 'Direction to scroll (top/bottom jump to the extremes)'
|
|
16
|
+
param :amount, type: :number, default: 500, description: 'Pixels to scroll for up/down/left/right (default: 500)'
|
|
17
|
+
param :x, type: :number, description: 'Absolute horizontal scroll position'
|
|
18
|
+
param :y, type: :number, description: 'Absolute vertical scroll position'
|
|
19
|
+
|
|
20
|
+
def perform(params)
|
|
21
|
+
ensure_browser_active
|
|
22
|
+
selector = params[:selector]
|
|
23
|
+
element = selector ? find_element(selector) : nil
|
|
24
|
+
logger.info "Scrolling #{selector || 'window'} #{params[:direction] || ''}".strip
|
|
25
|
+
|
|
26
|
+
position = page.evaluate(SCROLL_SCRIPT, element, params[:direction], params[:amount].to_f,
|
|
27
|
+
params[:x], params[:y])
|
|
28
|
+
|
|
29
|
+
success_response(x: position['x'], y: position['y'], target: position['target'])
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
logger.error "Scroll failed: #{e.message}"
|
|
32
|
+
error_response("Failed to scroll: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
SCROLL_SCRIPT = <<~JS
|
|
36
|
+
(function(el, direction, amount, x, y) {
|
|
37
|
+
if (el && !direction && x == null && y == null) {
|
|
38
|
+
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
|
39
|
+
return { x: window.scrollX, y: window.scrollY, target: 'window' };
|
|
40
|
+
}
|
|
41
|
+
const isWindow = !el;
|
|
42
|
+
const current = () => isWindow ? { x: window.scrollX, y: window.scrollY } : { x: el.scrollLeft, y: el.scrollTop };
|
|
43
|
+
const max = isWindow
|
|
44
|
+
? { x: document.documentElement.scrollWidth, y: document.documentElement.scrollHeight }
|
|
45
|
+
: { x: el.scrollWidth, y: el.scrollHeight };
|
|
46
|
+
let next = current();
|
|
47
|
+
if (x != null) next.x = x;
|
|
48
|
+
if (y != null) next.y = y;
|
|
49
|
+
switch (direction) {
|
|
50
|
+
case 'up': next.y -= amount; break;
|
|
51
|
+
case 'down': next.y += amount; break;
|
|
52
|
+
case 'left': next.x -= amount; break;
|
|
53
|
+
case 'right': next.x += amount; break;
|
|
54
|
+
case 'top': next.y = 0; break;
|
|
55
|
+
case 'bottom': next.y = max.y; break;
|
|
56
|
+
}
|
|
57
|
+
if (isWindow) window.scrollTo(next.x, next.y); else el.scrollTo(next.x, next.y);
|
|
58
|
+
const after = current();
|
|
59
|
+
return { x: after.x, y: after.y, target: isWindow ? 'window' : 'element' };
|
|
60
|
+
})(arguments[0], arguments[1], arguments[2], arguments[3], arguments[4])
|
|
61
|
+
JS
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Select option(s) in a <select>
|
|
6
|
+
class SelectOptionTool < BaseTool
|
|
7
|
+
tool_name 'select_option'
|
|
8
|
+
description 'Select an option in a <select> element by value, label or index (values for multiple selects)'
|
|
9
|
+
|
|
10
|
+
param :selector, type: :string, required: true, description: 'Selector of the <select> element'
|
|
11
|
+
param :value, type: :string, description: 'Option value to select'
|
|
12
|
+
param :label, type: :string, description: 'Option visible text to select'
|
|
13
|
+
param :index, type: :integer, description: 'Zero-based option index to select'
|
|
14
|
+
param :values, type: :array, description: 'Several option values (multiple selects)',
|
|
15
|
+
schema: { items: { type: 'string' } }
|
|
16
|
+
|
|
17
|
+
def perform(params)
|
|
18
|
+
ensure_browser_active
|
|
19
|
+
selector = params[:selector]
|
|
20
|
+
criteria = params.slice(:value, :label, :index, :values).compact
|
|
21
|
+
raise ToolError, 'Provide one of value, label, index or values' if criteria.empty?
|
|
22
|
+
|
|
23
|
+
logger.info "Selecting option in #{selector}: #{criteria.inspect}"
|
|
24
|
+
element = find_element(selector)
|
|
25
|
+
selected = page.evaluate(SELECT_SCRIPT, element, criteria.to_json)
|
|
26
|
+
|
|
27
|
+
success_response(selector: selector,
|
|
28
|
+
selected: selected.map { |o| { value: o['value'], label: o['label'] } })
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
logger.error "Select option failed: #{e.message}"
|
|
31
|
+
error_response("Failed to select option: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
SELECT_SCRIPT = <<~JS
|
|
35
|
+
(function(select, json) {
|
|
36
|
+
const criteria = JSON.parse(json);
|
|
37
|
+
if (!select || select.tagName.toLowerCase() !== 'select') throw new Error('Element is not a <select>');
|
|
38
|
+
const options = Array.from(select.options);
|
|
39
|
+
const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
40
|
+
let wanted = [];
|
|
41
|
+
if (criteria.values) wanted = options.filter(o => criteria.values.includes(o.value));
|
|
42
|
+
else if (criteria.value != null) wanted = options.filter(o => o.value === criteria.value).slice(0, 1);
|
|
43
|
+
else if (criteria.label != null) wanted = options.filter(o => norm(o.textContent) === norm(criteria.label)).slice(0, 1);
|
|
44
|
+
else if (criteria.index != null) wanted = options[criteria.index] ? [options[criteria.index]] : [];
|
|
45
|
+
if (wanted.length === 0) throw new Error('No option matches ' + json);
|
|
46
|
+
if (!select.multiple) wanted = wanted.slice(0, 1);
|
|
47
|
+
options.forEach(o => { o.selected = wanted.includes(o); });
|
|
48
|
+
if (!select.multiple) select.value = wanted[0].value;
|
|
49
|
+
select.dispatchEvent(new Event('input', { bubbles: true }));
|
|
50
|
+
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
51
|
+
return wanted.map(o => ({ value: o.value, label: norm(o.textContent) }));
|
|
52
|
+
})(arguments[0], arguments[1])
|
|
53
|
+
JS
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module FerrumMCP
|
|
4
4
|
module Tools
|
|
5
|
-
# Base class for session management tools
|
|
5
|
+
# Base class for session management tools: they talk to the SessionManager
|
|
6
|
+
# and do not need a running browser.
|
|
6
7
|
class SessionTool
|
|
8
|
+
extend Definition
|
|
9
|
+
|
|
10
|
+
requires_session false
|
|
11
|
+
|
|
7
12
|
attr_reader :session_manager, :logger
|
|
8
13
|
|
|
9
14
|
def initialize(session_manager)
|
|
@@ -11,16 +16,12 @@ module FerrumMCP
|
|
|
11
16
|
@logger = session_manager.logger
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.description
|
|
19
|
-
raise NotImplementedError, 'Subclasses must implement .description'
|
|
19
|
+
def execute(raw_params)
|
|
20
|
+
perform(self.class.normalize_params(raw_params))
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
raise NotImplementedError, 'Subclasses must implement
|
|
23
|
+
def perform(_params)
|
|
24
|
+
raise NotImplementedError, 'Subclasses must implement #perform'
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
protected
|