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
|
@@ -29,16 +29,22 @@ module FerrumMCP
|
|
|
29
29
|
exit 1
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Runs the server until STDIN closes (stdio) or a signal arrives (http).
|
|
33
|
+
# Shutdown always happens here, on the main thread, never inside a trap.
|
|
32
34
|
def start
|
|
33
35
|
validate!
|
|
34
36
|
setup_servers
|
|
35
37
|
setup_signal_handlers
|
|
36
38
|
log_startup_info
|
|
37
39
|
run
|
|
40
|
+
rescue SignalException
|
|
41
|
+
config.logger.info 'Signal received, shutting down'
|
|
38
42
|
rescue StandardError => e
|
|
39
43
|
config.logger.error "ERROR: #{e.message}"
|
|
40
44
|
config.logger.error e.backtrace.join("\n")
|
|
41
45
|
exit 1
|
|
46
|
+
ensure
|
|
47
|
+
shutdown
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
private
|
|
@@ -59,16 +65,23 @@ module FerrumMCP
|
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# Trap handlers must not log or take locks (Ruby forbids Mutex use in
|
|
69
|
+
# trap context), so they only interrupt the main thread; #start does the
|
|
70
|
+
# actual shutdown.
|
|
62
71
|
def setup_signal_handlers
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
%w[INT TERM].each do |signal|
|
|
73
|
+
trap(signal) { raise Interrupt, signal }
|
|
74
|
+
end
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def shutdown
|
|
78
|
+
return if @shutdown_done
|
|
79
|
+
|
|
80
|
+
@shutdown_done = true
|
|
68
81
|
config.logger.info 'Shutting down...'
|
|
69
|
-
transport_server
|
|
70
|
-
mcp_server
|
|
71
|
-
|
|
82
|
+
transport_server&.stop
|
|
83
|
+
mcp_server&.shutdown
|
|
84
|
+
config.logger.info 'Shutdown complete'
|
|
72
85
|
end
|
|
73
86
|
|
|
74
87
|
def log_startup_info
|
|
@@ -158,7 +171,7 @@ module FerrumMCP
|
|
|
158
171
|
|
|
159
172
|
def run
|
|
160
173
|
transport_server.start
|
|
161
|
-
# Keep main thread alive for HTTP (stdio blocks
|
|
174
|
+
# Keep main thread alive for HTTP (stdio blocks until STDIN closes)
|
|
162
175
|
sleep if @options[:transport] == 'http'
|
|
163
176
|
end
|
|
164
177
|
end
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
3
6
|
module FerrumMCP
|
|
4
7
|
# Configuration class for Ferrum MCP Server
|
|
5
8
|
class Configuration
|
|
6
|
-
attr_accessor :headless, :timeout, :server_host, :server_port, :log_level, :
|
|
7
|
-
:rate_limit_enabled, :rate_limit_max_requests, :rate_limit_window
|
|
8
|
-
|
|
9
|
+
attr_accessor :headless, :timeout, :server_host, :server_port, :log_level, :log_file, :transport,
|
|
10
|
+
:max_sessions, :rate_limit_enabled, :rate_limit_max_requests, :rate_limit_window,
|
|
11
|
+
:api_key_enabled, :api_keys, :trust_proxy
|
|
12
|
+
attr_reader :browsers, :user_profiles, :bot_profiles, :url_policy, :upload_allowed_dirs
|
|
9
13
|
|
|
10
14
|
# Browser configuration structure
|
|
11
15
|
BrowserConfig = Struct.new(:id, :name, :path, :type, :description, keyword_init: true) do
|
|
@@ -34,19 +38,30 @@ module FerrumMCP
|
|
|
34
38
|
@timeout = ENV.fetch('BROWSER_TIMEOUT', '60').to_i
|
|
35
39
|
@server_host = ENV.fetch('MCP_SERVER_HOST', '0.0.0.0')
|
|
36
40
|
@server_port = ENV.fetch('MCP_SERVER_PORT', '3000').to_i
|
|
37
|
-
@log_level = ENV.fetch('LOG_LEVEL', '
|
|
41
|
+
@log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym
|
|
42
|
+
@log_file = ENV.fetch('LOG_FILE', nil)
|
|
38
43
|
@transport = transport
|
|
39
44
|
@max_sessions = ENV.fetch('MAX_CONCURRENT_SESSIONS', '10').to_i
|
|
40
45
|
|
|
46
|
+
# Only honour X-Forwarded-For when the server sits behind a trusted proxy
|
|
47
|
+
@trust_proxy = ENV.fetch('TRUST_PROXY', 'false') == 'true'
|
|
48
|
+
|
|
49
|
+
# Optional navigation restrictions (ALLOWED_HOSTS / BLOCKED_HOSTS)
|
|
50
|
+
@url_policy = UrlPolicy.from_env
|
|
51
|
+
|
|
52
|
+
# Directories the upload_file tool may read from (UPLOAD_ALLOWED_DIRS)
|
|
53
|
+
@upload_allowed_dirs = load_upload_allowed_dirs
|
|
54
|
+
|
|
41
55
|
# Rate limiting configuration
|
|
42
56
|
@rate_limit_enabled = ENV.fetch('RATE_LIMIT_ENABLED', 'true') == 'true'
|
|
43
57
|
@rate_limit_max_requests = ENV.fetch('RATE_LIMIT_MAX_REQUESTS', '100').to_i
|
|
44
58
|
@rate_limit_window = ENV.fetch('RATE_LIMIT_WINDOW', '60').to_i
|
|
45
59
|
|
|
46
|
-
#
|
|
47
|
-
@
|
|
48
|
-
@
|
|
49
|
-
|
|
60
|
+
# API key authentication configuration
|
|
61
|
+
@api_key_enabled = ENV.fetch('API_KEY_ENABLED', 'false') == 'true'
|
|
62
|
+
@api_keys = load_api_keys
|
|
63
|
+
|
|
64
|
+
load_browser_configurations
|
|
50
65
|
end
|
|
51
66
|
|
|
52
67
|
def valid?
|
|
@@ -80,14 +95,48 @@ module FerrumMCP
|
|
|
80
95
|
end
|
|
81
96
|
|
|
82
97
|
def logger
|
|
83
|
-
@logger ||=
|
|
98
|
+
@logger ||= create_logger
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
# Environment variable keys to skip when loading browsers
|
|
87
102
|
RESERVED_BROWSER_ENV_KEYS = %w[BROWSER_PATH BROWSER_HEADLESS BROWSER_TIMEOUT].freeze
|
|
88
103
|
|
|
104
|
+
# Check if API key authentication is properly configured
|
|
105
|
+
def api_key_configured?
|
|
106
|
+
api_key_enabled && api_keys.any?
|
|
107
|
+
end
|
|
108
|
+
|
|
89
109
|
private
|
|
90
110
|
|
|
111
|
+
def load_browser_configurations
|
|
112
|
+
@browsers = load_browsers
|
|
113
|
+
@user_profiles = load_user_profiles
|
|
114
|
+
@bot_profiles = load_bot_profiles
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Defaults to the current directory and the system temp dir
|
|
118
|
+
def load_upload_allowed_dirs
|
|
119
|
+
configured = ENV.fetch('UPLOAD_ALLOWED_DIRS', '').split(',').map(&:strip).reject(&:empty?)
|
|
120
|
+
dirs = configured.empty? ? [Dir.pwd, Dir.tmpdir] : configured
|
|
121
|
+
dirs.map { |d| File.expand_path(d) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Load API keys from environment variables
|
|
125
|
+
# Supports single key (API_KEY) or multiple keys (API_KEYS=key1,key2,key3)
|
|
126
|
+
def load_api_keys
|
|
127
|
+
keys = []
|
|
128
|
+
|
|
129
|
+
# Load single API key
|
|
130
|
+
single_key = ENV.fetch('API_KEY', nil)
|
|
131
|
+
keys << single_key if single_key && !single_key.empty?
|
|
132
|
+
|
|
133
|
+
# Load multiple API keys (comma-separated)
|
|
134
|
+
multiple_keys = ENV.fetch('API_KEYS', nil)
|
|
135
|
+
keys.concat(multiple_keys.split(',').map(&:strip).reject(&:empty?)) if multiple_keys && !multiple_keys.empty?
|
|
136
|
+
|
|
137
|
+
keys.uniq
|
|
138
|
+
end
|
|
139
|
+
|
|
91
140
|
# Load browser configurations from environment variables
|
|
92
141
|
# Format: BROWSER_<ID>=type:path:name:description
|
|
93
142
|
# Example: BROWSER_CHROME=chrome:/usr/bin/google-chrome:Google Chrome:Standard Chrome browser
|
|
@@ -213,17 +262,27 @@ module FerrumMCP
|
|
|
213
262
|
profiles
|
|
214
263
|
end
|
|
215
264
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
265
|
+
# Logs never go to STDOUT: in stdio transport STDOUT carries the protocol.
|
|
266
|
+
# LOG_FILE=<path> writes to that file, LOG_FILE=stderr writes to STDERR,
|
|
267
|
+
# unset writes to ./logs/ferrum_mcp.log under the current directory (never
|
|
268
|
+
# inside the installed gem), falling back to the system temp dir.
|
|
269
|
+
def create_logger
|
|
270
|
+
Logger.new(log_device, level: log_level)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def log_device
|
|
274
|
+
target = log_file.to_s.strip
|
|
275
|
+
return $stderr if target.casecmp('stderr').zero?
|
|
276
|
+
return prepare_log_path(target) unless target.empty?
|
|
222
277
|
|
|
223
|
-
|
|
278
|
+
prepare_log_path(File.join(Dir.pwd, 'logs', 'ferrum_mcp.log'))
|
|
279
|
+
rescue SystemCallError
|
|
280
|
+
File.join(Dir.tmpdir, 'ferrum_mcp.log')
|
|
281
|
+
end
|
|
224
282
|
|
|
225
|
-
|
|
226
|
-
|
|
283
|
+
def prepare_log_path(path)
|
|
284
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
285
|
+
path
|
|
227
286
|
end
|
|
228
287
|
end
|
|
229
288
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
# Optional libvips-backed resizing. ruby-vips raises LoadError when the
|
|
5
|
+
# native library is missing; screenshots are then returned at full size.
|
|
6
|
+
module ImageResizer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def available?
|
|
10
|
+
return @available unless @available.nil?
|
|
11
|
+
|
|
12
|
+
@available = begin
|
|
13
|
+
require 'vips'
|
|
14
|
+
true
|
|
15
|
+
rescue LoadError => e
|
|
16
|
+
warn_once("libvips not available (#{e.message}); large screenshots will not be resized")
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Scale the image down so both sides fit within max_dimension
|
|
22
|
+
def fit(image_data, max_dimension, format, logger: nil)
|
|
23
|
+
image = Vips::Image.new_from_buffer(image_data, '')
|
|
24
|
+
width = image.width
|
|
25
|
+
height = image.height
|
|
26
|
+
return image_data if width <= max_dimension && height <= max_dimension
|
|
27
|
+
|
|
28
|
+
scale = [max_dimension.to_f / width, max_dimension.to_f / height].min
|
|
29
|
+
new_width = (width * scale).to_i
|
|
30
|
+
new_height = (height * scale).to_i
|
|
31
|
+
logger&.info "Resizing screenshot from #{width}x#{height} to #{new_width}x#{new_height}"
|
|
32
|
+
|
|
33
|
+
image.thumbnail_image(new_width, height: new_height, size: :force).write_to_buffer(".#{format}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def warn_once(message)
|
|
37
|
+
return if @warned
|
|
38
|
+
|
|
39
|
+
@warned = true
|
|
40
|
+
Kernel.warn "[ferrum-mcp] #{message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/ferrum_mcp/server.rb
CHANGED
|
@@ -24,13 +24,27 @@ module FerrumMCP
|
|
|
24
24
|
Tools::DragAndDropTool,
|
|
25
25
|
Tools::AcceptCookiesTool,
|
|
26
26
|
Tools::SolveCaptchaTool,
|
|
27
|
+
Tools::ScrollTool,
|
|
28
|
+
Tools::SelectOptionTool,
|
|
29
|
+
Tools::UploadFileTool,
|
|
27
30
|
# Extraction
|
|
31
|
+
Tools::SnapshotTool,
|
|
28
32
|
Tools::GetTextTool,
|
|
29
33
|
Tools::GetHTMLTool,
|
|
30
34
|
Tools::ScreenshotTool,
|
|
31
35
|
Tools::GetTitleTool,
|
|
32
36
|
Tools::GetURLTool,
|
|
33
37
|
Tools::FindByTextTool,
|
|
38
|
+
# Waiting
|
|
39
|
+
Tools::WaitForSelectorTool,
|
|
40
|
+
Tools::WaitForTextTool,
|
|
41
|
+
Tools::WaitForNetworkIdleTool,
|
|
42
|
+
# Tabs & viewport
|
|
43
|
+
Tools::ListTabsTool,
|
|
44
|
+
Tools::NewTabTool,
|
|
45
|
+
Tools::SwitchTabTool,
|
|
46
|
+
Tools::CloseTabTool,
|
|
47
|
+
Tools::SetViewportTool,
|
|
34
48
|
# Advanced
|
|
35
49
|
Tools::ExecuteScriptTool,
|
|
36
50
|
Tools::EvaluateJSTool,
|
|
@@ -46,7 +60,6 @@ module FerrumMCP
|
|
|
46
60
|
@logger = config.logger
|
|
47
61
|
@session_manager = SessionManager.new(config)
|
|
48
62
|
@resource_manager = ResourceManager.new(config)
|
|
49
|
-
@tool_instances = {}
|
|
50
63
|
@mcp_server = create_mcp_server
|
|
51
64
|
|
|
52
65
|
setup_tools
|
|
@@ -54,20 +67,6 @@ module FerrumMCP
|
|
|
54
67
|
setup_error_handling
|
|
55
68
|
end
|
|
56
69
|
|
|
57
|
-
# Deprecated: For backward compatibility
|
|
58
|
-
# Sessions must be created explicitly using create_session tool
|
|
59
|
-
def start_browser
|
|
60
|
-
raise NotImplementedError, 'start_browser is deprecated. Use create_session tool to create a session.'
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Deprecated: For backward compatibility
|
|
64
|
-
def stop_browser
|
|
65
|
-
logger.warn 'stop_browser is deprecated, use session_manager.close_all_sessions'
|
|
66
|
-
session_manager.close_all_sessions
|
|
67
|
-
@tool_instances = {}
|
|
68
|
-
logger.info 'All sessions stopped'
|
|
69
|
-
end
|
|
70
|
-
|
|
71
70
|
# Shutdown server and cleanup all sessions
|
|
72
71
|
def shutdown
|
|
73
72
|
logger.info 'Shutting down server...'
|
|
@@ -140,101 +139,69 @@ module FerrumMCP
|
|
|
140
139
|
end
|
|
141
140
|
|
|
142
141
|
def execute_tool(tool_class, params)
|
|
142
|
+
params = params.except(:server_context)
|
|
143
143
|
logger.debug "Executing tool: #{tool_class.tool_name} with params: #{params.inspect}"
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
else
|
|
151
|
-
# Extract session_id from params (required)
|
|
152
|
-
session_id = params[:session_id] || params['session_id']
|
|
153
|
-
|
|
154
|
-
unless session_id
|
|
155
|
-
logger.error "session_id is required for #{tool_class.tool_name}"
|
|
156
|
-
return error_tool_response('session_id is required. Create a session first using create_session tool.')
|
|
157
|
-
end
|
|
145
|
+
result = if tool_class.requires_session?
|
|
146
|
+
execute_browser_tool(tool_class, params)
|
|
147
|
+
else
|
|
148
|
+
tool_class.new(session_manager).execute(params)
|
|
149
|
+
end
|
|
158
150
|
|
|
159
|
-
|
|
151
|
+
to_mcp_response(tool_class, result)
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
logger.error "Tool execution error (#{tool_class.tool_name}): #{e.class} - #{e.message}"
|
|
154
|
+
logger.error e.backtrace.first(10).join("\n")
|
|
155
|
+
error_tool_response("#{e.class}: #{e.message}")
|
|
156
|
+
end
|
|
160
157
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
tool.execute(params)
|
|
167
|
-
end
|
|
158
|
+
def execute_browser_tool(tool_class, params)
|
|
159
|
+
session_id = params[:session_id] || params['session_id']
|
|
160
|
+
if session_id.nil? || session_id.to_s.empty?
|
|
161
|
+
logger.error "session_id is required for #{tool_class.tool_name}"
|
|
162
|
+
return { success: false, error: 'session_id is required. Create a session first using create_session tool.' }
|
|
168
163
|
end
|
|
169
164
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
# MCP expects a Tool::Response object
|
|
173
|
-
# Convert our tool result to MCP format
|
|
174
|
-
if result[:success]
|
|
175
|
-
logger.debug "Tool succeeded, creating MCP::Tool::Response with data: #{result[:data].inspect}"
|
|
176
|
-
|
|
177
|
-
# Check if this is an image response
|
|
178
|
-
if result[:type] == 'image'
|
|
179
|
-
logger.debug "Creating image response with mime_type: #{result[:mime_type]}"
|
|
180
|
-
# Return MCP Tool::Response with image content
|
|
181
|
-
MCP::Tool::Response.new([{
|
|
182
|
-
type: 'image',
|
|
183
|
-
data: result[:data],
|
|
184
|
-
mimeType: result[:mime_type]
|
|
185
|
-
}])
|
|
186
|
-
else
|
|
187
|
-
# Return a proper MCP Tool::Response with the data as text content
|
|
188
|
-
MCP::Tool::Response.new([{ type: 'text', text: result[:data].to_json }])
|
|
189
|
-
end
|
|
190
|
-
else
|
|
191
|
-
logger.error "Tool failed with error: #{result[:error]}"
|
|
192
|
-
# Return an error response
|
|
193
|
-
MCP::Tool::Response.new([{ type: 'text', text: result[:error] }], error: true)
|
|
165
|
+
session_manager.with_session(session_id) do |browser_manager|
|
|
166
|
+
tool_class.new(browser_manager).execute(params)
|
|
194
167
|
end
|
|
195
|
-
rescue StandardError => e
|
|
196
|
-
logger.error "Tool execution error (#{tool_class.tool_name}): #{e.class} - #{e.message}"
|
|
197
|
-
logger.error 'Backtrace:'
|
|
198
|
-
logger.error e.backtrace.first(10).join("\n")
|
|
199
|
-
# Return an error response for unexpected exceptions
|
|
200
|
-
MCP::Tool::Response.new([{ type: 'text', text: "#{e.class}: #{e.message}" }], error: true)
|
|
201
168
|
end
|
|
202
169
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
170
|
+
def to_mcp_response(tool_class, result)
|
|
171
|
+
unless result[:success]
|
|
172
|
+
logger.error "Tool #{tool_class.tool_name} failed: #{result[:error]}"
|
|
173
|
+
return error_tool_response(result[:error])
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if result[:type] == 'image'
|
|
177
|
+
MCP::Tool::Response.new([{ type: 'image', data: result[:data], mimeType: result[:mime_type] }])
|
|
178
|
+
else
|
|
179
|
+
MCP::Tool::Response.new([{ type: 'text', text: result[:data].to_json }])
|
|
180
|
+
end
|
|
211
181
|
end
|
|
212
182
|
|
|
213
183
|
def setup_error_handling
|
|
214
184
|
MCP.configure do |mcp_config|
|
|
215
|
-
mcp_config.exception_reporter =
|
|
216
|
-
logger.error '=' * 80
|
|
217
|
-
logger.error "MCP Exception: #{exception.class} - #{exception.message}"
|
|
218
|
-
logger.error "Context: #{context.inspect}"
|
|
219
|
-
|
|
220
|
-
# Log the original error if there is one
|
|
221
|
-
if exception.respond_to?(:original_error) && exception.original_error
|
|
222
|
-
logger.error "ORIGINAL ERROR: #{exception.original_error.class} - #{exception.original_error.message}"
|
|
223
|
-
logger.error 'ORIGINAL BACKTRACE:'
|
|
224
|
-
logger.error exception.original_error.backtrace.first(15).join("\n")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
logger.error 'Exception backtrace:'
|
|
228
|
-
logger.error exception.backtrace.join("\n")
|
|
229
|
-
logger.error '=' * 80
|
|
230
|
-
}
|
|
231
|
-
|
|
185
|
+
mcp_config.exception_reporter = ->(exception, context) { report_exception(exception, context) }
|
|
232
186
|
mcp_config.instrumentation_callback = lambda { |data|
|
|
233
187
|
logger.debug "MCP Method: #{data[:method]}, Duration: #{data[:duration]}s"
|
|
234
188
|
}
|
|
235
189
|
end
|
|
236
190
|
end
|
|
237
191
|
|
|
192
|
+
def report_exception(exception, context)
|
|
193
|
+
logger.error '=' * 80
|
|
194
|
+
logger.error "MCP Exception: #{exception.class} - #{exception.message}"
|
|
195
|
+
logger.error "Context: #{context.inspect}"
|
|
196
|
+
original = exception.respond_to?(:original_error) ? exception.original_error : nil
|
|
197
|
+
if original
|
|
198
|
+
logger.error "ORIGINAL ERROR: #{original.class} - #{original.message}"
|
|
199
|
+
logger.error original.backtrace.first(15).join("\n")
|
|
200
|
+
end
|
|
201
|
+
logger.error exception.backtrace.join("\n")
|
|
202
|
+
logger.error '=' * 80
|
|
203
|
+
end
|
|
204
|
+
|
|
238
205
|
def error_response(message)
|
|
239
206
|
{
|
|
240
207
|
jsonrpc: '2.0',
|
data/lib/ferrum_mcp/session.rb
CHANGED
|
@@ -15,14 +15,23 @@ module FerrumMCP
|
|
|
15
15
|
@last_used_at = Time.now
|
|
16
16
|
@metadata = options[:metadata] || {}
|
|
17
17
|
@mutex = Mutex.new
|
|
18
|
+
@closed = false
|
|
18
19
|
@session_config, @browser_manager = create_browser_manager
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
# Execute a block with thread-safe access to the browser
|
|
22
|
+
# Execute a block with thread-safe access to the browser.
|
|
23
|
+
# A dead browser detected during the block leaves the session inactive so
|
|
24
|
+
# the next call restarts it instead of failing forever.
|
|
22
25
|
def with_browser
|
|
23
26
|
@mutex.synchronize do
|
|
24
27
|
@last_used_at = Time.now
|
|
25
|
-
|
|
28
|
+
begin
|
|
29
|
+
yield @browser_manager
|
|
30
|
+
rescue Ferrum::DeadBrowserError
|
|
31
|
+
logger.warn "Browser for session #{@id} died during a tool call"
|
|
32
|
+
@browser_manager.stop
|
|
33
|
+
raise
|
|
34
|
+
end
|
|
26
35
|
end
|
|
27
36
|
end
|
|
28
37
|
|
|
@@ -31,21 +40,41 @@ module FerrumMCP
|
|
|
31
40
|
@browser_manager.active?
|
|
32
41
|
end
|
|
33
42
|
|
|
43
|
+
def closed?
|
|
44
|
+
@closed
|
|
45
|
+
end
|
|
46
|
+
|
|
34
47
|
# Start the browser for this session
|
|
35
48
|
def start
|
|
49
|
+
@mutex.synchronize { start_browser }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Make sure a live browser is available, restarting it if its process died.
|
|
53
|
+
def ensure_started
|
|
36
54
|
@mutex.synchronize do
|
|
37
|
-
@browser_manager.
|
|
38
|
-
|
|
55
|
+
if @browser_manager.active? && !@browser_manager.healthy?
|
|
56
|
+
logger.warn "Restarting dead browser for session #{@id}"
|
|
57
|
+
@browser_manager.stop
|
|
58
|
+
end
|
|
59
|
+
start_browser
|
|
39
60
|
end
|
|
40
61
|
end
|
|
41
62
|
|
|
42
|
-
# Stop the browser for this session
|
|
63
|
+
# Stop the browser for this session (the session can be started again)
|
|
43
64
|
def stop
|
|
44
65
|
@mutex.synchronize do
|
|
45
66
|
@browser_manager.stop if @browser_manager.active?
|
|
46
67
|
end
|
|
47
68
|
end
|
|
48
69
|
|
|
70
|
+
# Close the session for good: stops the browser and refuses any restart
|
|
71
|
+
def close
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@closed = true
|
|
74
|
+
@browser_manager.stop if @browser_manager.active?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
49
78
|
# Check if session is idle (not used for a while)
|
|
50
79
|
def idle?(timeout_seconds)
|
|
51
80
|
Time.now - @last_used_at > timeout_seconds
|
|
@@ -56,6 +85,7 @@ module FerrumMCP
|
|
|
56
85
|
{
|
|
57
86
|
id: @id,
|
|
58
87
|
active: active?,
|
|
88
|
+
closed: closed?,
|
|
59
89
|
created_at: @created_at.iso8601,
|
|
60
90
|
last_used_at: @last_used_at.iso8601,
|
|
61
91
|
idle_seconds: (Time.now - @last_used_at).to_i,
|
|
@@ -85,6 +115,18 @@ module FerrumMCP
|
|
|
85
115
|
|
|
86
116
|
private
|
|
87
117
|
|
|
118
|
+
def logger
|
|
119
|
+
@config.logger
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Caller must hold @mutex
|
|
123
|
+
def start_browser
|
|
124
|
+
raise SessionError, "Session #{@id} is closed" if @closed
|
|
125
|
+
|
|
126
|
+
@browser_manager.start unless @browser_manager.active?
|
|
127
|
+
@last_used_at = Time.now
|
|
128
|
+
end
|
|
129
|
+
|
|
88
130
|
def normalize_options(options)
|
|
89
131
|
{
|
|
90
132
|
browser_id: options[:browser_id] || options['browser_id'],
|
|
@@ -157,6 +199,21 @@ module FerrumMCP
|
|
|
157
199
|
@base_config.logger
|
|
158
200
|
end
|
|
159
201
|
|
|
202
|
+
def url_policy
|
|
203
|
+
@base_config.url_policy
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def upload_allowed_dirs
|
|
207
|
+
@base_config.upload_allowed_dirs
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Browser flags handed to Ferrum: defaults merged with session-specific
|
|
211
|
+
# options. Ferrum prefixes every key with "--" itself, so keys are stored
|
|
212
|
+
# without dashes ("window-size", not "--window-size").
|
|
213
|
+
def merged_browser_options
|
|
214
|
+
default_browser_options.merge(normalized_browser_options)
|
|
215
|
+
end
|
|
216
|
+
|
|
160
217
|
private
|
|
161
218
|
|
|
162
219
|
def resolve_browser(overrides, base_config)
|
|
@@ -200,10 +257,10 @@ module FerrumMCP
|
|
|
200
257
|
end
|
|
201
258
|
end
|
|
202
259
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
260
|
+
def normalized_browser_options
|
|
261
|
+
@browser_options.to_h.transform_keys do |key|
|
|
262
|
+
key.to_s.sub(/\A-+/, '')
|
|
263
|
+
end
|
|
207
264
|
end
|
|
208
265
|
|
|
209
266
|
def default_browser_options
|
|
@@ -215,6 +272,7 @@ module FerrumMCP
|
|
|
215
272
|
}
|
|
216
273
|
|
|
217
274
|
options['disable-setuid-sandbox'] = nil if ENV['CI']
|
|
275
|
+
options['user-data-dir'] = user_profile.path if user_profile
|
|
218
276
|
|
|
219
277
|
# Add BotBrowser profile if configured
|
|
220
278
|
if using_botbrowser? && botbrowser_profile && File.exist?(botbrowser_profile)
|
|
@@ -62,17 +62,15 @@ module FerrumMCP
|
|
|
62
62
|
# Close a specific session
|
|
63
63
|
# @param session_id [String] Session ID
|
|
64
64
|
# @return [Boolean] Success
|
|
65
|
-
def close_session(session_id)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
true
|
|
75
|
-
end
|
|
65
|
+
def close_session(session_id) # rubocop:disable Naming/PredicateMethod
|
|
66
|
+
# Remove under the global lock, stop outside of it: stopping Chrome can
|
|
67
|
+
# take seconds and must not block every other session meanwhile.
|
|
68
|
+
session = @mutex.synchronize { @sessions.delete(session_id) }
|
|
69
|
+
return false unless session
|
|
70
|
+
|
|
71
|
+
logger.info "Closing session #{session_id}"
|
|
72
|
+
session.close
|
|
73
|
+
true
|
|
76
74
|
end
|
|
77
75
|
|
|
78
76
|
# List all active sessions
|
|
@@ -91,11 +89,13 @@ module FerrumMCP
|
|
|
91
89
|
|
|
92
90
|
# Close all sessions
|
|
93
91
|
def close_all_sessions
|
|
94
|
-
@mutex.synchronize do
|
|
92
|
+
sessions = @mutex.synchronize do
|
|
95
93
|
logger.info "Closing all #{@sessions.size} sessions"
|
|
96
|
-
@sessions.
|
|
94
|
+
removed = @sessions.values
|
|
97
95
|
@sessions.clear
|
|
96
|
+
removed
|
|
98
97
|
end
|
|
98
|
+
sessions.each(&:close)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# Execute a block with a session (thread-safe)
|
|
@@ -107,8 +107,8 @@ module FerrumMCP
|
|
|
107
107
|
session = get_session(session_id)
|
|
108
108
|
raise SessionError, "Session not found: #{session_id}" unless session
|
|
109
109
|
|
|
110
|
-
# Start browser if
|
|
111
|
-
session.
|
|
110
|
+
# Start the browser if needed (or restart it if its process died)
|
|
111
|
+
session.ensure_started
|
|
112
112
|
|
|
113
113
|
# Execute with thread-safe access
|
|
114
114
|
session.with_browser(&)
|