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
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 =
|
|
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
|
-
#
|
|
5
|
+
# Drag and drop
|
|
6
6
|
class DragAndDropTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
138
|
-
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
|
|
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
|
-
|
|
57
|
+
delay = [0.3 / steps, 0.01].max
|
|
156
58
|
(1..steps).each do |step|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
+
# Evaluate JavaScript and return the result
|
|
6
6
|
class EvaluateJSTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
#
|
|
5
|
+
# Execute JavaScript for side effects
|
|
6
6
|
class ExecuteScriptTool < BaseTool
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|