ferrum-mcp 1.0.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 +7 -0
- data/.env.example +90 -0
- data/CHANGELOG.md +229 -0
- data/CONTRIBUTING.md +469 -0
- data/LICENSE +21 -0
- data/README.md +334 -0
- data/SECURITY.md +286 -0
- data/bin/ferrum-mcp +66 -0
- data/bin/lint +10 -0
- data/bin/serve +3 -0
- data/bin/test +4 -0
- data/docs/API_REFERENCE.md +1410 -0
- data/docs/CONFIGURATION.md +254 -0
- data/docs/DEPLOYMENT.md +846 -0
- data/docs/DOCKER.md +836 -0
- data/docs/DOCKER_BOTBROWSER.md +455 -0
- data/docs/GETTING_STARTED.md +249 -0
- data/docs/TROUBLESHOOTING.md +677 -0
- data/lib/ferrum_mcp/browser_manager.rb +101 -0
- data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
- data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
- data/lib/ferrum_mcp/configuration.rb +229 -0
- data/lib/ferrum_mcp/resource_manager.rb +223 -0
- data/lib/ferrum_mcp/server.rb +254 -0
- data/lib/ferrum_mcp/session.rb +227 -0
- data/lib/ferrum_mcp/session_manager.rb +183 -0
- data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
- data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
- data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
- data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
- data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
- data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
- data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
- data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
- data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
- data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
- data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
- data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
- data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
- data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
- data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
- data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
- data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
- data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
- data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
- data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
- data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
- data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
- data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
- data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
- data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
- data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
- data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
- data/lib/ferrum_mcp/transport/http_server.rb +93 -0
- data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
- data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
- data/lib/ferrum_mcp/version.rb +5 -0
- data/lib/ferrum_mcp/whisper_service.rb +222 -0
- data/lib/ferrum_mcp.rb +35 -0
- metadata +248 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
# Manages MCP resources for browser configurations, profiles, and capabilities
|
|
5
|
+
class ResourceManager
|
|
6
|
+
attr_reader :config, :logger
|
|
7
|
+
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
@logger = config.logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Get all available resources
|
|
14
|
+
def resources
|
|
15
|
+
@resources ||= build_resources
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Read a specific resource by URI
|
|
19
|
+
def read_resource(uri)
|
|
20
|
+
case uri
|
|
21
|
+
when 'ferrum://browsers'
|
|
22
|
+
read_browsers_resource
|
|
23
|
+
when 'ferrum://user-profiles'
|
|
24
|
+
read_user_profiles_resource
|
|
25
|
+
when 'ferrum://bot-profiles'
|
|
26
|
+
read_bot_profiles_resource
|
|
27
|
+
when 'ferrum://capabilities'
|
|
28
|
+
read_capabilities_resource
|
|
29
|
+
when %r{^ferrum://browsers/(.+)$}
|
|
30
|
+
read_browser_detail(::Regexp.last_match(1))
|
|
31
|
+
when %r{^ferrum://user-profiles/(.+)$}
|
|
32
|
+
read_user_profile_detail(::Regexp.last_match(1))
|
|
33
|
+
when %r{^ferrum://bot-profiles/(.+)$}
|
|
34
|
+
read_bot_profile_detail(::Regexp.last_match(1))
|
|
35
|
+
else
|
|
36
|
+
logger.error "Unknown resource URI: #{uri}"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_resources
|
|
44
|
+
resources = []
|
|
45
|
+
resources.concat(build_browser_resources)
|
|
46
|
+
resources.concat(build_user_profile_resources)
|
|
47
|
+
resources.concat(build_bot_profile_resources)
|
|
48
|
+
resources << build_capabilities_resource
|
|
49
|
+
resources
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_browser_resources
|
|
53
|
+
resources = [create_resource('ferrum://browsers', 'available-browsers',
|
|
54
|
+
'List of all available browser configurations')]
|
|
55
|
+
|
|
56
|
+
config.browsers.each do |browser|
|
|
57
|
+
resources << create_resource("ferrum://browsers/#{browser.id}", "browser-#{browser.id}",
|
|
58
|
+
"Configuration for #{browser.name}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
resources
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_user_profile_resources
|
|
65
|
+
resources = [create_resource('ferrum://user-profiles', 'user-profiles',
|
|
66
|
+
'List of all available Chrome user profiles')]
|
|
67
|
+
|
|
68
|
+
config.user_profiles.each do |profile|
|
|
69
|
+
resources << create_resource("ferrum://user-profiles/#{profile.id}", "user-profile-#{profile.id}",
|
|
70
|
+
"Details for user profile: #{profile.name}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
resources
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_bot_profile_resources
|
|
77
|
+
resources = [create_resource('ferrum://bot-profiles', 'bot-profiles',
|
|
78
|
+
'List of all available BotBrowser profiles')]
|
|
79
|
+
|
|
80
|
+
config.bot_profiles.each do |profile|
|
|
81
|
+
resources << create_resource("ferrum://bot-profiles/#{profile.id}", "bot-profile-#{profile.id}",
|
|
82
|
+
"Details for BotBrowser profile: #{profile.name}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
resources
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_capabilities_resource
|
|
89
|
+
create_resource('ferrum://capabilities', 'server-capabilities',
|
|
90
|
+
'Server capabilities and feature flags')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_resource(uri, name, description)
|
|
94
|
+
MCP::Resource.new(
|
|
95
|
+
uri: uri,
|
|
96
|
+
name: name,
|
|
97
|
+
description: description,
|
|
98
|
+
mime_type: 'application/json'
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read_browsers_resource
|
|
103
|
+
{
|
|
104
|
+
uri: 'ferrum://browsers',
|
|
105
|
+
mimeType: 'application/json',
|
|
106
|
+
text: JSON.pretty_generate({
|
|
107
|
+
browsers: config.browsers.map(&:to_h),
|
|
108
|
+
default: config.default_browser&.id,
|
|
109
|
+
total: config.browsers.count
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read_browser_detail(browser_id)
|
|
115
|
+
browser = config.find_browser(browser_id)
|
|
116
|
+
return nil unless browser
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
uri: "ferrum://browsers/#{browser_id}",
|
|
120
|
+
mimeType: 'application/json',
|
|
121
|
+
text: JSON.pretty_generate(browser.to_h.merge(
|
|
122
|
+
is_default: browser == config.default_browser,
|
|
123
|
+
exists: browser.path.nil? || File.exist?(browser.path),
|
|
124
|
+
usage: {
|
|
125
|
+
session_param: 'browser_id',
|
|
126
|
+
example: "create_session(browser_id: '#{browser.id}')"
|
|
127
|
+
}
|
|
128
|
+
))
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_user_profiles_resource
|
|
133
|
+
{
|
|
134
|
+
uri: 'ferrum://user-profiles',
|
|
135
|
+
mimeType: 'application/json',
|
|
136
|
+
text: JSON.pretty_generate({
|
|
137
|
+
profiles: config.user_profiles.map(&:to_h),
|
|
138
|
+
total: config.user_profiles.count,
|
|
139
|
+
note: 'User profiles are standard Chrome user data directories'
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def read_user_profile_detail(profile_id)
|
|
145
|
+
profile = config.find_user_profile(profile_id)
|
|
146
|
+
return nil unless profile
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
uri: "ferrum://user-profiles/#{profile_id}",
|
|
150
|
+
mimeType: 'application/json',
|
|
151
|
+
text: JSON.pretty_generate(profile.to_h.merge(
|
|
152
|
+
exists: File.directory?(profile.path),
|
|
153
|
+
usage: {
|
|
154
|
+
session_param: 'user_profile_id',
|
|
155
|
+
example: "create_session(user_profile_id: '#{profile.id}')"
|
|
156
|
+
}
|
|
157
|
+
))
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def read_bot_profiles_resource
|
|
162
|
+
{
|
|
163
|
+
uri: 'ferrum://bot-profiles',
|
|
164
|
+
mimeType: 'application/json',
|
|
165
|
+
text: JSON.pretty_generate({
|
|
166
|
+
profiles: config.bot_profiles.map(&:to_h),
|
|
167
|
+
total: config.bot_profiles.count,
|
|
168
|
+
note: 'BotBrowser profiles contain anti-detection fingerprints',
|
|
169
|
+
using_botbrowser: config.using_botbrowser?
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def read_bot_profile_detail(profile_id)
|
|
175
|
+
profile = config.find_bot_profile(profile_id)
|
|
176
|
+
return nil unless profile
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
uri: "ferrum://bot-profiles/#{profile_id}",
|
|
180
|
+
mimeType: 'application/json',
|
|
181
|
+
text: JSON.pretty_generate(profile.to_h.merge(
|
|
182
|
+
exists: File.exist?(profile.path),
|
|
183
|
+
usage: {
|
|
184
|
+
session_param: 'bot_profile_id',
|
|
185
|
+
example: "create_session(bot_profile_id: '#{profile.id}')"
|
|
186
|
+
},
|
|
187
|
+
features: %w[
|
|
188
|
+
canvas_fingerprinting
|
|
189
|
+
webgl_protection
|
|
190
|
+
audio_context_hardening
|
|
191
|
+
webrtc_leak_prevention
|
|
192
|
+
]
|
|
193
|
+
))
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def read_capabilities_resource
|
|
198
|
+
{
|
|
199
|
+
uri: 'ferrum://capabilities',
|
|
200
|
+
mimeType: 'application/json',
|
|
201
|
+
text: JSON.pretty_generate({
|
|
202
|
+
version: FerrumMCP::VERSION,
|
|
203
|
+
features: {
|
|
204
|
+
multi_browser: config.browsers.count > 1,
|
|
205
|
+
user_profiles: config.user_profiles.any?,
|
|
206
|
+
bot_profiles: config.bot_profiles.any?,
|
|
207
|
+
botbrowser_integration: config.using_botbrowser?,
|
|
208
|
+
session_management: true,
|
|
209
|
+
screenshot: true,
|
|
210
|
+
javascript_execution: true,
|
|
211
|
+
cookie_management: true,
|
|
212
|
+
form_interaction: true,
|
|
213
|
+
captcha_solving: true
|
|
214
|
+
},
|
|
215
|
+
transport: config.transport,
|
|
216
|
+
browsers_count: config.browsers.count,
|
|
217
|
+
user_profiles_count: config.user_profiles.count,
|
|
218
|
+
bot_profiles_count: config.bot_profiles.count
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
# Main MCP Server implementation
|
|
5
|
+
class Server
|
|
6
|
+
attr_reader :mcp_server, :session_manager, :resource_manager, :config, :logger
|
|
7
|
+
|
|
8
|
+
TOOL_CLASSES = [
|
|
9
|
+
# Session Management
|
|
10
|
+
Tools::CreateSessionTool,
|
|
11
|
+
Tools::ListSessionsTool,
|
|
12
|
+
Tools::CloseSessionTool,
|
|
13
|
+
Tools::GetSessionInfoTool,
|
|
14
|
+
# Navigation
|
|
15
|
+
Tools::NavigateTool,
|
|
16
|
+
Tools::GoBackTool,
|
|
17
|
+
Tools::GoForwardTool,
|
|
18
|
+
Tools::RefreshTool,
|
|
19
|
+
# Interaction
|
|
20
|
+
Tools::ClickTool,
|
|
21
|
+
Tools::FillFormTool,
|
|
22
|
+
Tools::PressKeyTool,
|
|
23
|
+
Tools::HoverTool,
|
|
24
|
+
Tools::DragAndDropTool,
|
|
25
|
+
Tools::AcceptCookiesTool,
|
|
26
|
+
Tools::SolveCaptchaTool,
|
|
27
|
+
# Extraction
|
|
28
|
+
Tools::GetTextTool,
|
|
29
|
+
Tools::GetHTMLTool,
|
|
30
|
+
Tools::ScreenshotTool,
|
|
31
|
+
Tools::GetTitleTool,
|
|
32
|
+
Tools::GetURLTool,
|
|
33
|
+
Tools::FindByTextTool,
|
|
34
|
+
# Advanced
|
|
35
|
+
Tools::ExecuteScriptTool,
|
|
36
|
+
Tools::EvaluateJSTool,
|
|
37
|
+
Tools::GetCookiesTool,
|
|
38
|
+
Tools::SetCookieTool,
|
|
39
|
+
Tools::ClearCookiesTool,
|
|
40
|
+
Tools::GetAttributeTool,
|
|
41
|
+
Tools::QueryShadowDOMTool
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
def initialize(config = Configuration.new)
|
|
45
|
+
@config = config
|
|
46
|
+
@logger = config.logger
|
|
47
|
+
@session_manager = SessionManager.new(config)
|
|
48
|
+
@resource_manager = ResourceManager.new(config)
|
|
49
|
+
@tool_instances = {}
|
|
50
|
+
@mcp_server = create_mcp_server
|
|
51
|
+
|
|
52
|
+
setup_tools
|
|
53
|
+
setup_resources
|
|
54
|
+
setup_error_handling
|
|
55
|
+
end
|
|
56
|
+
|
|
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
|
+
# Shutdown server and cleanup all sessions
|
|
72
|
+
def shutdown
|
|
73
|
+
logger.info 'Shutting down server...'
|
|
74
|
+
session_manager.shutdown
|
|
75
|
+
logger.info 'Server shutdown complete'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_request(json_request)
|
|
79
|
+
request = JSON.parse(json_request)
|
|
80
|
+
logger.debug "Received request: #{request['method']}"
|
|
81
|
+
|
|
82
|
+
mcp_server.handle_request(request)
|
|
83
|
+
rescue JSON::ParserError => e
|
|
84
|
+
logger.error "Invalid JSON request: #{e.message}"
|
|
85
|
+
error_response('Invalid JSON request')
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
logger.error "Request handling error: #{e.message}"
|
|
88
|
+
logger.error e.backtrace.join("\n")
|
|
89
|
+
error_response(e.message)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def create_mcp_server
|
|
95
|
+
MCP::Server.new(
|
|
96
|
+
name: 'ferrum-browser',
|
|
97
|
+
version: FerrumMCP::VERSION,
|
|
98
|
+
instructions: 'A browser automation server using Ferrum and BotBrowser for web scraping and testing',
|
|
99
|
+
resources: resource_manager.resources
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def setup_tools
|
|
104
|
+
# Capture references to instance variables for use in the block
|
|
105
|
+
server_instance = self
|
|
106
|
+
|
|
107
|
+
TOOL_CLASSES.each do |tool_class|
|
|
108
|
+
mcp_server.define_tool(
|
|
109
|
+
name: tool_class.tool_name,
|
|
110
|
+
description: tool_class.description,
|
|
111
|
+
input_schema: tool_class.input_schema
|
|
112
|
+
) do |**params|
|
|
113
|
+
# Call execute_tool on the server instance
|
|
114
|
+
server_instance.send(:execute_tool, tool_class, params)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
logger.info "Registered #{TOOL_CLASSES.length} tools"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def setup_resources
|
|
122
|
+
# Capture references to instance variables for use in the block
|
|
123
|
+
manager = resource_manager
|
|
124
|
+
|
|
125
|
+
# Define the resources_read handler
|
|
126
|
+
mcp_server.resources_read_handler do |params|
|
|
127
|
+
uri = params[:uri]
|
|
128
|
+
logger.debug "Reading resource: #{uri}"
|
|
129
|
+
|
|
130
|
+
result = manager.read_resource(uri)
|
|
131
|
+
if result
|
|
132
|
+
[result]
|
|
133
|
+
else
|
|
134
|
+
logger.error "Resource not found: #{uri}"
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
logger.info "Registered #{resource_manager.resources.length} resources"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def execute_tool(tool_class, params)
|
|
143
|
+
logger.debug "Executing tool: #{tool_class.tool_name} with params: #{params.inspect}"
|
|
144
|
+
|
|
145
|
+
# Session management tools don't need a browser session
|
|
146
|
+
if session_management_tool?(tool_class)
|
|
147
|
+
logger.debug "Executing session management tool: #{tool_class.tool_name}"
|
|
148
|
+
tool = tool_class.new(session_manager)
|
|
149
|
+
result = tool.execute(params)
|
|
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
|
|
158
|
+
|
|
159
|
+
logger.debug "Using session_id: #{session_id}"
|
|
160
|
+
|
|
161
|
+
# Execute tool within session context
|
|
162
|
+
result = session_manager.with_session(session_id) do |browser_manager|
|
|
163
|
+
logger.debug "Creating tool instance for #{tool_class.tool_name}"
|
|
164
|
+
tool = tool_class.new(browser_manager)
|
|
165
|
+
logger.debug "Calling execute on #{tool_class.tool_name}"
|
|
166
|
+
tool.execute(params)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
logger.debug "Tool #{tool_class.tool_name} result: #{result.inspect}"
|
|
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)
|
|
194
|
+
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
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check if tool is a session management tool
|
|
204
|
+
def session_management_tool?(tool_class)
|
|
205
|
+
[
|
|
206
|
+
Tools::CreateSessionTool,
|
|
207
|
+
Tools::ListSessionsTool,
|
|
208
|
+
Tools::CloseSessionTool,
|
|
209
|
+
Tools::GetSessionInfoTool
|
|
210
|
+
].include?(tool_class)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def setup_error_handling
|
|
214
|
+
MCP.configure do |mcp_config|
|
|
215
|
+
mcp_config.exception_reporter = lambda { |exception, context|
|
|
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
|
+
|
|
232
|
+
mcp_config.instrumentation_callback = lambda { |data|
|
|
233
|
+
logger.debug "MCP Method: #{data[:method]}, Duration: #{data[:duration]}s"
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def error_response(message)
|
|
239
|
+
{
|
|
240
|
+
jsonrpc: '2.0',
|
|
241
|
+
error: {
|
|
242
|
+
code: -32_603,
|
|
243
|
+
message: message
|
|
244
|
+
},
|
|
245
|
+
id: nil
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Helper to create error response for tool execution
|
|
250
|
+
def error_tool_response(message)
|
|
251
|
+
MCP::Tool::Response.new([{ type: 'text', text: message }], error: true)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module FerrumMCP
|
|
6
|
+
# Represents a browser session with its own BrowserManager and configuration
|
|
7
|
+
class Session
|
|
8
|
+
attr_reader :id, :browser_manager, :config, :session_config, :created_at, :last_used_at, :metadata, :options
|
|
9
|
+
|
|
10
|
+
def initialize(config:, options: {})
|
|
11
|
+
@id = SecureRandom.uuid
|
|
12
|
+
@config = config
|
|
13
|
+
@options = normalize_options(options)
|
|
14
|
+
@created_at = Time.now
|
|
15
|
+
@last_used_at = Time.now
|
|
16
|
+
@metadata = options[:metadata] || {}
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@session_config, @browser_manager = create_browser_manager
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a block with thread-safe access to the browser
|
|
22
|
+
def with_browser
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@last_used_at = Time.now
|
|
25
|
+
yield @browser_manager
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if session is active (browser is running)
|
|
30
|
+
def active?
|
|
31
|
+
@browser_manager.active?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Start the browser for this session
|
|
35
|
+
def start
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@browser_manager.start unless @browser_manager.active?
|
|
38
|
+
@last_used_at = Time.now
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Stop the browser for this session
|
|
43
|
+
def stop
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@browser_manager.stop if @browser_manager.active?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if session is idle (not used for a while)
|
|
50
|
+
def idle?(timeout_seconds)
|
|
51
|
+
Time.now - @last_used_at > timeout_seconds
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get session information
|
|
55
|
+
def info
|
|
56
|
+
{
|
|
57
|
+
id: @id,
|
|
58
|
+
active: active?,
|
|
59
|
+
created_at: @created_at.iso8601,
|
|
60
|
+
last_used_at: @last_used_at.iso8601,
|
|
61
|
+
idle_seconds: (Time.now - @last_used_at).to_i,
|
|
62
|
+
metadata: @metadata,
|
|
63
|
+
browser_type: browser_type,
|
|
64
|
+
options: sanitized_options
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get browser type (public method for logging and info)
|
|
69
|
+
def browser_type
|
|
70
|
+
if @session_config.bot_profile
|
|
71
|
+
"BotBrowser (#{@session_config.bot_profile.name})"
|
|
72
|
+
elsif @session_config.browser
|
|
73
|
+
# Check ID first (for system browser), then type
|
|
74
|
+
if @session_config.browser.id == 'system'
|
|
75
|
+
'System Chrome/Chromium'
|
|
76
|
+
elsif @session_config.browser.type == 'botbrowser'
|
|
77
|
+
"BotBrowser (#{@session_config.browser.name})"
|
|
78
|
+
else
|
|
79
|
+
@session_config.browser.name
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
'System Chrome/Chromium'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def normalize_options(options)
|
|
89
|
+
{
|
|
90
|
+
browser_id: options[:browser_id] || options['browser_id'],
|
|
91
|
+
browser_path: options[:browser_path] || options['browser_path'],
|
|
92
|
+
user_profile_id: options[:user_profile_id] || options['user_profile_id'],
|
|
93
|
+
bot_profile_id: options[:bot_profile_id] || options['bot_profile_id'],
|
|
94
|
+
botbrowser_profile: options[:botbrowser_profile] || options['botbrowser_profile'],
|
|
95
|
+
headless: options.fetch(:headless, options.fetch('headless', @config.headless)),
|
|
96
|
+
timeout: options.fetch(:timeout, options.fetch('timeout', @config.timeout)),
|
|
97
|
+
browser_options: options[:browser_options] || options['browser_options'] || {},
|
|
98
|
+
metadata: options[:metadata] || options['metadata'] || {}
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def create_browser_manager
|
|
103
|
+
# Create a custom configuration for this session
|
|
104
|
+
session_config = SessionConfiguration.new(
|
|
105
|
+
base_config: @config,
|
|
106
|
+
overrides: @options
|
|
107
|
+
)
|
|
108
|
+
[session_config, BrowserManager.new(session_config)]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Return sanitized options (without sensitive data)
|
|
112
|
+
def sanitized_options
|
|
113
|
+
@options.except(:metadata)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Session-specific configuration that overrides base configuration
|
|
118
|
+
class SessionConfiguration
|
|
119
|
+
attr_reader :browser, :user_profile, :bot_profile, :headless, :timeout,
|
|
120
|
+
:server_host, :server_port, :log_level, :transport,
|
|
121
|
+
:browser_options
|
|
122
|
+
|
|
123
|
+
def initialize(base_config:, overrides:)
|
|
124
|
+
@base_config = base_config
|
|
125
|
+
@headless = overrides[:headless]
|
|
126
|
+
@timeout = overrides[:timeout]
|
|
127
|
+
@browser_options = overrides[:browser_options] || {}
|
|
128
|
+
@server_host = base_config.server_host
|
|
129
|
+
@server_port = base_config.server_port
|
|
130
|
+
@log_level = base_config.log_level
|
|
131
|
+
@transport = base_config.transport
|
|
132
|
+
|
|
133
|
+
# Resolve browser configuration
|
|
134
|
+
@browser = resolve_browser(overrides, base_config)
|
|
135
|
+
@user_profile = resolve_user_profile(overrides, base_config)
|
|
136
|
+
@bot_profile = resolve_bot_profile(overrides, base_config)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def valid?
|
|
140
|
+
browser&.path.nil? || File.exist?(browser.path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def using_botbrowser?
|
|
144
|
+
browser&.type == 'botbrowser' || !bot_profile.nil?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Legacy compatibility methods
|
|
148
|
+
def browser_path
|
|
149
|
+
browser&.path
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def botbrowser_profile
|
|
153
|
+
bot_profile&.path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def logger
|
|
157
|
+
@base_config.logger
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def resolve_browser(overrides, base_config)
|
|
163
|
+
# Priority: browser_id > browser_path (legacy) > default browser
|
|
164
|
+
if overrides[:browser_id]
|
|
165
|
+
base_config.find_browser(overrides[:browser_id])
|
|
166
|
+
elsif overrides[:browser_path]
|
|
167
|
+
# Legacy: create a temporary browser config
|
|
168
|
+
Configuration::BrowserConfig.new(
|
|
169
|
+
id: 'custom',
|
|
170
|
+
name: 'Custom Browser',
|
|
171
|
+
path: overrides[:browser_path],
|
|
172
|
+
type: overrides[:botbrowser_profile] ? 'botbrowser' : 'chrome',
|
|
173
|
+
description: 'Session-specific browser'
|
|
174
|
+
)
|
|
175
|
+
else
|
|
176
|
+
base_config.default_browser
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def resolve_user_profile(overrides, base_config)
|
|
181
|
+
# Priority: user_profile_id > nil
|
|
182
|
+
return nil unless overrides[:user_profile_id]
|
|
183
|
+
|
|
184
|
+
base_config.find_user_profile(overrides[:user_profile_id])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def resolve_bot_profile(overrides, base_config)
|
|
188
|
+
# Priority: bot_profile_id > botbrowser_profile (legacy) > nil
|
|
189
|
+
if overrides[:bot_profile_id]
|
|
190
|
+
base_config.find_bot_profile(overrides[:bot_profile_id])
|
|
191
|
+
elsif overrides[:botbrowser_profile]
|
|
192
|
+
# Legacy: create a temporary bot profile config
|
|
193
|
+
Configuration::BotProfileConfig.new(
|
|
194
|
+
id: 'custom',
|
|
195
|
+
name: 'Custom Profile',
|
|
196
|
+
path: overrides[:botbrowser_profile],
|
|
197
|
+
encrypted: overrides[:botbrowser_profile].end_with?('.enc'),
|
|
198
|
+
description: 'Session-specific profile'
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Merge session-specific browser options with base options
|
|
204
|
+
def merged_browser_options
|
|
205
|
+
base_options = default_browser_options
|
|
206
|
+
base_options.merge(@browser_options)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def default_browser_options
|
|
210
|
+
options = {
|
|
211
|
+
'no-sandbox' => nil,
|
|
212
|
+
'disable-dev-shm-usage' => nil,
|
|
213
|
+
'disable-blink-features' => 'AutomationControlled',
|
|
214
|
+
'disable-gpu' => nil
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
options['disable-setuid-sandbox'] = nil if ENV['CI']
|
|
218
|
+
|
|
219
|
+
# Add BotBrowser profile if configured
|
|
220
|
+
if using_botbrowser? && botbrowser_profile && File.exist?(botbrowser_profile)
|
|
221
|
+
options['bot-profile'] = botbrowser_profile
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
options
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|