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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +37 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +30 -9
  5. data/docs/API_REFERENCE.md +300 -21
  6. data/docs/CONFIGURATION.md +34 -1
  7. data/docs/DEPLOYMENT.md +1 -0
  8. data/docs/DOCKER_BOTBROWSER.md +4 -0
  9. data/lib/ferrum_mcp/browser_manager.rb +56 -32
  10. data/lib/ferrum_mcp/cli/command_handler.rb +4 -3
  11. data/lib/ferrum_mcp/cli/server_runner.rb +19 -6
  12. data/lib/ferrum_mcp/configuration.rb +77 -18
  13. data/lib/ferrum_mcp/image_resizer.rb +43 -0
  14. data/lib/ferrum_mcp/server.rb +58 -91
  15. data/lib/ferrum_mcp/session.rb +67 -9
  16. data/lib/ferrum_mcp/session_manager.rb +15 -15
  17. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +11 -31
  18. data/lib/ferrum_mcp/tools/base_tool.rb +79 -38
  19. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +14 -43
  20. data/lib/ferrum_mcp/tools/click_tool.rb +58 -181
  21. data/lib/ferrum_mcp/tools/close_session_tool.rb +8 -30
  22. data/lib/ferrum_mcp/tools/close_tab_tool.rb +44 -0
  23. data/lib/ferrum_mcp/tools/create_session_tool.rb +38 -110
  24. data/lib/ferrum_mcp/tools/definition.rb +106 -0
  25. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +43 -145
  26. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +6 -28
  27. data/lib/ferrum_mcp/tools/execute_script_tool.rb +6 -30
  28. data/lib/ferrum_mcp/tools/fill_form_tool.rb +37 -52
  29. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +39 -114
  30. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +10 -39
  31. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +23 -52
  32. data/lib/ferrum_mcp/tools/get_html_tool.rb +22 -35
  33. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +5 -21
  34. data/lib/ferrum_mcp/tools/get_text_tool.rb +22 -46
  35. data/lib/ferrum_mcp/tools/get_title_tool.rb +5 -28
  36. data/lib/ferrum_mcp/tools/get_url_tool.rb +5 -25
  37. data/lib/ferrum_mcp/tools/go_back_tool.rb +7 -30
  38. data/lib/ferrum_mcp/tools/go_forward_tool.rb +7 -30
  39. data/lib/ferrum_mcp/tools/hover_tool.rb +10 -51
  40. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +5 -19
  41. data/lib/ferrum_mcp/tools/list_tabs_tool.rb +20 -0
  42. data/lib/ferrum_mcp/tools/navigate_tool.rb +17 -37
  43. data/lib/ferrum_mcp/tools/new_tab_tool.rb +36 -0
  44. data/lib/ferrum_mcp/tools/press_key_tool.rb +18 -63
  45. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +50 -205
  46. data/lib/ferrum_mcp/tools/refresh_tool.rb +7 -30
  47. data/lib/ferrum_mcp/tools/screenshot_tool.rb +29 -86
  48. data/lib/ferrum_mcp/tools/scroll_tool.rb +64 -0
  49. data/lib/ferrum_mcp/tools/select_option_tool.rb +56 -0
  50. data/lib/ferrum_mcp/tools/session_tool.rb +10 -9
  51. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +15 -56
  52. data/lib/ferrum_mcp/tools/set_viewport_tool.rb +32 -0
  53. data/lib/ferrum_mcp/tools/snapshot_tool.rb +175 -0
  54. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +17 -35
  55. data/lib/ferrum_mcp/tools/switch_tab_tool.rb +29 -0
  56. data/lib/ferrum_mcp/tools/tab_tool.rb +40 -0
  57. data/lib/ferrum_mcp/tools/upload_file_tool.rb +50 -0
  58. data/lib/ferrum_mcp/tools/wait_for_network_idle_tool.rb +32 -0
  59. data/lib/ferrum_mcp/tools/wait_for_selector_tool.rb +59 -0
  60. data/lib/ferrum_mcp/tools/wait_for_text_tool.rb +53 -0
  61. data/lib/ferrum_mcp/transport/api_key_authenticator.rb +112 -0
  62. data/lib/ferrum_mcp/transport/client_address.rb +23 -0
  63. data/lib/ferrum_mcp/transport/http_server.rb +11 -1
  64. data/lib/ferrum_mcp/transport/rate_limiter.rb +2 -6
  65. data/lib/ferrum_mcp/transport/stdio_server.rb +8 -30
  66. data/lib/ferrum_mcp/url_policy.rb +88 -0
  67. data/lib/ferrum_mcp/version.rb +1 -1
  68. metadata +21 -3
@@ -2,223 +2,68 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to interact with Shadow DOM elements
5
+ # Interact with Shadow DOM elements
6
6
  class QueryShadowDOMTool < BaseTool
7
- def self.tool_name
8
- 'query_shadow_dom'
9
- end
10
-
11
- def self.description
12
- 'Query and interact with elements inside Shadow DOM'
13
- end
14
-
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- host_selector: {
20
- type: 'string',
21
- description: 'CSS selector of the Shadow DOM host element'
22
- },
23
- shadow_selector: {
24
- type: 'string',
25
- description: 'CSS selector to find element(s) within the Shadow DOM'
26
- },
27
- action: {
28
- type: 'string',
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
- private
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
- def click_in_shadow_dom(host_selector, shadow_selector)
83
- host_js = host_selector.inspect
84
- shadow_js = shadow_selector.inspect
41
+ private
85
42
 
86
- script = <<~JS.strip
87
- (function() {
88
- var host = document.querySelector(#{host_js});
89
- if (!host || !host.shadowRoot) {
90
- throw new Error('Shadow DOM host not found or has no shadowRoot');
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
- var element = host.shadowRoot.querySelector(#{shadow_js});
93
- if (!element) {
94
- throw new Error('Element not found in Shadow DOM');
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 get_html_from_shadow_dom(host_selector, shadow_selector, multiple)
146
- host_js = host_selector.inspect
147
- shadow_js = shadow_selector.inspect
148
-
149
- script = if multiple
150
- <<~JS.strip
151
- (function() {
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
- # Tool to refresh the current page
5
+ # Refresh the current page
6
6
  class RefreshTool < BaseTool
7
- def self.tool_name
8
- 'refresh'
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 self.input_schema
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
- browser.refresh
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 'vips'
3
+ require 'base64'
4
4
 
5
5
  module FerrumMCP
6
6
  module Tools
7
- # Tool to take screenshots
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
- def self.description
16
- 'Take a screenshot of the page or a specific element'
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
- def self.input_schema
20
- {
21
- type: 'object',
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 execute(params)
19
+ def perform(params)
48
20
  ensure_browser_active
49
- selector = param(params, :selector)
50
- full_page = param(params, :full_page) || false
51
- format = param(params, :format) || 'png'
52
-
21
+ selector = params[:selector]
22
+ format = params[:format]
53
23
  logger.info 'Taking screenshot'
54
24
 
55
- # If selector provided, verify element exists and is visible
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 if element.respond_to?(:scroll_into_view)
59
-
60
- # Small delay to ensure element is fully rendered
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
- # Request binary encoding from Ferrum (by default it returns base64)
65
- options = { format: format, full: full_page, encoding: :binary }
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
- # Resize image if any dimension exceeds MAX_DIMENSION
89
- # @param image_data [String] Binary image data
90
- # @param format [String] Image format ('png' or 'jpeg')
91
- # @return [String] Resized binary image data (or original if no resize needed)
92
- def resize_if_needed(image_data, format)
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
- logger.info "Resizing screenshot from #{width}x#{height} to #{new_width}x#{new_height}"
48
+ page.execute("arguments[0].setAttribute('data-fmcp-shot', '1')", element)
49
+ '[data-fmcp-shot="1"]'
50
+ end
109
51
 
110
- # Resize image (using high quality Lanczos3 interpolation)
111
- resized = image.thumbnail_image(new_width, height: new_height, size: :force)
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
- # Return resized binary data in the correct format
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 self.tool_name
15
- raise NotImplementedError, 'Subclasses must implement .tool_name'
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 self.input_schema
23
- raise NotImplementedError, 'Subclasses must implement .input_schema'
23
+ def perform(_params)
24
+ raise NotImplementedError, 'Subclasses must implement #perform'
24
25
  end
25
26
 
26
27
  protected