searxng 0.1.0 → 0.2.1

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.
@@ -1,5 +1,14 @@
1
1
  require "fast_mcp"
2
2
  require "searxng"
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "open3"
7
+ require "socket"
8
+ require "time"
9
+ require "timeout"
10
+ require "uri"
11
+ require "searxng/network_policy"
3
12
 
4
13
  FastMcp = MCP unless defined?(FastMcp)
5
14
 
@@ -53,15 +62,47 @@ module Searxng
53
62
  end
54
63
 
55
64
  def self.start
65
+ validate_environment!
56
66
  server = FastMcp::Server.new(
57
67
  name: "searxng",
58
68
  version: Searxng::VERSION,
59
69
  logger: NullLogger.new
60
70
  )
61
- server.register_tool(SearxngWebSearchTool)
71
+ register_tools(server)
72
+ register_resources(server)
62
73
  server.start
63
74
  end
64
75
 
76
+ def self.register_tools(server)
77
+ server.register_tool(SearxngWebSearchTool)
78
+ server.register_tool(WebUrlReadTool)
79
+ end
80
+
81
+ def self.register_resources(server)
82
+ server.register_resource(ServerConfigResource)
83
+ server.register_resource(UsageGuideResource)
84
+ end
85
+
86
+ def self.validate_environment!
87
+ searxng_url = ENV["SEARXNG_URL"]
88
+ if searxng_url.nil? || searxng_url.strip.empty?
89
+ raise ConfigurationError, ErrorMessages.configuration_missing_url
90
+ end
91
+
92
+ uri = URI.parse(searxng_url)
93
+ unless %w[http https].include?(uri.scheme)
94
+ raise ConfigurationError, ErrorMessages.configuration_invalid_protocol(uri.scheme)
95
+ end
96
+
97
+ user = ENV["SEARXNG_USER"] || ENV["AUTH_USERNAME"]
98
+ password = ENV["SEARXNG_PASSWORD"] || ENV["AUTH_PASSWORD"]
99
+ if (user && !password) || (!user && password)
100
+ raise ConfigurationError, ErrorMessages.configuration_auth_pair
101
+ end
102
+ rescue URI::InvalidURIError
103
+ raise ConfigurationError, ErrorMessages.configuration_invalid_url(searxng_url)
104
+ end
105
+
65
106
  class BaseTool < FastMcp::Tool
66
107
  protected
67
108
 
@@ -73,6 +114,7 @@ module Searxng
73
114
  class SearxngWebSearchTool < BaseTool
74
115
  tool_name "searxng_web_search"
75
116
  description "Performs a web search using the SearXNG API. Use this to find information on the web. Aggregates results from multiple search engines."
117
+ annotations(readOnlyHint: true, openWorldHint: true) if respond_to?(:annotations)
76
118
 
77
119
  arguments do
78
120
  required(:query).filled(:string).description("The search query")
@@ -98,6 +140,7 @@ module Searxng
98
140
 
99
141
  def format_search_result(data, max_results: 10, pageno: 1)
100
142
  out = []
143
+ query = data[:query].to_s
101
144
  data[:infoboxes]&.each do |ib|
102
145
  out << "Infobox: #{ib[:infobox]}"
103
146
  out << "ID: #{ib[:id]}"
@@ -107,7 +150,7 @@ module Searxng
107
150
  results = data[:results] || []
108
151
  total = data[:number_of_results]
109
152
  if results.empty?
110
- out << "No results found"
153
+ out << ErrorMessages.no_results(query.empty? ? "your query" : query)
111
154
  else
112
155
  limit = [max_results.to_i, 1].max
113
156
  shown = results.first(limit)
@@ -127,5 +170,753 @@ module Searxng
127
170
  out.join("\n").strip
128
171
  end
129
172
  end
173
+
174
+ class WebUrlReadTool < BaseTool
175
+ tool_name "web_url_read"
176
+ description "Reads a URL and converts HTML/XML content into Markdown. Supports character pagination, section extraction, paragraph ranges, and heading-only mode."
177
+ annotations(readOnlyHint: true, openWorldHint: true) if respond_to?(:annotations)
178
+
179
+ CACHE_TTL_SECONDS = 300
180
+ CACHE_MAX_ENTRIES = 32
181
+ CACHE_MAX_BYTES = 8 * 1024 * 1024
182
+ MAX_FETCH_BYTES = 2 * 1024 * 1024
183
+ DEFAULT_MAX_LENGTH = 20_000
184
+ MIN_TIMEOUT_MS = 1_000
185
+ MAX_TIMEOUT_MS = 30_000
186
+ DEFAULT_URL_SCHEMES = %w[http https ipfs].freeze
187
+ SUPPORTED_URL_SCHEMES = %w[http https ftp sftp smb gemini ipfs].freeze
188
+ @@url_cache = {} # rubocop:disable Style/ClassVars
189
+ @@url_cache_bytes = 0 # rubocop:disable Style/ClassVars
190
+ @@url_cache_mutex = Mutex.new # rubocop:disable Style/ClassVars
191
+
192
+ arguments do
193
+ required(:url).filled(:string).description("URL to fetch and read")
194
+ optional(:startChar).filled(:integer).description("Starting character position (default: 0)")
195
+ optional(:maxLength).filled(:integer).description("Maximum characters to return (default: 20000)")
196
+ optional(:section).filled(:string).description("Extract by heading text")
197
+ optional(:paragraphRange).filled(:string).description("Paragraph range, e.g. '1-5', '3', '10-'")
198
+ optional(:readHeadings).filled(:bool).description("Return only headings when true")
199
+ optional(:timeoutMs).filled(:integer).description("Request timeout in ms (default: 10000, clamped 1000..30000)")
200
+ end
201
+
202
+ def call(url:, **kwargs)
203
+ start_char = kwargs.fetch(:startChar, 0)
204
+ max_length = kwargs.fetch(:maxLength, DEFAULT_MAX_LENGTH)
205
+ section = kwargs[:section]
206
+ paragraph_range = kwargs[:paragraphRange]
207
+ read_headings = kwargs.fetch(:readHeadings, false)
208
+ timeout_ms = clamp_timeout_ms(kwargs.fetch(:timeoutMs, 10_000))
209
+
210
+ normalized = normalize_url(url)
211
+ uri = URI.parse(normalized)
212
+ ensure_scheme_enabled!(uri.scheme)
213
+ unless uri.scheme == "ipfs"
214
+ _validated_uri, ip_address = NetworkPolicy.validate!(
215
+ normalized,
216
+ allowed_schemes: [uri.scheme],
217
+ allow_userinfo: !%w[http https].include?(uri.scheme)
218
+ )
219
+ end
220
+ markdown = case uri.scheme
221
+ when "http", "https"
222
+ html = fetch_html(normalized, timeout_ms: timeout_ms)
223
+ convert_to_markdown(html, redact_uri_string(normalized))
224
+ when "ftp"
225
+ content = fetch_ftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
226
+ convert_fetched_content(content, redact_uri_string(normalized))
227
+ when "sftp"
228
+ content = fetch_sftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
229
+ convert_fetched_content(content, redact_uri_string(normalized))
230
+ when "smb"
231
+ content = fetch_smb(uri, ip_address: ip_address, timeout_ms: timeout_ms)
232
+ convert_fetched_content(content, redact_uri_string(normalized))
233
+ when "gemini"
234
+ gemtext = fetch_gemini(uri, ip_address: ip_address, timeout_ms: timeout_ms)
235
+ gemtext_to_markdown(gemtext, uri)
236
+ when "ipfs"
237
+ gateway_url = resolve_ipfs_url(uri)
238
+ html = fetch_html(gateway_url, timeout_ms: timeout_ms)
239
+ convert_to_markdown(html, gateway_url)
240
+ else
241
+ raise ConfigurationError, %(Unsupported URL scheme "#{uri.scheme}". Supported schemes: http, https, ftp, sftp, smb, gemini, ipfs.)
242
+ end
243
+ apply_options(markdown, start_char: start_char, max_length: max_length, section: section, paragraph_range: paragraph_range, read_headings: read_headings)
244
+ rescue Searxng::Error => e
245
+ "Error: #{e.message}"
246
+ end
247
+
248
+ private
249
+
250
+ def normalize_url(url)
251
+ raw = url.to_s.strip
252
+ uri = URI.parse(raw)
253
+ if uri.scheme
254
+ scheme = uri.scheme.downcase
255
+ return raw if uri.host && %w[http https ftp sftp smb gemini ipfs].include?(scheme)
256
+
257
+ raise ConfigurationError, %(Unsupported URL scheme "#{uri.scheme}". Supported schemes: http, https, ftp, sftp, smb, gemini, ipfs.)
258
+ end
259
+
260
+ candidate = "https://#{raw}"
261
+ parsed = URI.parse(candidate)
262
+ return candidate if parsed.host
263
+
264
+ raise ArgumentError
265
+ rescue URI::InvalidURIError, ArgumentError
266
+ raise ConfigurationError, %(URL Format Error: Invalid URL "#{url}")
267
+ end
268
+
269
+ def ensure_scheme_enabled!(scheme)
270
+ return if enabled_url_schemes.include?(scheme)
271
+
272
+ raise ConfigurationError, %(URL scheme "#{scheme}" is not enabled)
273
+ end
274
+
275
+ def enabled_url_schemes
276
+ configured = ENV["SEARXNG_WEB_URL_SCHEMES"]
277
+ return DEFAULT_URL_SCHEMES unless configured
278
+
279
+ configured.split(",").map { |scheme| scheme.strip.downcase } & SUPPORTED_URL_SCHEMES
280
+ end
281
+
282
+ def clamp_timeout_ms(value)
283
+ value.to_i.clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
284
+ end
285
+
286
+ def redact_uri_string(url)
287
+ uri = url.is_a?(URI) ? url.dup : URI.parse(url.to_s)
288
+ uri.user = nil
289
+ uri.password = nil
290
+ uri.to_s
291
+ rescue URI::InvalidURIError
292
+ "(invalid URL)"
293
+ end
294
+
295
+ def fetch_html(url, timeout_ms:)
296
+ now = Time.now.to_i
297
+ cached = cache_get(url, now: now)
298
+ return cached if cached
299
+
300
+ uri, ip_address = NetworkPolicy.validate!(url, allowed_schemes: %w[http https])
301
+ client = get_client
302
+ http = build_fetch_http(client, uri, ip_address, timeout_ms: timeout_ms)
303
+ request = Net::HTTP::Get.new(uri.request_uri)
304
+ request["Host"] = uri.hostname || uri.host
305
+ request["User-Agent"] = ENV["SEARXNG_USER_AGENT"].to_s.strip.empty? ? "searxng-ruby/#{Searxng::VERSION} web_url_read" : ENV["SEARXNG_USER_AGENT"]
306
+ request["Accept-Encoding"] = "identity"
307
+ safe_url = redact_uri_string(url)
308
+ body = nil
309
+ status_code = nil
310
+ status_message = nil
311
+ location = nil
312
+ Timeout.timeout(timeout_ms / 1000.0) do
313
+ http.request(request) do |response|
314
+ status_code = response.code.to_i
315
+ status_message = response.message
316
+ location = response["location"]
317
+ unless response.is_a?(Net::HTTPSuccess)
318
+ read_limited_body(response)
319
+ raise APIError.new(
320
+ ErrorMessages.fetch_http_error(status_code, status_message, location: location),
321
+ status_code: status_code,
322
+ uri: safe_url
323
+ )
324
+ end
325
+ body = read_limited_body(response)
326
+ end
327
+ end
328
+ if body.to_s.strip.empty?
329
+ raise APIError.new("Content Error: Website returned empty content.", status_code: status_code, uri: safe_url)
330
+ end
331
+
332
+ cache_put(url, body, at: now)
333
+ body
334
+ rescue Timeout::Error
335
+ raise NetworkError, "Timeout Error: #{URI.parse(url).host} took longer than #{timeout_ms}ms to respond"
336
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError, EOFError => e
337
+ raise NetworkError, ErrorMessages.network_error(e, uri: URI.parse(url), target: "website")
338
+ end
339
+
340
+ def build_fetch_http(client, uri, ip_address, timeout_ms:)
341
+ proxy_uri = client.send(:proxy_uri_for, uri)
342
+ seconds = [timeout_ms / 1000.0, 1.0].max
343
+ if proxy_uri && uri.scheme == "http"
344
+ http = Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
345
+ .new(ip_address, uri.port)
346
+ http.open_timeout = seconds
347
+ http.read_timeout = seconds
348
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
349
+ return http
350
+ end
351
+
352
+ http = client.send(:build_http, uri)
353
+ http.ipaddr = ip_address
354
+ http.open_timeout = seconds
355
+ http.read_timeout = seconds
356
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
357
+ http
358
+ end
359
+
360
+ def read_limited_body(response)
361
+ body = +""
362
+ response.read_body do |chunk|
363
+ body << chunk
364
+ if body.bytesize > MAX_FETCH_BYTES
365
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: nil)
366
+ end
367
+ end
368
+ body
369
+ end
370
+
371
+ def cache_get(url, now:)
372
+ @@url_cache_mutex.synchronize do
373
+ purge_expired_cache!(now)
374
+ entry = @@url_cache[url]
375
+ (entry && (now - entry[:at] <= CACHE_TTL_SECONDS)) ? entry[:html] : nil
376
+ end
377
+ end
378
+
379
+ def cache_put(url, html, at:)
380
+ @@url_cache_mutex.synchronize do
381
+ purge_expired_cache!(at)
382
+ while (@@url_cache.size >= CACHE_MAX_ENTRIES || (@@url_cache_bytes + html.bytesize) > CACHE_MAX_BYTES) && !@@url_cache.empty?
383
+ evict_oldest_cache_entry!
384
+ end
385
+ return if html.bytesize > CACHE_MAX_BYTES
386
+
387
+ previous = @@url_cache[url]
388
+ @@url_cache_bytes -= previous[:html].bytesize if previous
389
+ @@url_cache[url] = {at: at, html: html}
390
+ @@url_cache_bytes += html.bytesize
391
+ end
392
+ end
393
+
394
+ def purge_expired_cache!(now)
395
+ @@url_cache.delete_if do |_key, entry|
396
+ expired = now - entry[:at] > CACHE_TTL_SECONDS
397
+ @@url_cache_bytes -= entry[:html].bytesize if expired
398
+ expired
399
+ end
400
+ end
401
+
402
+ def evict_oldest_cache_entry!
403
+ oldest_key, oldest = @@url_cache.min_by { |_key, entry| entry[:at] }
404
+ return unless oldest_key
405
+
406
+ @@url_cache_bytes -= oldest[:html].bytesize
407
+ @@url_cache.delete(oldest_key)
408
+ end
409
+
410
+ def convert_to_markdown(html, base_url)
411
+ load_teplo_core!
412
+ ast = TeploCore.parse_html(html)
413
+ markdown = TeploCore.ast_to_markdown(ast, base_url)
414
+ if markdown.to_s.strip.empty?
415
+ "Content Warning: Page fetched but appears empty after conversion (#{base_url}). May contain only media or require JavaScript."
416
+ else
417
+ markdown
418
+ end
419
+ rescue Searxng::Error
420
+ raise
421
+ rescue => e
422
+ raise APIError.new("Conversion Error: Cannot convert HTML to Markdown (#{base_url}) - #{e.message}", uri: base_url)
423
+ end
424
+
425
+ def convert_fetched_content(content, base_url)
426
+ text = content.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
427
+ return text if text.strip.empty?
428
+
429
+ looks_like_markup = text.match?(/\A\s*<(?:!doctype|html|body|xml|rss|feed|svg|\?xml)/i)
430
+ looks_like_markup ? convert_to_markdown(text, base_url) : text
431
+ end
432
+
433
+ def fetch_ftp(uri, timeout_ms:, ip_address: nil)
434
+ begin
435
+ require "net/ftp"
436
+ rescue LoadError
437
+ raise ConfigurationError, "FTP support requires the 'net-ftp' gem."
438
+ end
439
+
440
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
441
+ body = +""
442
+ ftp = nil
443
+ safe_uri = redact_uri_string(uri)
444
+ Timeout.timeout(timeout_ms / 1000.0) do
445
+ ftp = Net::FTP.new
446
+ ftp.passive = true
447
+ ftp.use_pasv_ip = false if ftp.respond_to?(:use_pasv_ip=)
448
+ begin
449
+ ftp.connect(ip_address, uri.port || 21)
450
+ ftp.read_timeout = [timeout_ms / 1000.0, 1.0].max
451
+ ftp.open_timeout = [timeout_ms / 1000.0, 1.0].max
452
+ user = uri.user || credential_for(uri.host, "FTP_USER") || "anonymous"
453
+ pass = uri.password || credential_for(uri.host, "FTP_PASSWORD") || "anonymous@"
454
+ ftp.login(user, pass)
455
+ path = URI.decode_www_form_component(uri.path.to_s)
456
+ raise APIError.new("FTP path is required", uri: safe_uri) if path.empty? || path == "/"
457
+ raise ConfigurationError, "FTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
458
+
459
+ ftp.retrbinary("RETR #{path}", 16_384) do |chunk|
460
+ body << chunk
461
+ if body.bytesize > MAX_FETCH_BYTES
462
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
463
+ end
464
+ end
465
+ ensure
466
+ ftp&.close unless ftp.nil? || ftp.closed?
467
+ end
468
+ end
469
+ body
470
+ rescue Timeout::Error
471
+ raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
472
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
473
+ raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "FTP server")
474
+ rescue Searxng::Error
475
+ raise
476
+ rescue => e
477
+ if e.class.name.start_with?("Net::FTP")
478
+ raise APIError.new("FTP Error: #{e.message}", uri: redact_uri_string(uri))
479
+ end
480
+ raise e
481
+ end
482
+
483
+ def fetch_sftp(uri, timeout_ms:, ip_address: nil)
484
+ begin
485
+ require "net/sftp"
486
+ rescue LoadError
487
+ raise ConfigurationError, "SFTP support requires the 'net-sftp' gem."
488
+ end
489
+
490
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
491
+ user = uri.user || credential_for(uri.host, "SFTP_USER")
492
+ password = uri.password || credential_for(uri.host, "SFTP_PASSWORD")
493
+ raise ConfigurationError, "SFTP requires username (sftp://user@host/path or SFTP_USER)." unless user
494
+
495
+ path = URI.decode_www_form_component(uri.path.to_s)
496
+ safe_uri = redact_uri_string(uri)
497
+ raise APIError.new("SFTP path is required", uri: safe_uri) if path.empty? || path == "/"
498
+ raise ConfigurationError, "SFTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
499
+
500
+ result = nil
501
+ Timeout.timeout(timeout_ms / 1000.0) do
502
+ options = {password: password, port: uri.port || 22, non_interactive: true, verify_host_key: :always}
503
+ options[:host_key_alias] = uri.host if uri.host
504
+ Net::SFTP.start(ip_address, user, **options) do |sftp|
505
+ result = sftp.download!(path).to_s
506
+ end
507
+ end
508
+ if result.to_s.bytesize > MAX_FETCH_BYTES
509
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
510
+ end
511
+ result.to_s
512
+ rescue Timeout::Error
513
+ raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
514
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
515
+ raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SFTP server")
516
+ rescue Searxng::Error
517
+ raise
518
+ rescue => e
519
+ raise e if e.is_a?(ConfigurationError)
520
+ if e.class.name.include?("StatusException")
521
+ description = e.respond_to?(:description) ? e.description : e.message
522
+ raise APIError.new("SFTP Error: #{description}", uri: safe_uri)
523
+ end
524
+ if e.class.name.include?("AuthenticationFailed")
525
+ raise APIError.new("SFTP authentication failed", uri: safe_uri)
526
+ end
527
+ raise APIError.new("SFTP Error: #{e.message}", uri: safe_uri)
528
+ end
529
+
530
+ def fetch_smb(uri, timeout_ms:, ip_address: nil)
531
+ ip_address ||= validated_ip_address(uri, allow_userinfo: true)
532
+ share, remote_path = parse_smb_path(uri)
533
+ user = uri.user || credential_for(uri.host, "SMB_USER")
534
+ pass = uri.password || credential_for(uri.host, "SMB_PASSWORD")
535
+ domain = credential_for(uri.host, "SMB_DOMAIN")
536
+
537
+ full_user = if user
538
+ (domain && !domain.empty?) ? "#{domain}\\#{user}" : user
539
+ end
540
+
541
+ cmd = ["smbclient", "//#{ip_address}/#{share}", "-c", %(get "#{remote_path}" -), "-t", [(timeout_ms / 1000.0).ceil, 1].max.to_s]
542
+ env = {}
543
+ if full_user
544
+ cmd << "-U" << full_user
545
+ env["PASSWD"] = pass.to_s
546
+ else
547
+ cmd << "-N"
548
+ end
549
+
550
+ stdout = +""
551
+ stderr = +""
552
+ status = nil
553
+ wait_thr = nil
554
+ Timeout.timeout(timeout_ms / 1000.0) do
555
+ Open3.popen3(env, *cmd) do |stdin, out, err, thr|
556
+ stdin.close
557
+ wait_thr = thr
558
+ stdout = out.read.to_s
559
+ stderr = err.read.to_s
560
+ status = thr.value
561
+ end
562
+ end
563
+ if stdout.bytesize > MAX_FETCH_BYTES
564
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: redact_uri_string(uri))
565
+ end
566
+
567
+ raise APIError.new("SMB Error: #{stderr.strip.empty? ? "request failed" : stderr.strip[0, 500]}", uri: redact_uri_string(uri)) unless status&.success?
568
+
569
+ stdout
570
+ rescue Errno::ENOENT
571
+ raise ConfigurationError, "SMB support requires 'smbclient' to be installed and available in PATH."
572
+ rescue Timeout::Error
573
+ if wait_thr&.alive?
574
+ begin
575
+ Process.kill("TERM", wait_thr.pid)
576
+ rescue Errno::ESRCH
577
+ end
578
+ end
579
+ raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
580
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
581
+ raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SMB server")
582
+ end
583
+
584
+ def parse_smb_path(uri)
585
+ raw_path = uri.path.to_s
586
+ segments = raw_path.split("/").reject(&:empty?).map { |segment| URI.decode_www_form_component(segment) }
587
+ share = segments.shift
588
+ remote = segments.join("/")
589
+ raise APIError.new("SMB path must include share and file (smb://host/share/path/file)", uri: redact_uri_string(uri)) if share.to_s.empty? || remote.empty?
590
+ if share.match?(/[\x00-\x1f\x7f;"\\]/) || remote.match?(/[\x00-\x1f\x7f;"\\]/)
591
+ raise ConfigurationError, "SMB path contains unsupported characters"
592
+ end
593
+
594
+ [share, remote]
595
+ end
596
+
597
+ def fetch_gemini(uri, timeout_ms:, ip_address: nil)
598
+ ip_address ||= validated_ip_address(uri)
599
+ response_header = nil
600
+ body = +""
601
+ tcp = nil
602
+ ssl = nil
603
+ safe_uri = redact_uri_string(uri)
604
+ Timeout.timeout(timeout_ms / 1000.0) do
605
+ tcp = TCPSocket.new(ip_address, uri.port || 1965)
606
+ begin
607
+ ctx = OpenSSL::SSL::SSLContext.new
608
+ if ENV["GEMINI_INSECURE"] == "1"
609
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
610
+ else
611
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
612
+ end
613
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
614
+ ssl.hostname = uri.host if ssl.respond_to?(:hostname=)
615
+ ssl.connect
616
+ ssl.post_connection_check(uri.host) unless ENV["GEMINI_INSECURE"] == "1"
617
+ ssl.write("#{uri}\r\n")
618
+ response_header = ssl.gets("\r\n", 2048)
619
+ while body.bytesize <= MAX_FETCH_BYTES
620
+ chunk = ssl.read(16_384)
621
+ break if chunk.nil? || chunk.empty?
622
+ body << chunk
623
+ end
624
+ raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri) if body.bytesize > MAX_FETCH_BYTES
625
+ ensure
626
+ ssl&.close
627
+ tcp&.close unless tcp.nil? || tcp.closed?
628
+ end
629
+ end
630
+
631
+ unless response_header
632
+ raise APIError.new("Gemini response error: empty response header", uri: safe_uri)
633
+ end
634
+
635
+ status, meta = response_header.strip.split(/\s+/, 2)
636
+ status_code = status.to_i
637
+ case status_code
638
+ when 20..29
639
+ body
640
+ when 30..39
641
+ raise APIError.new("Gemini redirect (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
642
+ when 40..49
643
+ raise APIError.new("Gemini temporary failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
644
+ when 50..59
645
+ raise APIError.new("Gemini permanent failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
646
+ when 60..69
647
+ raise APIError.new("Gemini certificate required (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
648
+ else
649
+ raise APIError.new("Gemini response error (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
650
+ end
651
+ rescue Timeout::Error
652
+ raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
653
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError, EOFError => e
654
+ raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "Gemini server")
655
+ end
656
+
657
+ def gemtext_to_markdown(gemtext, base_uri)
658
+ lines = gemtext.to_s.split("\n")
659
+ out = lines.map do |line|
660
+ if line.start_with?("=>")
661
+ target, *label_parts = line.sub(/\A=>\s*/, "").split(/\s+/)
662
+ label = label_parts.join(" ").strip
663
+ next "" if target.to_s.empty?
664
+ resolved = resolve_relative_uri(base_uri, target)
665
+ display = label.empty? ? resolved : label
666
+ "- [#{display}](#{resolved})"
667
+ elsif line.start_with?("### ")
668
+ "### #{line[4..].to_s.strip}"
669
+ elsif line.start_with?("## ")
670
+ "## #{line[3..].to_s.strip}"
671
+ elsif line.start_with?("# ")
672
+ "# #{line[2..].to_s.strip}"
673
+ elsif line.start_with?("> ")
674
+ "> #{line[2..]}"
675
+ else
676
+ line
677
+ end
678
+ end
679
+ out.join("\n")
680
+ end
681
+
682
+ def resolve_relative_uri(base_uri, target)
683
+ target_uri = URI.parse(target)
684
+ return target if target_uri.scheme
685
+
686
+ URI.join(base_uri.to_s, target).to_s
687
+ rescue URI::Error
688
+ target
689
+ end
690
+
691
+ def resolve_ipfs_url(uri)
692
+ gateway = ENV["IPFS_GATEWAY"] || "https://ipfs.io"
693
+ gateway = gateway.sub(%r{/+\z}, "")
694
+ parsed_gateway = URI.parse(gateway)
695
+ unless %w[http https].include?(parsed_gateway.scheme)
696
+ raise ConfigurationError, "IPFS_GATEWAY must use http or https, got: #{parsed_gateway.scheme.inspect}"
697
+ end
698
+
699
+ cid = uri.host.to_s
700
+ unless cid.match?(/\A[a-zA-Z0-9_-]+\z/)
701
+ raise ConfigurationError, "IPFS CID is invalid"
702
+ end
703
+
704
+ path = uri.path.to_s
705
+ segments = path.split("/").reject(&:empty?)
706
+ segments.each do |segment|
707
+ decoded = URI.decode_www_form_component(segment)
708
+ if decoded == "." || decoded == ".." || decoded.include?("\0")
709
+ raise ConfigurationError, "IPFS path is not allowed"
710
+ end
711
+ end
712
+
713
+ query = uri.query ? "?#{uri.query}" : ""
714
+ "#{gateway}/ipfs/#{cid}#{path}#{query}"
715
+ rescue URI::InvalidURIError
716
+ raise ConfigurationError, "IPFS_GATEWAY has invalid format: #{gateway.inspect}"
717
+ end
718
+
719
+ def credential_for(host, variable)
720
+ allowed_hosts = ENV["SEARXNG_WEB_URL_CREDENTIAL_HOSTS"].to_s
721
+ .split(",")
722
+ .map { |candidate| candidate.strip.downcase }
723
+ ENV[variable] if allowed_hosts.include?(host.downcase)
724
+ end
725
+
726
+ def validated_ip_address(uri, allow_userinfo: false)
727
+ NetworkPolicy.validate!(
728
+ uri.to_s,
729
+ allowed_schemes: [uri.scheme],
730
+ allow_userinfo: allow_userinfo
731
+ )[1]
732
+ end
733
+
734
+ def load_teplo_core!
735
+ return if defined?(TeploCore)
736
+
737
+ require "teplo_core"
738
+ rescue LoadError
739
+ local = File.expand_path("../../../textplorer/core-ruby/lib/teplo_core", __dir__)
740
+ if File.exist?("#{local}.rb")
741
+ require local
742
+ else
743
+ raise ConfigurationError, "web_url_read requires the teplo_core gem for HTML to Markdown conversion."
744
+ end
745
+ end
746
+
747
+ def apply_options(markdown, start_char:, max_length:, section:, paragraph_range:, read_headings:)
748
+ result = markdown.to_s
749
+ return extract_headings(result) if read_headings
750
+
751
+ if section && !section.strip.empty?
752
+ section_text = extract_section(result, section)
753
+ return %(Section "#{section}" not found in the content.) if section_text.empty?
754
+
755
+ result = section_text
756
+ end
757
+
758
+ if paragraph_range && !paragraph_range.strip.empty?
759
+ paragraph_text = extract_paragraph_range(result, paragraph_range)
760
+ return %(Paragraph range "#{paragraph_range}" is invalid or out of bounds.) if paragraph_text.empty?
761
+
762
+ result = paragraph_text
763
+ end
764
+
765
+ start = [start_char.to_i, 0].max
766
+ result = (start >= result.length) ? "" : result[start..]
767
+
768
+ if max_length
769
+ max = max_length.to_i
770
+ result = result[0, max] if max.positive?
771
+ end
772
+ result
773
+ end
774
+
775
+ def extract_section(markdown, heading)
776
+ lines = markdown.split("\n")
777
+ section_regex = /^\#{1,6}\s*.*#{Regexp.escape(heading)}.*$/i
778
+
779
+ start_index = -1
780
+ current_level = 0
781
+ lines.each_with_index do |line, idx|
782
+ next unless line.match?(section_regex)
783
+
784
+ start_index = idx
785
+ current_level = line[/^#+/].to_s.length
786
+ break
787
+ end
788
+ return "" if start_index < 0
789
+
790
+ end_index = lines.length
791
+ lines[(start_index + 1)..]&.each_with_index do |line, offset|
792
+ level = line[/^#+/].to_s.length
793
+ next if level.zero? || level > current_level
794
+
795
+ end_index = start_index + 1 + offset
796
+ break
797
+ end
798
+
799
+ lines[start_index...end_index].join("\n")
800
+ end
801
+
802
+ def extract_paragraph_range(markdown, range)
803
+ paragraphs = markdown.split(/\n{2,}/).map(&:strip).reject(&:empty?)
804
+ match = range.to_s.strip.match(/\A(\d+)(?:-(\d*))?\z/)
805
+ return "" unless match
806
+
807
+ start = match[1].to_i - 1
808
+ return "" if start.negative? || start >= paragraphs.length
809
+
810
+ if match[2].nil?
811
+ paragraphs[start].to_s
812
+ elsif match[2].empty?
813
+ paragraphs[start..].join("\n\n")
814
+ else
815
+ ending = match[2].to_i
816
+ paragraphs[start...ending].join("\n\n")
817
+ end
818
+ end
819
+
820
+ def extract_headings(markdown)
821
+ headings = markdown.split("\n").select { |line| line.match?(/^\#{1,6}\s+/) }
822
+ headings.empty? ? "No headings found in the content." : headings.join("\n")
823
+ end
824
+ end
825
+
826
+ class ServerConfigResource < FastMcp::Resource
827
+ uri "config://server-config"
828
+ resource_name "Server Configuration"
829
+ description "Current SearXNG MCP server configuration and capabilities"
830
+ mime_type "application/json"
831
+
832
+ def content
833
+ searxng_url = ENV["SEARXNG_URL"].to_s
834
+ user = ENV["SEARXNG_USER"] || ENV["AUTH_USERNAME"]
835
+ password = ENV["SEARXNG_PASSWORD"] || ENV["AUTH_PASSWORD"]
836
+ config = {
837
+ server_info: {
838
+ name: "searxng",
839
+ version: Searxng::VERSION
840
+ },
841
+ environment: {
842
+ searxng_url: safe_url(searxng_url),
843
+ has_auth: !!(user && password),
844
+ has_proxy: !!(ENV["HTTP_PROXY"] || ENV["HTTPS_PROXY"] || ENV["http_proxy"] || ENV["https_proxy"] || ENV["ALL_PROXY"] || ENV["all_proxy"]),
845
+ has_no_proxy: !!(ENV["NO_PROXY"] || ENV["no_proxy"])
846
+ },
847
+ capabilities: {
848
+ tools: %w[searxng_web_search web_url_read],
849
+ resources: %w[config://server-config help://usage-guide],
850
+ transport: ["stdio"]
851
+ }
852
+ }
853
+ JSON.pretty_generate(config)
854
+ end
855
+
856
+ private
857
+
858
+ def safe_url(raw)
859
+ return "(not configured)" if raw.nil? || raw.strip.empty?
860
+
861
+ uri = URI.parse(raw)
862
+ uri.user = nil
863
+ uri.password = nil
864
+ uri.to_s
865
+ rescue URI::InvalidURIError
866
+ "(invalid URL)"
867
+ end
868
+ end
869
+
870
+ class UsageGuideResource < FastMcp::Resource
871
+ uri "help://usage-guide"
872
+ resource_name "Usage Guide"
873
+ description "Short guide for using SearXNG MCP tools and environment variables"
874
+ mime_type "text/markdown"
875
+
876
+ def content
877
+ <<~MD
878
+ # SearXNG MCP Server Help
879
+
880
+ ## Tools
881
+ 1. `searxng_web_search` - Search the web via SearXNG.
882
+ 2. `web_url_read` - Fetch a URL and return Markdown-converted content.
883
+
884
+ ## Required Environment
885
+ - `SEARXNG_URL` (must be `http://` or `https://`)
886
+
887
+ ## Optional Environment
888
+ - `SEARXNG_USER` and `SEARXNG_PASSWORD` (or `AUTH_USERNAME` and `AUTH_PASSWORD`)
889
+ - `HTTP_PROXY` / `HTTPS_PROXY`
890
+ - `ALL_PROXY` (for shared proxy config)
891
+ - `NO_PROXY`
892
+ - `SEARXNG_WEB_URL_SCHEMES` (comma-separated opt-in protocols; default: `http,https,ipfs`)
893
+ - `SEARXNG_WEB_URL_CREDENTIAL_HOSTS` (comma-separated hosts allowed to receive environment credentials)
894
+ - `FTP_USER` / `FTP_PASSWORD` (optional credentials for allowlisted hosts)
895
+ - `SFTP_USER` / `SFTP_PASSWORD` (optional credentials for allowlisted hosts)
896
+ - `SMB_USER` / `SMB_PASSWORD` / `SMB_DOMAIN` (optional credentials for allowlisted hosts)
897
+ - `IPFS_GATEWAY` (default: `https://ipfs.io`)
898
+ - `GEMINI_INSECURE=1` (optional, disables TLS verification for Gemini)
899
+
900
+ ## Protocol Notes
901
+ - `http` / `https`: fully supported (`searxng_web_search` + `web_url_read`)
902
+ - `ftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (file retrieval via FTP)
903
+ - `sftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (requires `net-sftp` gem)
904
+ - `smb`: opt-in through `SEARXNG_WEB_URL_SCHEMES` via `smbclient`
905
+ - `gemini`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (Gemini fetch + Gemtext conversion)
906
+ - `ipfs`: supported via HTTP gateway mapping (`ipfs://...` -> `IPFS_GATEWAY`)
907
+ - `.onion` / `.i2p`: not supported by `web_url_read` (local DNS and destination policy run before any proxy)
908
+ - `socks5`: may work for SearXNG search via proxy env; not a bypass for `web_url_read` destination policy
909
+
910
+ ## Examples
911
+ - Search: `{"query":"latest ruby news","time_range":"day"}`
912
+ - Read URL: `{"url":"https://example.com","section":"Introduction","maxLength":2000}`
913
+ - Read FTP: `{"url":"ftp://ftp.example.com/path/file.txt"}`
914
+ - Read SFTP: `{"url":"sftp://user@example.com/path/file.txt"}`
915
+ - Read SMB: `{"url":"smb://fileserver/share/path/file.txt"}`
916
+ - Read Gemini: `{"url":"gemini://geminiprotocol.net"}`
917
+ - Read IPFS: `{"url":"ipfs://bafybeigdyrzt.../index.html"}`
918
+ MD
919
+ end
920
+ end
130
921
  end
131
922
  end