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,37 +2,14 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to get page title
5
+ # Get page title
6
6
  class GetTitleTool < BaseTool
7
- def self.tool_name
8
- 'get_title'
9
- end
10
-
11
- def self.description
12
- 'Get the title of the current page'
13
- end
7
+ tool_name 'get_title'
8
+ description 'Get the title of 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
- logger.info 'Getting page title'
31
-
32
- success_response(
33
- title: browser.title,
34
- url: browser.url
35
- )
12
+ success_response(title: page.title, url: page.url)
36
13
  rescue StandardError => e
37
14
  logger.error "Get title failed: #{e.message}"
38
15
  error_response("Failed to get title: #{e.message}")
@@ -2,34 +2,14 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to get current URL
5
+ # Get current URL
6
6
  class GetURLTool < BaseTool
7
- def self.tool_name
8
- 'get_url'
9
- end
10
-
11
- def self.description
12
- 'Get the current URL of the page'
13
- end
7
+ tool_name 'get_url'
8
+ description 'Get the current page URL'
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
-
31
- logger.info 'Getting current URL'
32
- success_response(url: browser.url)
12
+ success_response(url: page.url)
33
13
  rescue StandardError => e
34
14
  logger.error "Get URL failed: #{e.message}"
35
15
  error_response("Failed to get URL: #{e.message}")
@@ -2,41 +2,18 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to go back in browser history
5
+ # Go back to the previous page in browser history
6
6
  class GoBackTool < BaseTool
7
- def self.tool_name
8
- 'go_back'
9
- end
10
-
11
- def self.description
12
- 'Go back to the previous page in browser history'
13
- end
7
+ tool_name 'go_back'
8
+ description 'Go back to the previous page in browser history'
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 'Going back'
31
- browser.back
32
-
33
- # Wait for network to be idle to ensure page is loaded
34
- browser.network.wait_for_idle(timeout: 30)
13
+ page.back
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 "Go back timeout: #{e.message}"
42
19
  error_response("Go back timed out: #{e.message}")
@@ -2,41 +2,18 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to go forward in browser history
5
+ # Go forward to the next page in browser history
6
6
  class GoForwardTool < BaseTool
7
- def self.tool_name
8
- 'go_forward'
9
- end
10
-
11
- def self.description
12
- 'Go forward to the next page in browser history'
13
- end
7
+ tool_name 'go_forward'
8
+ description 'Go forward to the next page in browser history'
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 'Going forward'
31
- browser.forward
32
-
33
- # Wait for network to be idle to ensure page is loaded
34
- browser.network.wait_for_idle(timeout: 30)
13
+ page.forward
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 "Go forward timeout: #{e.message}"
42
19
  error_response("Go forward timed out: #{e.message}")
@@ -2,51 +2,27 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to hover over an element
5
+ # Hover over an element
6
6
  class HoverTool < BaseTool
7
- def self.tool_name
8
- 'hover'
9
- end
10
-
11
- def self.description
12
- 'Hover over an element using a CSS selector'
13
- end
14
-
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- selector: {
20
- type: 'string',
21
- description: 'CSS selector of the element to hover over'
22
- },
23
- session_id: {
24
- type: 'string',
25
- description: 'Session ID to use for this operation'
26
- }
27
- },
28
- required: %w[selector session_id]
29
- }
30
- end
7
+ tool_name 'hover'
8
+ description 'Hover over an element (CSS selector, XPath or snapshot ref)'
31
9
 
32
- def execute(params)
33
- selector = param(params, :selector)
10
+ param :selector, type: :string, required: true, description: 'Selector of the element to hover over'
34
11
 
12
+ def perform(params)
13
+ selector = params[:selector]
35
14
  logger.info "Hovering over element: #{selector}"
36
15
 
37
- # First ensure element exists and is ready
38
16
  element = find_element(selector)
17
+ element.scroll_into_view
39
18
 
40
- # Scroll into view if supported
41
- element.scroll_into_view if element.respond_to?(:scroll_into_view)
42
-
43
- # Try native hover first, fallback to JavaScript
44
19
  begin
45
20
  element.hover
46
- logger.debug 'Native hover successful'
47
21
  rescue StandardError => e
48
22
  logger.debug "Native hover failed, using JavaScript: #{e.message}"
49
- hover_with_javascript(selector)
23
+ page.execute(<<~JS, element)
24
+ arguments[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
25
+ JS
50
26
  end
51
27
 
52
28
  success_response(message: "Hovered over #{selector}")
@@ -54,23 +30,6 @@ module FerrumMCP
54
30
  logger.error "Hover failed: #{e.message}"
55
31
  error_response("Failed to hover: #{e.message}")
56
32
  end
57
-
58
- private
59
-
60
- def hover_with_javascript(selector)
61
- # Use inspect to properly escape the selector for JavaScript (prevents XSS)
62
- script = <<~JS
63
- const element = document.querySelector(#{selector.inspect});
64
- if (!element) {
65
- throw new Error('Element not found: ' + #{selector.inspect});
66
- }
67
- const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window });
68
- element.dispatchEvent(event);
69
- JS
70
-
71
- browser.execute(script)
72
- logger.debug 'JavaScript hover successful'
73
- end
74
33
  end
75
34
  end
76
35
  end
@@ -2,28 +2,14 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
+ # List all active sessions
5
6
  class ListSessionsTool < SessionTool
6
- def self.tool_name
7
- 'list_sessions'
8
- end
9
-
10
- def self.description
11
- 'List all active browser sessions with their information (id, status, type, uptime, etc.)'
12
- end
13
-
14
- def self.input_schema
15
- {
16
- type: 'object',
17
- properties: {}
18
- }
19
- end
7
+ tool_name 'list_sessions'
8
+ description 'List all active browser sessions with their information (id, status, type, uptime, etc.)'
20
9
 
21
- def execute(_params)
10
+ def perform(_params)
22
11
  sessions = session_manager.list_sessions
23
- success_response(
24
- count: sessions.size,
25
- sessions: sessions
26
- )
12
+ success_response(count: sessions.size, sessions: sessions)
27
13
  rescue StandardError => e
28
14
  logger.error "Failed to list sessions: #{e.message}"
29
15
  error_response("Failed to list sessions: #{e.message}")
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # List open tabs
6
+ class ListTabsTool < TabTool
7
+ tool_name 'list_tabs'
8
+ description 'List the open tabs of the session with their tab_id, url and title; marks the current one'
9
+
10
+ def perform(_params)
11
+ ensure_browser_active
12
+ list = tabs.map.with_index { |tab, index| describe(tab, index) }
13
+ success_response(count: list.length, tabs: list)
14
+ rescue StandardError => e
15
+ logger.error "List tabs failed: #{e.message}"
16
+ error_response("Failed to list tabs: #{e.message}")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -2,51 +2,31 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to navigate to a URL
5
+ # Navigate to a URL
6
6
  class NavigateTool < BaseTool
7
- def self.tool_name
8
- 'navigate'
9
- end
10
-
11
- def self.description
12
- 'Navigate to a specific URL in the browser'
13
- end
7
+ tool_name 'navigate'
8
+ description 'Navigate to a specific URL in the browser and wait for the network to settle'
14
9
 
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- url: {
20
- type: 'string',
21
- description: 'The URL to navigate to (must include protocol: http:// or https://)'
22
- },
23
- session_id: {
24
- type: 'string',
25
- description: 'Session ID to use for this operation'
26
- }
27
- },
28
- required: %w[url session_id]
29
- }
30
- end
10
+ param :url, type: :string, required: true,
11
+ description: 'The URL to navigate to (must include protocol: http:// or https://)'
12
+ param :wait_for_idle, type: :boolean, default: true,
13
+ description: 'Wait for network idle after navigation (default: true)'
14
+ param :timeout, type: :number, default: 30,
15
+ description: 'Seconds to wait for the network to become idle (default: 30)'
31
16
 
32
- def execute(params)
17
+ def perform(params)
33
18
  ensure_browser_active
34
- url = param(params, :url)
19
+ url = params[:url].to_s
35
20
 
36
- # Validate URL format
37
- raise ToolError, 'URL must start with http:// or https://' unless %r{^https?://}.match?(url)
21
+ raise ToolError, 'URL must start with http:// or https://' unless %r{\Ahttps?://}.match?(url)
38
22
 
39
- logger.info "Navigating to: #{url}"
40
- browser.goto(url)
23
+ @browser_manager.config.url_policy.check!(url)
41
24
 
42
- # Wait for network to be idle to ensure page is loaded
43
- # This prevents race conditions with subsequent tool calls
44
- browser.network.wait_for_idle(timeout: 30)
25
+ logger.info "Navigating to: #{url}"
26
+ page.goto(url)
27
+ page.network.wait_for_idle(timeout: params[:timeout]) if params[:wait_for_idle]
45
28
 
46
- success_response(
47
- url: browser.url,
48
- title: browser.title
49
- )
29
+ success_response(url: page.url, title: page.title)
50
30
  rescue Ferrum::TimeoutError => e
51
31
  logger.error "Navigation timeout: #{e.message}"
52
32
  error_response("Navigation timed out: #{e.message}")
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FerrumMCP
4
+ module Tools
5
+ # Open a new tab
6
+ class NewTabTool < TabTool
7
+ tool_name 'new_tab'
8
+ description 'Open a new tab (optionally at a URL) and make it the current tab'
9
+
10
+ param :url, type: :string, description: 'Optional: URL to open in the new tab'
11
+
12
+ def perform(params)
13
+ ensure_browser_active
14
+ url = params[:url]
15
+ if url
16
+ raise ToolError, 'URL must start with http:// or https://' unless %r{\Ahttps?://}.match?(url)
17
+
18
+ @browser_manager.config.url_policy.check!(url)
19
+ end
20
+
21
+ logger.info "Opening new tab#{" at #{url}" if url}"
22
+ tab = @browser_manager.create_page
23
+ @browser_manager.select_page(tab)
24
+ if url
25
+ tab.goto(url)
26
+ tab.network.wait_for_idle(timeout: 30)
27
+ end
28
+
29
+ success_response(tab_id: tab.target_id, url: tab.url, title: tab.title, count: tabs.length)
30
+ rescue StandardError => e
31
+ logger.error "New tab failed: #{e.message}"
32
+ error_response("Failed to open tab: #{e.message}")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,53 +2,33 @@
2
2
 
3
3
  module FerrumMCP
4
4
  module Tools
5
- # Tool to press keyboard keys
5
+ # Press keyboard keys
6
6
  class PressKeyTool < BaseTool
7
- def self.tool_name
8
- 'press_key'
9
- end
7
+ tool_name 'press_key'
8
+ description 'Press a keyboard key (e.g. Enter, Tab, Escape, ArrowDown) or type a character'
10
9
 
11
- def self.description
12
- 'Press keyboard keys (e.g., Enter, Tab, Escape)'
13
- end
10
+ param :key, type: :string, required: true, description: 'Key to press (Enter, Tab, Escape, ArrowDown, etc.)'
11
+ param :selector, type: :string, description: 'Optional: selector of the element to focus before pressing the key'
14
12
 
15
- def self.input_schema
16
- {
17
- type: 'object',
18
- properties: {
19
- key: {
20
- type: 'string',
21
- description: 'Key to press (Enter, Tab, Escape, ArrowDown, etc.)'
22
- },
23
- selector: {
24
- type: 'string',
25
- description: 'Optional: CSS selector to focus before pressing key'
26
- },
27
- session_id: {
28
- type: 'string',
29
- description: 'Session ID to use for this operation'
30
- }
31
- },
32
- required: %w[key session_id]
33
- }
34
- end
13
+ KEY_ALIASES = {
14
+ 'enter' => :Enter, 'return' => :Enter, 'tab' => :Tab, 'escape' => :Escape, 'esc' => :Escape,
15
+ 'backspace' => :Backspace, 'delete' => :Delete, 'del' => :Delete,
16
+ 'arrowdown' => :Down, 'down' => :Down, 'arrowup' => :Up, 'up' => :Up,
17
+ 'arrowleft' => :Left, 'left' => :Left, 'arrowright' => :Right, 'right' => :Right,
18
+ 'space' => ' ', 'pageup' => :PageUp, 'pagedown' => :PageDown, 'home' => :Home, 'end' => :End
19
+ }.freeze
35
20
 
36
- def execute(params)
37
- key = param(params, :key)
38
- selector = param(params, :selector)
21
+ def perform(params)
22
+ key = params[:key].to_s
23
+ selector = params[:selector]
39
24
 
40
25
  if selector
41
26
  logger.info "Focusing element: #{selector}"
42
- element = find_element(selector)
43
- element.focus
27
+ find_element(selector).focus
44
28
  end
45
29
 
46
30
  logger.info "Pressing key: #{key}"
47
- normalized_key = normalize_key(key)
48
-
49
- # Use keyboard.type for key presses
50
- # This handles both special keys and regular characters correctly
51
- browser.keyboard.type(normalized_key)
31
+ page.keyboard.type(normalize_key(key))
52
32
 
53
33
  success_response(message: "Pressed key: #{key}")
54
34
  rescue StandardError => e
@@ -59,32 +39,7 @@ module FerrumMCP
59
39
  private
60
40
 
61
41
  def normalize_key(key)
62
- # Convert common key names to Ferrum format
63
- case key.to_s.downcase
64
- when 'enter', 'return'
65
- :Enter
66
- when 'tab'
67
- :Tab
68
- when 'escape', 'esc'
69
- :Escape
70
- when 'backspace'
71
- :Backspace
72
- when 'delete', 'del'
73
- :Delete
74
- when 'arrowdown', 'down'
75
- :Down
76
- when 'arrowup', 'up'
77
- :Up
78
- when 'arrowleft', 'left'
79
- :Left
80
- when 'arrowright', 'right'
81
- :Right
82
- when 'space'
83
- ' '
84
- else
85
- # If already a symbol, return as-is, otherwise try to convert
86
- key.is_a?(Symbol) ? key : key.to_sym
87
- end
42
+ KEY_ALIASES.fetch(key.downcase) { key.length == 1 ? key : key.to_sym }
88
43
  end
89
44
  end
90
45
  end