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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +90 -0
  3. data/CHANGELOG.md +229 -0
  4. data/CONTRIBUTING.md +469 -0
  5. data/LICENSE +21 -0
  6. data/README.md +334 -0
  7. data/SECURITY.md +286 -0
  8. data/bin/ferrum-mcp +66 -0
  9. data/bin/lint +10 -0
  10. data/bin/serve +3 -0
  11. data/bin/test +4 -0
  12. data/docs/API_REFERENCE.md +1410 -0
  13. data/docs/CONFIGURATION.md +254 -0
  14. data/docs/DEPLOYMENT.md +846 -0
  15. data/docs/DOCKER.md +836 -0
  16. data/docs/DOCKER_BOTBROWSER.md +455 -0
  17. data/docs/GETTING_STARTED.md +249 -0
  18. data/docs/TROUBLESHOOTING.md +677 -0
  19. data/lib/ferrum_mcp/browser_manager.rb +101 -0
  20. data/lib/ferrum_mcp/cli/command_handler.rb +99 -0
  21. data/lib/ferrum_mcp/cli/server_runner.rb +166 -0
  22. data/lib/ferrum_mcp/configuration.rb +229 -0
  23. data/lib/ferrum_mcp/resource_manager.rb +223 -0
  24. data/lib/ferrum_mcp/server.rb +254 -0
  25. data/lib/ferrum_mcp/session.rb +227 -0
  26. data/lib/ferrum_mcp/session_manager.rb +183 -0
  27. data/lib/ferrum_mcp/tools/accept_cookies_tool.rb +458 -0
  28. data/lib/ferrum_mcp/tools/base_tool.rb +114 -0
  29. data/lib/ferrum_mcp/tools/clear_cookies_tool.rb +66 -0
  30. data/lib/ferrum_mcp/tools/click_tool.rb +218 -0
  31. data/lib/ferrum_mcp/tools/close_session_tool.rb +49 -0
  32. data/lib/ferrum_mcp/tools/create_session_tool.rb +146 -0
  33. data/lib/ferrum_mcp/tools/drag_and_drop_tool.rb +171 -0
  34. data/lib/ferrum_mcp/tools/evaluate_js_tool.rb +46 -0
  35. data/lib/ferrum_mcp/tools/execute_script_tool.rb +48 -0
  36. data/lib/ferrum_mcp/tools/fill_form_tool.rb +78 -0
  37. data/lib/ferrum_mcp/tools/find_by_text_tool.rb +153 -0
  38. data/lib/ferrum_mcp/tools/get_attribute_tool.rb +56 -0
  39. data/lib/ferrum_mcp/tools/get_cookies_tool.rb +70 -0
  40. data/lib/ferrum_mcp/tools/get_html_tool.rb +52 -0
  41. data/lib/ferrum_mcp/tools/get_session_info_tool.rb +40 -0
  42. data/lib/ferrum_mcp/tools/get_text_tool.rb +67 -0
  43. data/lib/ferrum_mcp/tools/get_title_tool.rb +42 -0
  44. data/lib/ferrum_mcp/tools/get_url_tool.rb +39 -0
  45. data/lib/ferrum_mcp/tools/go_back_tool.rb +49 -0
  46. data/lib/ferrum_mcp/tools/go_forward_tool.rb +49 -0
  47. data/lib/ferrum_mcp/tools/hover_tool.rb +76 -0
  48. data/lib/ferrum_mcp/tools/list_sessions_tool.rb +33 -0
  49. data/lib/ferrum_mcp/tools/navigate_tool.rb +59 -0
  50. data/lib/ferrum_mcp/tools/press_key_tool.rb +91 -0
  51. data/lib/ferrum_mcp/tools/query_shadow_dom_tool.rb +225 -0
  52. data/lib/ferrum_mcp/tools/refresh_tool.rb +49 -0
  53. data/lib/ferrum_mcp/tools/screenshot_tool.rb +121 -0
  54. data/lib/ferrum_mcp/tools/session_tool.rb +37 -0
  55. data/lib/ferrum_mcp/tools/set_cookie_tool.rb +77 -0
  56. data/lib/ferrum_mcp/tools/solve_captcha_tool.rb +528 -0
  57. data/lib/ferrum_mcp/transport/http_server.rb +93 -0
  58. data/lib/ferrum_mcp/transport/rate_limiter.rb +79 -0
  59. data/lib/ferrum_mcp/transport/stdio_server.rb +63 -0
  60. data/lib/ferrum_mcp/version.rb +5 -0
  61. data/lib/ferrum_mcp/whisper_service.rb +222 -0
  62. data/lib/ferrum_mcp.rb +35 -0
  63. 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