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,528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool to automatically detect and solve audio CAPTCHAs using Whisper
|
|
6
|
+
# Works intelligently without requiring specific selectors
|
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
|
+
class SolveCaptchaTool < BaseTool
|
|
9
|
+
def self.tool_name
|
|
10
|
+
'solve_captcha'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.description
|
|
14
|
+
'Automatically detect and solve audio CAPTCHA challenges using Whisper speech recognition. ' \
|
|
15
|
+
'Intelligently finds reCAPTCHA, hCaptcha, and other audio challenges without manual configuration.'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.input_schema
|
|
19
|
+
{
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
session_id: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Session ID to use for this operation'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
required: %w[session_id]
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Known CAPTCHA checkbox selectors (to trigger challenge)
|
|
32
|
+
CAPTCHA_CHECKBOX_SELECTORS = [
|
|
33
|
+
# reCAPTCHA
|
|
34
|
+
'.recaptcha-checkbox-border',
|
|
35
|
+
'#recaptcha-anchor',
|
|
36
|
+
|
|
37
|
+
# hCaptcha
|
|
38
|
+
'.h-captcha',
|
|
39
|
+
'.checkbox-label'
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# Known CAPTCHA audio button selectors
|
|
43
|
+
AUDIO_BUTTON_SELECTORS = [
|
|
44
|
+
# reCAPTCHA
|
|
45
|
+
'#recaptcha-audio-button',
|
|
46
|
+
'button[aria-labelledby*="audio"]',
|
|
47
|
+
'button[title*="audio" i]',
|
|
48
|
+
|
|
49
|
+
# hCaptcha
|
|
50
|
+
'button[aria-label*="audio" i]',
|
|
51
|
+
|
|
52
|
+
# Generic
|
|
53
|
+
'button[id*="audio" i]',
|
|
54
|
+
'button[class*="audio" i]',
|
|
55
|
+
'[data-action*="audio" i]'
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Known CAPTCHA audio source selectors
|
|
59
|
+
AUDIO_SOURCE_SELECTORS = [
|
|
60
|
+
# reCAPTCHA
|
|
61
|
+
'audio#audio-source',
|
|
62
|
+
'audio source',
|
|
63
|
+
'#audio-source',
|
|
64
|
+
'audio',
|
|
65
|
+
'.rc-audiochallenge-tdownload-link',
|
|
66
|
+
'a.rc-audiochallenge-tdownload-link',
|
|
67
|
+
'.rc-audiochallenge-instructions a',
|
|
68
|
+
|
|
69
|
+
# hCaptcha
|
|
70
|
+
'audio source',
|
|
71
|
+
|
|
72
|
+
# Generic
|
|
73
|
+
'audio[src]',
|
|
74
|
+
'[id*="audio"][src]',
|
|
75
|
+
'[id*="audio"][href]'
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
# Known CAPTCHA input field selectors
|
|
79
|
+
INPUT_FIELD_SELECTORS = [
|
|
80
|
+
# reCAPTCHA
|
|
81
|
+
'#audio-response',
|
|
82
|
+
'input[id*="audio"]',
|
|
83
|
+
'.rc-audiochallenge-response-field',
|
|
84
|
+
|
|
85
|
+
# hCaptcha
|
|
86
|
+
'input[type="text"]',
|
|
87
|
+
'input[data-action*="verify" i]',
|
|
88
|
+
|
|
89
|
+
# Generic
|
|
90
|
+
'input[placeholder*="hear" i]',
|
|
91
|
+
'input[aria-label*="audio" i]'
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
# Known CAPTCHA verify/submit button selectors
|
|
95
|
+
VERIFY_BUTTON_SELECTORS = [
|
|
96
|
+
# reCAPTCHA
|
|
97
|
+
'#recaptcha-verify-button',
|
|
98
|
+
'button[id*="verify" i]',
|
|
99
|
+
'.rc-button-default',
|
|
100
|
+
|
|
101
|
+
# hCaptcha
|
|
102
|
+
'button[type="submit"]',
|
|
103
|
+
'button[data-action*="verify" i]',
|
|
104
|
+
|
|
105
|
+
# Generic
|
|
106
|
+
'button[class*="submit" i]',
|
|
107
|
+
'button[value*="verify" i]'
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
def execute(_params)
|
|
111
|
+
ensure_browser_active
|
|
112
|
+
|
|
113
|
+
logger.info 'Starting intelligent CAPTCHA detection and solving...'
|
|
114
|
+
|
|
115
|
+
# Initialize Whisper service
|
|
116
|
+
whisper = WhisperService.new(logger: logger)
|
|
117
|
+
|
|
118
|
+
# Wait for page to load with random delay
|
|
119
|
+
random_sleep(1.5, 2.5)
|
|
120
|
+
|
|
121
|
+
# Step 1: Click CAPTCHA checkbox to trigger challenge
|
|
122
|
+
logger.info 'Detecting CAPTCHA checkbox...'
|
|
123
|
+
checkbox_info = detect_and_click_checkbox
|
|
124
|
+
if checkbox_info[:found]
|
|
125
|
+
logger.info "Found checkbox: #{checkbox_info[:selector]}"
|
|
126
|
+
random_sleep(2, 3) # Wait for challenge to appear with random delay
|
|
127
|
+
else
|
|
128
|
+
logger.info 'No checkbox found, assuming challenge already visible'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Step 2: Detect CAPTCHA and click audio button
|
|
132
|
+
logger.info 'Detecting CAPTCHA audio button...'
|
|
133
|
+
audio_button_info = detect_and_click_audio_button
|
|
134
|
+
return error_response('No CAPTCHA audio button found') unless audio_button_info[:found]
|
|
135
|
+
|
|
136
|
+
logger.info "Found audio button: #{audio_button_info[:selector]}"
|
|
137
|
+
|
|
138
|
+
# Wait for audio challenge to load with random delay
|
|
139
|
+
random_sleep(2, 3)
|
|
140
|
+
|
|
141
|
+
# Step 2: Detect and get audio URL
|
|
142
|
+
logger.info 'Detecting audio source...'
|
|
143
|
+
audio_url = detect_audio_source
|
|
144
|
+
return error_response('No audio source found') unless audio_url
|
|
145
|
+
|
|
146
|
+
logger.info "Found audio URL: #{audio_url[0..50]}..."
|
|
147
|
+
|
|
148
|
+
# Step 3: Download audio using Whisper service
|
|
149
|
+
logger.info 'Downloading audio challenge...'
|
|
150
|
+
audio_file = whisper.download_audio(browser, audio_url)
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
# Step 4: Transcribe with Whisper service
|
|
154
|
+
logger.info 'Transcribing with Whisper...'
|
|
155
|
+
transcription = whisper.transcribe(audio_file.path)
|
|
156
|
+
logger.info "Transcription: #{transcription}"
|
|
157
|
+
|
|
158
|
+
# Step 5: Detect and fill input field
|
|
159
|
+
logger.info 'Detecting input field...'
|
|
160
|
+
input_info = detect_and_fill_input(transcription)
|
|
161
|
+
return error_response('No input field found') unless input_info[:found]
|
|
162
|
+
|
|
163
|
+
logger.info "Filled input: #{input_info[:selector]}"
|
|
164
|
+
|
|
165
|
+
# Step 6: Detect and click verify button
|
|
166
|
+
logger.info 'Detecting verify button...'
|
|
167
|
+
verify_info = detect_and_click_verify
|
|
168
|
+
return error_response('No verify button found') unless verify_info[:found]
|
|
169
|
+
|
|
170
|
+
logger.info "Clicked verify: #{verify_info[:selector]}"
|
|
171
|
+
|
|
172
|
+
# Wait for verification with random delay
|
|
173
|
+
random_sleep(1.5, 2.5)
|
|
174
|
+
|
|
175
|
+
success_response(
|
|
176
|
+
message: 'CAPTCHA solved successfully',
|
|
177
|
+
transcription: transcription,
|
|
178
|
+
audio_button: audio_button_info[:selector],
|
|
179
|
+
input_field: input_info[:selector],
|
|
180
|
+
verify_button: verify_info[:selector]
|
|
181
|
+
)
|
|
182
|
+
ensure
|
|
183
|
+
cleanup_audio_file(audio_file)
|
|
184
|
+
end
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
logger.error "CAPTCHA solving failed: #{e.message}"
|
|
187
|
+
logger.error e.backtrace.first(5).join("\n")
|
|
188
|
+
error_response("Failed to solve CAPTCHA: #{e.message}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Detect and click CAPTCHA checkbox to trigger challenge
|
|
194
|
+
def detect_and_click_checkbox
|
|
195
|
+
# Try known checkbox selectors
|
|
196
|
+
CAPTCHA_CHECKBOX_SELECTORS.each do |selector|
|
|
197
|
+
element = browser.at_css(selector)
|
|
198
|
+
next unless element
|
|
199
|
+
next unless element_visible?(element)
|
|
200
|
+
|
|
201
|
+
if click_element(element)
|
|
202
|
+
logger.info "Clicked checkbox: #{selector}"
|
|
203
|
+
return { found: true, selector: selector }
|
|
204
|
+
end
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
logger.debug "Checkbox selector '#{selector}' failed: #{e.message}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Try in iframes
|
|
210
|
+
frames = browser.frames
|
|
211
|
+
if frames.length > 1
|
|
212
|
+
frames[1..].each_with_index do |frame, index|
|
|
213
|
+
CAPTCHA_CHECKBOX_SELECTORS.each do |selector|
|
|
214
|
+
element = frame.at_css(selector)
|
|
215
|
+
next unless element
|
|
216
|
+
|
|
217
|
+
if click_element(element)
|
|
218
|
+
logger.info "Clicked checkbox in iframe: #{selector}"
|
|
219
|
+
return { found: true, selector: "iframe[#{index}] > #{selector}" }
|
|
220
|
+
end
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
logger.debug "Iframe checkbox '#{selector}' failed: #{e.message}"
|
|
223
|
+
end
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
logger.debug "Cannot access iframe #{index}: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
{ found: false }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Detect and click audio button using multiple strategies
|
|
233
|
+
def detect_and_click_audio_button
|
|
234
|
+
# Strategy 1: Try known selectors
|
|
235
|
+
AUDIO_BUTTON_SELECTORS.each do |selector|
|
|
236
|
+
element = browser.at_css(selector)
|
|
237
|
+
next unless element
|
|
238
|
+
next unless element_visible?(element)
|
|
239
|
+
|
|
240
|
+
if click_element(element)
|
|
241
|
+
logger.info "Clicked audio button: #{selector}"
|
|
242
|
+
return { found: true, selector: selector }
|
|
243
|
+
end
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
logger.debug "Selector '#{selector}' failed: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Strategy 2: Try text-based detection
|
|
249
|
+
audio_patterns = ['audio challenge', 'audio', 'listen', 'get an audio challenge']
|
|
250
|
+
|
|
251
|
+
audio_patterns.each do |pattern|
|
|
252
|
+
elements = find_elements_by_text(pattern, tag: 'button')
|
|
253
|
+
element = elements.find { |el| element_visible?(el) }
|
|
254
|
+
next unless element
|
|
255
|
+
|
|
256
|
+
if click_element(element)
|
|
257
|
+
logger.info "Clicked audio button by text: #{pattern}"
|
|
258
|
+
return { found: true, selector: "button[text*='#{pattern}']" }
|
|
259
|
+
end
|
|
260
|
+
rescue StandardError => e
|
|
261
|
+
logger.debug "Text pattern '#{pattern}' failed: #{e.message}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Strategy 3: Try iframes
|
|
265
|
+
result = try_audio_button_in_iframes
|
|
266
|
+
return result if result[:found]
|
|
267
|
+
|
|
268
|
+
{ found: false }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Try to find audio button in iframes
|
|
272
|
+
def try_audio_button_in_iframes
|
|
273
|
+
frames = browser.frames
|
|
274
|
+
return { found: false } if frames.length <= 1
|
|
275
|
+
|
|
276
|
+
frames[1..].each_with_index do |frame, index|
|
|
277
|
+
AUDIO_BUTTON_SELECTORS.each do |selector|
|
|
278
|
+
element = frame.at_css(selector)
|
|
279
|
+
next unless element
|
|
280
|
+
|
|
281
|
+
if click_element(element)
|
|
282
|
+
logger.info "Clicked audio button in iframe: #{selector}"
|
|
283
|
+
return { found: true, selector: "iframe[#{index}] > #{selector}" }
|
|
284
|
+
end
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
logger.debug "Iframe selector '#{selector}' failed: #{e.message}"
|
|
287
|
+
end
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
logger.debug "Cannot access iframe #{index}: #{e.message}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
{ found: false }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Detect audio source URL
|
|
296
|
+
def detect_audio_source
|
|
297
|
+
logger.debug 'Trying to find audio source in main frame...'
|
|
298
|
+
|
|
299
|
+
# Try known selectors
|
|
300
|
+
AUDIO_SOURCE_SELECTORS.each do |selector|
|
|
301
|
+
element = browser.at_css(selector)
|
|
302
|
+
if element
|
|
303
|
+
logger.debug "Found element with selector: #{selector}"
|
|
304
|
+
else
|
|
305
|
+
logger.debug "No element found for selector: #{selector}"
|
|
306
|
+
next
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Try src attribute
|
|
310
|
+
url = element.attribute('src') || element.property('src')
|
|
311
|
+
if url && !url.empty?
|
|
312
|
+
logger.info "Found audio URL via src: #{url[0..50]}..."
|
|
313
|
+
return url
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Try href for download links
|
|
317
|
+
url = element.attribute('href') || element.property('href')
|
|
318
|
+
if url && !url.empty?
|
|
319
|
+
logger.info "Found audio URL via href: #{url[0..50]}..."
|
|
320
|
+
return url
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
logger.debug 'Element found but no src/href attribute'
|
|
324
|
+
rescue StandardError => e
|
|
325
|
+
logger.debug "Audio source selector '#{selector}' failed: #{e.message}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Try finding in iframes
|
|
329
|
+
logger.debug 'Trying to find audio source in iframes...'
|
|
330
|
+
frames = browser.frames
|
|
331
|
+
logger.debug "Found #{frames.length} frames"
|
|
332
|
+
|
|
333
|
+
if frames.length > 1
|
|
334
|
+
frames[1..].each_with_index do |frame, index|
|
|
335
|
+
logger.debug "Checking iframe #{index + 1}..."
|
|
336
|
+
AUDIO_SOURCE_SELECTORS.each do |selector|
|
|
337
|
+
element = frame.at_css(selector)
|
|
338
|
+
if element
|
|
339
|
+
logger.debug "Found element in iframe with selector: #{selector}"
|
|
340
|
+
url = element.attribute('src') || element.property('src')
|
|
341
|
+
if url && !url.empty?
|
|
342
|
+
logger.info "Found audio URL in iframe via src: #{url[0..50]}..."
|
|
343
|
+
return url
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
rescue StandardError => e
|
|
347
|
+
logger.debug "Iframe audio source '#{selector}' failed: #{e.message}"
|
|
348
|
+
end
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
logger.debug "Cannot access iframe #{index + 1}: #{e.message}"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
logger.error 'No audio source found after trying all strategies'
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Detect and fill input field
|
|
359
|
+
def detect_and_fill_input(text)
|
|
360
|
+
INPUT_FIELD_SELECTORS.each do |selector|
|
|
361
|
+
element = browser.at_css(selector)
|
|
362
|
+
next unless element
|
|
363
|
+
next unless element_visible?(element)
|
|
364
|
+
|
|
365
|
+
with_retry do
|
|
366
|
+
element.focus
|
|
367
|
+
random_sleep(0.1, 0.2)
|
|
368
|
+
# Type each character with small random delays to mimic human typing
|
|
369
|
+
type_like_human(element, text)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
logger.info "Filled input: #{selector}"
|
|
373
|
+
return { found: true, selector: selector }
|
|
374
|
+
rescue StandardError => e
|
|
375
|
+
logger.debug "Input selector '#{selector}' failed: #{e.message}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Try in iframes
|
|
379
|
+
frames = browser.frames
|
|
380
|
+
if frames.length > 1
|
|
381
|
+
frames[1..].each_with_index do |frame, index|
|
|
382
|
+
INPUT_FIELD_SELECTORS.each do |selector|
|
|
383
|
+
element = frame.at_css(selector)
|
|
384
|
+
next unless element
|
|
385
|
+
|
|
386
|
+
element.focus
|
|
387
|
+
random_sleep(0.1, 0.2)
|
|
388
|
+
type_like_human(element, text)
|
|
389
|
+
|
|
390
|
+
logger.info "Filled input in iframe: #{selector}"
|
|
391
|
+
return { found: true, selector: "iframe[#{index}] > #{selector}" }
|
|
392
|
+
rescue StandardError => e
|
|
393
|
+
logger.debug "Iframe input '#{selector}' failed: #{e.message}"
|
|
394
|
+
end
|
|
395
|
+
rescue StandardError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
{ found: false }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Detect and click verify button
|
|
404
|
+
def detect_and_click_verify
|
|
405
|
+
VERIFY_BUTTON_SELECTORS.each do |selector|
|
|
406
|
+
element = browser.at_css(selector)
|
|
407
|
+
next unless element
|
|
408
|
+
next unless element_visible?(element)
|
|
409
|
+
|
|
410
|
+
if click_element(element)
|
|
411
|
+
logger.info "Clicked verify: #{selector}"
|
|
412
|
+
return { found: true, selector: selector }
|
|
413
|
+
end
|
|
414
|
+
rescue StandardError => e
|
|
415
|
+
logger.debug "Verify selector '#{selector}' failed: #{e.message}"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Try text-based detection
|
|
419
|
+
verify_patterns = %w[verify submit check]
|
|
420
|
+
verify_patterns.each do |pattern|
|
|
421
|
+
elements = find_elements_by_text(pattern, tag: 'button')
|
|
422
|
+
element = elements.find { |el| element_visible?(el) }
|
|
423
|
+
next unless element
|
|
424
|
+
|
|
425
|
+
if click_element(element)
|
|
426
|
+
logger.info "Clicked verify by text: #{pattern}"
|
|
427
|
+
return { found: true, selector: "button[text*='#{pattern}']" }
|
|
428
|
+
end
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
logger.debug "Verify pattern '#{pattern}' failed: #{e.message}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Try in iframes
|
|
434
|
+
frames = browser.frames
|
|
435
|
+
if frames.length > 1
|
|
436
|
+
frames[1..].each_with_index do |frame, index|
|
|
437
|
+
VERIFY_BUTTON_SELECTORS.each do |selector|
|
|
438
|
+
element = frame.at_css(selector)
|
|
439
|
+
next unless element
|
|
440
|
+
|
|
441
|
+
if click_element(element)
|
|
442
|
+
logger.info "Clicked verify in iframe: #{selector}"
|
|
443
|
+
return { found: true, selector: "iframe[#{index}] > #{selector}" }
|
|
444
|
+
end
|
|
445
|
+
rescue StandardError => e
|
|
446
|
+
logger.debug "Iframe verify '#{selector}' failed: #{e.message}"
|
|
447
|
+
end
|
|
448
|
+
rescue StandardError
|
|
449
|
+
nil
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
{ found: false }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Helper: Find elements by text
|
|
457
|
+
def find_elements_by_text(text, tag: '*')
|
|
458
|
+
escaped = escape_xpath_string(text)
|
|
459
|
+
xpath = "//#{tag}[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', " \
|
|
460
|
+
"'abcdefghijklmnopqrstuvwxyz'), #{escaped})]"
|
|
461
|
+
|
|
462
|
+
browser.xpath(xpath)
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
logger.debug "XPath search for '#{text}' failed: #{e.message}"
|
|
465
|
+
[]
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Helper: Escape XPath string
|
|
469
|
+
def escape_xpath_string(text)
|
|
470
|
+
return "'#{text.downcase}'" unless text.include?("'")
|
|
471
|
+
|
|
472
|
+
parts = text.downcase.split("'")
|
|
473
|
+
quoted_parts = parts.map { |part| "'#{part}'" }
|
|
474
|
+
"concat(#{quoted_parts.join(", \"'\", ")})"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Helper: Random sleep to mimic human behavior
|
|
478
|
+
def random_sleep(min, max)
|
|
479
|
+
sleep(rand(min..max))
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Helper: Type text like a human with random delays between characters
|
|
483
|
+
def type_like_human(element, text)
|
|
484
|
+
text.each_char do |char|
|
|
485
|
+
element.type(char)
|
|
486
|
+
sleep(rand(0.05..0.15)) # Random delay between keystrokes
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Helper: Click element with fallback and human-like delay
|
|
491
|
+
def click_element(element)
|
|
492
|
+
return false unless element
|
|
493
|
+
|
|
494
|
+
element.scroll_into_view if element.respond_to?(:scroll_into_view)
|
|
495
|
+
random_sleep(0.1, 0.3) # Random delay before click
|
|
496
|
+
element.click
|
|
497
|
+
random_sleep(0.2, 0.4) # Random delay after click
|
|
498
|
+
true
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
logger.debug "Native click failed: #{e.message}, trying JavaScript..."
|
|
501
|
+
|
|
502
|
+
begin
|
|
503
|
+
browser.execute(<<~JAVASCRIPT, element)
|
|
504
|
+
arguments[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
505
|
+
setTimeout(() => arguments[0].click(), 100);
|
|
506
|
+
JAVASCRIPT
|
|
507
|
+
random_sleep(0.3, 0.5)
|
|
508
|
+
true
|
|
509
|
+
rescue StandardError => js_error
|
|
510
|
+
logger.debug "JavaScript click failed: #{js_error.message}"
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Helper: Cleanup temp file
|
|
516
|
+
def cleanup_audio_file(temp_file)
|
|
517
|
+
return unless temp_file
|
|
518
|
+
|
|
519
|
+
temp_file.close unless temp_file.closed?
|
|
520
|
+
temp_file.unlink
|
|
521
|
+
logger.debug 'Audio file cleaned up'
|
|
522
|
+
rescue StandardError => e
|
|
523
|
+
logger.warn "Cleanup failed: #{e.message}"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
# rubocop:enable Metrics/ClassLength
|
|
527
|
+
end
|
|
528
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module FerrumMCP
|
|
7
|
+
module Transport
|
|
8
|
+
# HTTP Server with MCP StreamableHTTPTransport
|
|
9
|
+
class HTTPServer
|
|
10
|
+
attr_reader :server, :config, :logger, :mcp_transport
|
|
11
|
+
|
|
12
|
+
def initialize(server, config)
|
|
13
|
+
@server = server
|
|
14
|
+
@config = config
|
|
15
|
+
@logger = config.logger
|
|
16
|
+
@mcp_transport = MCP::Server::Transports::StreamableHTTPTransport.new(server.mcp_server)
|
|
17
|
+
server.mcp_server.transport = @mcp_transport
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def app
|
|
21
|
+
mcp_transport = @mcp_transport
|
|
22
|
+
logger = @logger
|
|
23
|
+
config = @config
|
|
24
|
+
|
|
25
|
+
Rack::Builder.app do
|
|
26
|
+
use Rack::CommonLogger, logger
|
|
27
|
+
|
|
28
|
+
# Add rate limiting middleware if enabled
|
|
29
|
+
if config.rate_limit_enabled
|
|
30
|
+
use FerrumMCP::Transport::RateLimiter,
|
|
31
|
+
max_requests: config.rate_limit_max_requests,
|
|
32
|
+
window: config.rate_limit_window
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Health check endpoint
|
|
36
|
+
map '/health' do
|
|
37
|
+
run lambda { |_env|
|
|
38
|
+
[200, { 'Content-Type' => 'application/json' }, [JSON.generate({ status: 'ok' })]]
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Root endpoint
|
|
43
|
+
map '/' do
|
|
44
|
+
run lambda { |_env|
|
|
45
|
+
body = {
|
|
46
|
+
name: 'Ferrum MCP Server',
|
|
47
|
+
version: FerrumMCP::VERSION,
|
|
48
|
+
endpoints: {
|
|
49
|
+
mcp: '/mcp',
|
|
50
|
+
health: '/health'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
[200, { 'Content-Type' => 'application/json' }, [JSON.generate(body)]]
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# MCP endpoint - use StreamableHTTPTransport
|
|
58
|
+
map '/mcp' do
|
|
59
|
+
run lambda { |env|
|
|
60
|
+
request = Rack::Request.new(env)
|
|
61
|
+
mcp_transport.handle_request(request)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start
|
|
68
|
+
require 'puma'
|
|
69
|
+
|
|
70
|
+
logger.info "Starting HTTP server on #{config.server_host}:#{config.server_port}"
|
|
71
|
+
|
|
72
|
+
@puma_server = Puma::Server.new(app)
|
|
73
|
+
@puma_server.add_tcp_listener(config.server_host, config.server_port)
|
|
74
|
+
|
|
75
|
+
@puma_thread = Thread.new do
|
|
76
|
+
@puma_server.run.join
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sleep 0.5
|
|
80
|
+
|
|
81
|
+
logger.info 'HTTP server started'
|
|
82
|
+
logger.info "MCP endpoint: http://#{config.server_host}:#{config.server_port}/mcp"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop
|
|
86
|
+
logger.info 'Stopping HTTP server...'
|
|
87
|
+
@puma_server&.stop(true)
|
|
88
|
+
@puma_thread&.join
|
|
89
|
+
logger.info 'HTTP server stopped'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FerrumMCP
|
|
4
|
+
module Transport
|
|
5
|
+
# Simple in-memory rate limiter middleware for Rack
|
|
6
|
+
# Limits requests per IP address within a time window
|
|
7
|
+
class RateLimiter
|
|
8
|
+
def initialize(app, options = {})
|
|
9
|
+
@app = app
|
|
10
|
+
@max_requests = options[:max_requests] || 100
|
|
11
|
+
@window = options[:window] || 60 # seconds
|
|
12
|
+
@requests = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
client_ip = extract_ip(env)
|
|
18
|
+
|
|
19
|
+
return rate_limit_response if rate_limited?(client_ip)
|
|
20
|
+
|
|
21
|
+
track_request(client_ip)
|
|
22
|
+
@app.call(env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def extract_ip(env)
|
|
28
|
+
# Check X-Forwarded-For header first (for proxies/load balancers)
|
|
29
|
+
forwarded = env['HTTP_X_FORWARDED_FOR']
|
|
30
|
+
return forwarded.split(',').first.strip if forwarded
|
|
31
|
+
|
|
32
|
+
# Fall back to REMOTE_ADDR
|
|
33
|
+
env['REMOTE_ADDR'] || 'unknown'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rate_limited?(client_ip)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
cleanup_old_requests
|
|
39
|
+
|
|
40
|
+
request_times = @requests[client_ip] || []
|
|
41
|
+
request_times.length >= @max_requests
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def track_request(client_ip)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@requests[client_ip] ||= []
|
|
48
|
+
@requests[client_ip] << Time.now
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cleanup_old_requests
|
|
53
|
+
cutoff = Time.now - @window
|
|
54
|
+
|
|
55
|
+
@requests.each do |ip, times|
|
|
56
|
+
@requests[ip] = times.select { |t| t > cutoff }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Remove IPs with no recent requests
|
|
60
|
+
@requests.delete_if { |_ip, times| times.empty? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rate_limit_response
|
|
64
|
+
[
|
|
65
|
+
429,
|
|
66
|
+
{
|
|
67
|
+
'Content-Type' => 'application/json',
|
|
68
|
+
'Retry-After' => @window.to_s
|
|
69
|
+
},
|
|
70
|
+
[JSON.generate({
|
|
71
|
+
error: 'Rate limit exceeded',
|
|
72
|
+
message: "Maximum #{@max_requests} requests per #{@window} seconds allowed",
|
|
73
|
+
retry_after: @window
|
|
74
|
+
})]
|
|
75
|
+
]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|