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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Close a tab
6
+ class CloseTabTool < TabTool
7
+ tool_name 'close_tab'
8
+ description 'Close a tab (the current one by default). The last remaining tab cannot be closed.'
9
+
10
+ param :tab_id, type: :string, description: 'tab_id of the tab to close (default: current tab)'
11
+ param :index, type: :integer, description: 'Zero-based index of the tab to close (alternative to tab_id)'
12
+
13
+ def perform(params)
14
+ ensure_browser_active
15
+ list = tabs
16
+ raise ToolError, 'Cannot close the last tab of the session' if list.length <= 1
17
+
18
+ tab = if params[:tab_id] || params[:index]
19
+ find_tab(tab_id: params[:tab_id], index: params[:index])
20
+ else
21
+ current_tab
22
+ end
23
+ closing_current = tab.target_id == current_tab.target_id
24
+ logger.info "Closing tab #{tab.target_id}"
25
+ tab.close
26
+ wait_until_gone(tab.target_id)
27
+
28
+ @browser_manager.select_page(tabs.first) if closing_current
29
+ success_response(closed_tab_id: tab.target_id, current_tab_id: current_tab.target_id, count: tabs.length)
30
+ rescue StandardError => e
31
+ logger.error "Close tab failed: #{e.message}"
32
+ error_response("Failed to close tab: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ # Target.closeTarget is asynchronous; wait for the browser to drop it
38
+ def wait_until_gone(target_id, timeout: 2)
39
+ deadline = monotonic_now + timeout
40
+ sleep POLL_INTERVAL while monotonic_now < deadline && tabs.any? { |t| t.target_id == target_id }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,73 +2,47 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
+ # Create a new browser session
5
6
  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)
7
+ tool_name 'create_session'
8
+ description <<~DESC
9
+ Create a new browser session with custom options.
10
+ Supports multiple browsers in parallel (Chrome, BotBrowser).
11
+ Returns a session_id to use with other tools.
12
+
13
+ Note: When running in Docker (DOCKER=true), headless mode is mandatory.
14
+ Attempting to create a non-headless session will result in an error.
15
+ DESC
16
+
17
+ param :browser_id, type: :string,
18
+ description: 'Optional: Browser ID to use (from ferrum://browsers resource)'
19
+ param :user_profile_id, type: :string,
20
+ description: 'Optional: User profile ID to use (from ferrum://user-profiles resource)'
21
+ param :bot_profile_id, type: :string,
22
+ description: 'Optional: BotBrowser profile ID to use (from ferrum://bot-profiles resource)'
23
+ param :browser_path, type: :string,
24
+ description: 'Optional: Path to browser executable (legacy, prefer browser_id)'
25
+ param :botbrowser_profile, type: :string,
26
+ description: 'Optional: Path to BotBrowser profile (legacy, prefer bot_profile_id)'
27
+ param :headless, type: :boolean,
28
+ description: 'Optional: Run browser in headless mode (default: BROWSER_HEADLESS). ' \
29
+ 'REQUIRED to be true when running in Docker.'
30
+ param :timeout, type: :number, description: 'Optional: Browser timeout in seconds (default: 60)'
31
+ param :browser_options, type: :object,
32
+ description: 'Optional: Additional Chrome command-line flags without the leading ' \
33
+ 'dashes (e.g. {"window-size": "1920,1080", "lang": "fr-FR"})',
34
+ schema: { additionalProperties: { type: 'string' } }
35
+ param :metadata, type: :object,
36
+ description: 'Optional: Custom metadata for this session (e.g., {"user": "john"})',
37
+ schema: { additionalProperties: true }
38
+
39
+ SESSION_OPTION_KEYS = %i[browser_id user_profile_id bot_profile_id browser_path botbrowser_profile
40
+ headless timeout browser_options metadata].freeze
41
+
42
+ def perform(params)
69
43
  logger.info 'Creating new browser session'
70
44
 
71
- options = build_options(params)
45
+ options = params.slice(*SESSION_OPTION_KEYS).compact
72
46
  validate_docker_headless!(options)
73
47
 
74
48
  session_id = session_manager.create_session(options)
@@ -86,61 +60,15 @@ module FerrumMCP
86
60
  private
87
61
 
88
62
  def validate_docker_headless!(options)
89
- # Check if running in Docker environment
90
63
  return unless ENV['DOCKER'] == 'true'
91
64
 
92
- # In Docker, headless mode is mandatory
93
- # Check if headless is explicitly set to false
94
65
  if options.key?(:headless) && options[:headless] == false
95
66
  raise 'Headless mode is required when running in Docker. ' \
96
67
  'Cannot create a non-headless session in a containerized environment.'
97
68
  end
98
69
 
99
- # Force headless to true in Docker if not explicitly set
100
70
  options[:headless] = true unless options.key?(:headless)
101
71
  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
72
  end
145
73
  end
146
74
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Class-level DSL shared by every tool.
6
+ #
7
+ # class ClickTool < BaseTool
8
+ # tool_name 'click'
9
+ # description 'Click on an element'
10
+ # param :selector, type: :string, required: true, description: 'CSS selector'
11
+ # param :wait, type: :number, default: 5, description: 'Seconds to wait'
12
+ # end
13
+ #
14
+ # The JSON schema handed to MCP is generated from these declarations, the
15
+ # session_id parameter is injected for tools that need a browser, and
16
+ # incoming params are normalized (symbol keys, defaults applied) before
17
+ # reaching #perform.
18
+ module Definition
19
+ SESSION_ID_SCHEMA = {
20
+ type: 'string',
21
+ description: 'Session ID to use for this operation (returned by create_session)'
22
+ }.freeze
23
+
24
+ TYPES = %i[string number integer boolean array object].freeze
25
+
26
+ def tool_name(value = nil)
27
+ return @tool_name = value.to_s if value
28
+
29
+ @tool_name || raise(NotImplementedError, "#{name} must declare tool_name")
30
+ end
31
+
32
+ def description(value = nil)
33
+ return @description = value if value
34
+
35
+ @description || raise(NotImplementedError, "#{name} must declare description")
36
+ end
37
+
38
+ # Whether the tool operates on a browser session (session_id injected and
39
+ # required). Session-management tools set this to false.
40
+ def requires_session(value)
41
+ @requires_session = value
42
+ end
43
+
44
+ def requires_session?
45
+ return @requires_session unless @requires_session.nil?
46
+
47
+ superclass.respond_to?(:requires_session?) ? superclass.requires_session? : true
48
+ end
49
+
50
+ # Declare an input parameter.
51
+ # @param schema [Hash] extra JSON schema keywords (items, additionalProperties, ...)
52
+ def param(name, type:, description:, required: false, default: nil, enum: nil, schema: {}) # rubocop:disable Metrics/ParameterLists
53
+ raise ArgumentError, "Unknown param type #{type.inspect}" unless TYPES.include?(type.to_sym)
54
+
55
+ param_definitions[name.to_sym] = {
56
+ type: type.to_sym, description: description, required: required,
57
+ default: default, enum: enum, schema: schema
58
+ }
59
+ end
60
+
61
+ def param_definitions
62
+ @param_definitions ||= {}
63
+ end
64
+
65
+ def input_schema
66
+ properties = {}
67
+ properties[:session_id] = SESSION_ID_SCHEMA if requires_session?
68
+ param_definitions.each { |name, definition| properties[name] = json_schema_for(definition) }
69
+
70
+ required = []
71
+ required << 'session_id' if requires_session?
72
+ required.concat(param_definitions.select { |_, d| d[:required] }.keys.map(&:to_s))
73
+
74
+ schema = { type: 'object', properties: properties }
75
+ schema[:required] = required.uniq unless required.empty?
76
+ schema
77
+ end
78
+
79
+ # Symbol keys (recursively) and declared defaults applied.
80
+ def normalize_params(raw_params)
81
+ params = deep_symbolize(raw_params || {})
82
+ param_definitions.each do |name, definition|
83
+ params[name] = definition[:default] if params[name].nil? && !definition[:default].nil?
84
+ end
85
+ params
86
+ end
87
+
88
+ private
89
+
90
+ def json_schema_for(definition)
91
+ schema = { type: definition[:type].to_s, description: definition[:description] }
92
+ schema[:default] = definition[:default] unless definition[:default].nil?
93
+ schema[:enum] = definition[:enum] if definition[:enum]
94
+ schema.merge(definition[:schema])
95
+ end
96
+
97
+ def deep_symbolize(value)
98
+ case value
99
+ when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
100
+ when Array then value.map { |v| deep_symbolize(v) }
101
+ else value
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -2,119 +2,34 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to perform drag and drop operations
5
+ # Drag and drop
6
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
- )
7
+ tool_name 'drag_and_drop'
8
+ description 'Drag an element and drop it onto another element or at coordinates'
9
+
10
+ param :source_selector, type: :string, required: true, description: 'Selector of the element to drag'
11
+ param :target_selector, type: :string, description: 'Selector of the drop target (or use target_x/target_y)'
12
+ param :target_x, type: :number, description: 'X coordinate to drop at (alternative to target_selector)'
13
+ param :target_y, type: :number, description: 'Y coordinate to drop at (alternative to target_selector)'
14
+ param :steps, type: :number, default: 10, description: 'Number of intermediate mouse moves (default: 10)'
15
+
16
+ def perform(params)
17
+ source = params[:source_selector]
18
+ target = params[:target_selector]
19
+ logger.info "Dragging #{source} to #{target || "(#{params[:target_x]}, #{params[:target_y]})"}"
20
+
21
+ from_x, from_y = center_of(find_element(source))
22
+ to_x, to_y = if target
23
+ center_of(find_element(target))
24
+ elsif params[:target_x] && params[:target_y]
25
+ [params[:target_x], params[:target_y]]
26
+ else
27
+ raise ToolError, 'Either target_selector or both target_x and target_y must be provided'
28
+ end
29
+
30
+ perform_drag(from_x, from_y, to_x, to_y, params[:steps].to_i.clamp(1, 200))
31
+
32
+ success_response(message: "Dragged from (#{from_x.round}, #{from_y.round}) to (#{to_x.round}, #{to_y.round})")
118
33
  rescue StandardError => e
119
34
  logger.error "Drag and drop failed: #{e.message}"
120
35
  error_response("Failed to drag and drop: #{e.message}")
@@ -122,49 +37,32 @@ module FerrumMCP
122
37
 
123
38
  private
124
39
 
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
40
+ def center_of(element)
41
+ center = page.evaluate(<<~JS, element)
42
+ (function(el) {
43
+ const rect = el.getBoundingClientRect();
44
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
45
+ })(arguments[0])
46
+ JS
47
+ [center['x'], center['y']]
135
48
  end
136
49
 
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
50
+ def perform_drag(from_x, from_y, to_x, to_y, steps)
51
+ mouse = page.mouse
141
52
  mouse.move(x: from_x, y: from_y)
142
53
  sleep 0.05
143
-
144
- # Press mouse button
145
54
  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
55
+ sleep 0.05
154
56
 
155
- # Perform smooth drag
57
+ delay = [0.3 / steps, 0.01].max
156
58
  (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
59
+ progress = step / steps.to_f
60
+ mouse.move(x: from_x + ((to_x - from_x) * progress), y: from_y + ((to_y - from_y) * progress))
61
+ sleep delay
161
62
  end
162
63
 
163
- # Release mouse button
164
- sleep 0.05 # Small delay before release
64
+ sleep 0.05
165
65
  mouse.up
166
-
167
- logger.debug 'Drag and drop completed'
168
66
  end
169
67
  end
170
68
  end
@@ -2,39 +2,17 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to evaluate JavaScript
5
+ # Evaluate JavaScript and return the result
6
6
  class EvaluateJSTool < BaseTool
7
- def self.tool_name
8
- 'evaluate_js'
9
- end
7
+ tool_name 'evaluate_js'
8
+ description 'Evaluate a JavaScript expression in the page and return its (JSON-serializable) result'
10
9
 
11
- def self.description
12
- 'Evaluate JavaScript expression and return the result'
13
- end
10
+ param :expression, type: :string, required: true, description: 'JavaScript expression to evaluate'
14
11
 
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)
12
+ def perform(params)
33
13
  ensure_browser_active
34
- expression = params['expression'] || params[:expression]
35
-
36
14
  logger.info 'Evaluating JavaScript'
37
- result = browser.evaluate(expression)
15
+ result = page.evaluate(params[:expression].to_s)
38
16
 
39
17
  success_response(result: result)
40
18
  rescue StandardError => e
@@ -2,41 +2,17 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to execute JavaScript code
5
+ # Execute JavaScript for side effects
6
6
  class ExecuteScriptTool < BaseTool
7
- def self.tool_name
8
- 'execute_script'
9
- end
7
+ tool_name 'execute_script'
8
+ description 'Execute JavaScript code in the page for its side effects (use evaluate_js to get a value back)'
10
9
 
11
- def self.description
12
- 'Execute JavaScript code in the browser context'
13
- end
10
+ param :script, type: :string, required: true, description: 'JavaScript code to execute'
14
11
 
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)
12
+ def perform(params)
33
13
  ensure_browser_active
34
- script = param(params, :script)
35
-
36
14
  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)
15
+ page.execute(params[:script].to_s)
40
16
 
41
17
  success_response(message: 'Script executed successfully')
42
18
  rescue StandardError => e