Overhaul docs retrieval and web search quality
Replace the abandoned llms-txt-mcp/Chroma docs backend with an in-repo MCP service: SQLite WAL + FTS5 + sentence-transformer embeddings, transactional source replacement, persisted state across restarts, singleflight refresh with conditional requests, hybrid lexical/semantic ranking with exact-duplicate collapse, source/host filters, and explicit-by-default content retrieval. Add docs_rebuild and a docs-rebuild CLI command. Add deterministic llms-full.txt snapshot generation for machine-local menus with hash-validated provenance manifests; lifecycle commands promote a local menu to its snapshot only when the manifest validates. Switch public source profiles to content-bearing llms-full.txt feeds. Improve web search: bounded provider fallback with per-attempt diagnostics and cancellation, an optional Brave Search API provider, strict SearXNG engine selection, capped link/media extraction, and a real engine=browser renderer that routes every request through the existing SSRF vetting while blocking WebSockets, non-GET traffic, and private destinations. Extend release checks with offline unit suites and isolated candidate container tests for both images.
This commit is contained in:
@@ -4,10 +4,6 @@ ARG MCP_WEB_SEARCH_VERSION=1.3.0
|
||||
ARG MCP_WEB_SEARCH_MAX_BYTES=52428800
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
|
||||
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
|
||||
COPY overrides/bing.js /tmp/context-kit-bing-provider.js
|
||||
COPY --chmod=0444 mcp-probe.mjs http-entrypoint.mjs /usr/local/lib/context-kit/
|
||||
|
||||
# Chromium intentionally tracks Debian security updates inside the pinned base
|
||||
# image family; Bing's browser path is more likely to break with stale Chromium
|
||||
# than with patched OS packages.
|
||||
@@ -23,13 +19,27 @@ RUN python3 -m venv /opt/mcp-proxy \
|
||||
&& /opt/mcp-proxy/bin/pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" \
|
||||
&& /opt/mcp-proxy/bin/mcp-proxy --version
|
||||
|
||||
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
|
||||
COPY overrides/bing.js /tmp/context-kit-bing-provider.js
|
||||
COPY overrides/brave.js overrides/duckduckgo.js overrides/searxng.js overrides/registry.js overrides/diagnostics.mjs /tmp/context-kit-providers/
|
||||
COPY overrides/browser-fetch.js overrides/bounds.mjs /tmp/context-kit-fetch/
|
||||
COPY --chmod=0444 mcp-probe.mjs http-entrypoint.mjs /usr/local/lib/context-kit/
|
||||
|
||||
RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \
|
||||
&& cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \
|
||||
&& cp /tmp/context-kit-providers/brave.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/brave.js \
|
||||
&& cp /tmp/context-kit-providers/duckduckgo.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/duckduckgo.js \
|
||||
&& cp /tmp/context-kit-providers/searxng.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/searxng.js \
|
||||
&& cp /tmp/context-kit-providers/registry.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/registry.js \
|
||||
&& cp /tmp/context-kit-providers/diagnostics.mjs /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/diagnostics.js \
|
||||
&& cp /tmp/context-kit-fetch/browser-fetch.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/fetch/browser.js \
|
||||
&& cp /tmp/context-kit-fetch/bounds.mjs /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/fetch/bounds.js \
|
||||
&& node /tmp/patch-mcp-web-search.mjs \
|
||||
&& rm /tmp/patch-mcp-web-search.mjs /tmp/context-kit-bing-provider.js \
|
||||
&& rm -rf /tmp/patch-mcp-web-search.mjs /tmp/context-kit-bing-provider.js /tmp/context-kit-providers /tmp/context-kit-fetch \
|
||||
&& npm cache clean --force
|
||||
|
||||
RUN chmod 0555 /usr/local/lib/context-kit
|
||||
RUN chmod -R a+rX /usr/local/lib/context-kit \
|
||||
/usr/local/lib/node_modules/@zhafron/mcp-web-search
|
||||
|
||||
ENV CHROME_PATH=/usr/bin/chromium \
|
||||
DEFAULT_SEARCH_PROVIDER=searxng \
|
||||
@@ -37,6 +47,8 @@ ENV CHROME_PATH=/usr/bin/chromium \
|
||||
HTTP_TIMEOUT=15000 \
|
||||
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
|
||||
MAX_RESULTS=10 \
|
||||
MAX_PROVIDER_ATTEMPTS=4 \
|
||||
SEARCH_PROVIDER_TIMEOUT_MS=15000 \
|
||||
PATH=/opt/mcp-proxy/bin:$PATH \
|
||||
SEARXNG_URL=http://searxng:8080 \
|
||||
XDG_CACHE_HOME=/tmp/.cache
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
const protocolVersion = "2024-11-05";
|
||||
const expectedTools = ["fetch_url", "search_web"];
|
||||
|
||||
async function rpc(url, id, method, params = {}, timeoutMs = 5000) {
|
||||
export async function rpc(url, id, method, params = {}, timeoutMs = 5000) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -29,7 +29,7 @@ async function rpc(url, id, method, params = {}, timeoutMs = 5000) {
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
export async function probeMcp(url, { timeoutMs = 5000 } = {}) {
|
||||
export async function probeMcp(url, { timeoutMs = 5000, expectedTools: requiredTools = expectedTools } = {}) {
|
||||
const initialized = await rpc(url, 1, "initialize", {
|
||||
protocolVersion,
|
||||
capabilities: {},
|
||||
@@ -39,7 +39,7 @@ export async function probeMcp(url, { timeoutMs = 5000 } = {}) {
|
||||
|
||||
const listed = await rpc(url, 2, "tools/list", {}, timeoutMs);
|
||||
const names = new Set((listed?.tools || []).map(tool => tool.name));
|
||||
for (const name of expectedTools) {
|
||||
for (const name of requiredTools) {
|
||||
if (!names.has(name)) throw new Error(`tools/list omitted ${name}`);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
|
||||
@@ -43,7 +43,7 @@ export class BingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async search(q, limit, lang) {
|
||||
async search(q, limit, lang, signal) {
|
||||
const cacheKey = createCacheKey("bing", q, limit, lang);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached)
|
||||
@@ -51,7 +51,10 @@ export class BingProvider {
|
||||
const market = getMarketFromLang(lang);
|
||||
const results = await browserPool.withBrowser(async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const abort = () => void page.close().catch(() => undefined);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await page.setViewport({ width: 1365, height: 768 });
|
||||
await page.setUserAgent(DEFAULT_BROWSER_SEARCH_USER_AGENT);
|
||||
await page.setExtraHTTPHeaders(getAcceptLanguageHeader(lang));
|
||||
@@ -95,7 +98,8 @@ export class BingProvider {
|
||||
});
|
||||
}
|
||||
finally {
|
||||
await page.close();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (!page.isClosed()) await page.close();
|
||||
}
|
||||
});
|
||||
searchCache.set(cacheKey, results);
|
||||
|
||||
23
docker/web-search/overrides/bounds.mjs
Normal file
23
docker/web-search/overrides/bounds.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
const MAX_LINKS = 500;
|
||||
const MAX_IMAGES = 200;
|
||||
const MAX_VIDEO = 50;
|
||||
const MAX_AUDIO = 50;
|
||||
const MAX_ATTACHMENTS = 10;
|
||||
|
||||
export function boundFetchCollections(result) {
|
||||
const warnings = [...(result.warnings || [])];
|
||||
const trim = (value, maximum, label) => {
|
||||
if (!Array.isArray(value)) return value;
|
||||
if (value.length > maximum) warnings.push(`${label} truncated from ${value.length} to ${maximum}`);
|
||||
return value.slice(0, maximum);
|
||||
};
|
||||
if (result.links) result.links = trim(result.links, MAX_LINKS, "links");
|
||||
if (result.media) {
|
||||
result.media.images = trim(result.media.images, MAX_IMAGES, "images");
|
||||
result.media.videos = trim(result.media.videos, MAX_VIDEO, "videos");
|
||||
result.media.audio = trim(result.media.audio, MAX_AUDIO, "audio");
|
||||
}
|
||||
if (result.attachments) result.attachments = trim(result.attachments, MAX_ATTACHMENTS, "attachments");
|
||||
result.warnings = warnings;
|
||||
return result;
|
||||
}
|
||||
42
docker/web-search/overrides/brave.js
Normal file
42
docker/web-search/overrides/brave.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { HTTP_TIMEOUT } from "../constants.js";
|
||||
import { searchCache, createCacheKey } from "../utils/cache.js";
|
||||
|
||||
export class BraveProvider {
|
||||
name = "brave";
|
||||
configured = Boolean(process.env.BRAVE_SEARCH_API_KEY);
|
||||
|
||||
async search(q, limit, lang, signal) {
|
||||
if (!this.configured) return [];
|
||||
const cacheKey = createCacheKey("brave", q, limit, lang);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("count", String(Math.min(limit, 20)));
|
||||
url.searchParams.set("search_lang", lang.split(/[-_]/)[0] || "en");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": process.env.BRAVE_SEARCH_API_KEY
|
||||
},
|
||||
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(HTTP_TIMEOUT)]) : AbortSignal.timeout(HTTP_TIMEOUT)
|
||||
});
|
||||
if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
const items = (data.web?.results || []).slice(0, limit).flatMap(result => {
|
||||
if (!result.title || !result.url) return [];
|
||||
return [{
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
snippet: result.description || undefined,
|
||||
source: "brave"
|
||||
}];
|
||||
});
|
||||
searchCache.set(cacheKey, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return this.configured;
|
||||
}
|
||||
}
|
||||
95
docker/web-search/overrides/browser-fetch.js
Normal file
95
docker/web-search/overrides/browser-fetch.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { HTTP_TIMEOUT, MAX_BYTES } from "../constants.js";
|
||||
import { browserPool } from "../utils/browser-pool.js";
|
||||
import { assertSafeUrl } from "./security.js";
|
||||
import { fetchViaVettedAddress } from "./http.js";
|
||||
|
||||
const MAX_BROWSER_REQUESTS = 100;
|
||||
const MAX_BROWSER_TOTAL_BYTES = Math.min(MAX_BYTES, 20 * 1024 * 1024);
|
||||
|
||||
function responseHeaders(headers) {
|
||||
const record = {};
|
||||
headers.forEach((value, key) => { record[key] = value; });
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function fetchBrowserResource(url, timeoutMs = HTTP_TIMEOUT) {
|
||||
await assertSafeUrl(url);
|
||||
return browserPool.withBrowser(async browser => {
|
||||
const page = await browser.newPage();
|
||||
const devtools = await page.target().createCDPSession();
|
||||
await devtools.send("Network.enable");
|
||||
await devtools.send("Network.setBlockedURLs", {
|
||||
urls: ["ws://*", "wss://*", "file://*", "ftp://*"]
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const blockedTransport = name => class {
|
||||
constructor() {
|
||||
throw new DOMException(`${name} is disabled by the safe browser fetcher`, "SecurityError");
|
||||
}
|
||||
};
|
||||
for (const name of ["WebSocket", "WebTransport", "RTCPeerConnection", "webkitRTCPeerConnection"]) {
|
||||
if (name in globalThis) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
value: blockedTransport(name)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
await page.setBypassServiceWorker(true);
|
||||
let requests = 0;
|
||||
let totalBytes = 0;
|
||||
let blockedError;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", request => {
|
||||
void (async () => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url());
|
||||
if (!["http:", "https:"].includes(requestUrl.protocol)) throw new Error("unsupported browser request scheme");
|
||||
if (request.method() !== "GET") throw new Error("browser fetch blocks non-GET requests");
|
||||
requests += 1;
|
||||
if (requests > MAX_BROWSER_REQUESTS) throw new Error("browser request limit exceeded");
|
||||
await assertSafeUrl(requestUrl);
|
||||
const upstream = await fetchViaVettedAddress(requestUrl, timeoutMs);
|
||||
const body = Buffer.from(await upstream.arrayBuffer());
|
||||
totalBytes += body.byteLength;
|
||||
if (totalBytes > MAX_BROWSER_TOTAL_BYTES) throw new Error("browser byte limit exceeded");
|
||||
await request.respond({
|
||||
status: upstream.status,
|
||||
headers: responseHeaders(upstream.headers),
|
||||
body
|
||||
});
|
||||
} catch (error) {
|
||||
blockedError ||= error;
|
||||
await request.abort("blockedbyclient").catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
});
|
||||
try {
|
||||
const navigation = await page.goto(url.toString(), {
|
||||
waitUntil: "networkidle2",
|
||||
timeout: timeoutMs
|
||||
});
|
||||
if (blockedError && !navigation) throw blockedError;
|
||||
const finalUrl = new URL(page.url());
|
||||
await assertSafeUrl(finalUrl);
|
||||
const html = await page.content();
|
||||
const buffer = Buffer.from(html);
|
||||
if (buffer.byteLength > MAX_BYTES) throw new Error("rendered content too large");
|
||||
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
||||
const status = navigation?.status() || 200;
|
||||
const response = new Response(new Uint8Array(buffer), { status, headers });
|
||||
Object.defineProperty(response, "url", { value: finalUrl.toString() });
|
||||
return {
|
||||
response,
|
||||
finalUrl: finalUrl.toString(),
|
||||
contentType: headers.get("content-type"),
|
||||
buffer,
|
||||
byteLength: buffer.byteLength
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
63
docker/web-search/overrides/diagnostics.mjs
Normal file
63
docker/web-search/overrides/diagnostics.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const MAX_ERROR_LENGTH = 240;
|
||||
|
||||
export function classifyProviderError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
let category = "provider_error";
|
||||
if (lower.includes("timed out") || lower.includes("timeout")) category = "timeout";
|
||||
else if (lower.includes("429") || lower.includes("rate limit")) category = "rate_limited";
|
||||
else if (lower.includes("captcha") || lower.includes("challenge")) category = "blocked";
|
||||
else if (lower.includes("403") || lower.includes("401") || lower.includes("denied")) category = "forbidden";
|
||||
else if (lower.includes("network") || lower.includes("fetch") || lower.includes("socket")) category = "network";
|
||||
return { category, message: message.replace(/\s+/g, " ").slice(0, MAX_ERROR_LENGTH) };
|
||||
}
|
||||
|
||||
export async function attemptProvider(provider, query, limit, lang, options = {}) {
|
||||
const timeoutMs = Math.max(10, Math.min(options.timeoutMs || DEFAULT_TIMEOUT_MS, 60_000));
|
||||
const now = options.now || (() => performance.now());
|
||||
const started = now();
|
||||
if (provider.configured === false) {
|
||||
return {
|
||||
items: [],
|
||||
diagnostic: { provider: provider.name, status: "unavailable", duration_ms: 0, result_count: 0 }
|
||||
};
|
||||
}
|
||||
let timer;
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const items = await Promise.race([
|
||||
provider.search(query, limit, lang, controller.signal),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(new Error(`provider timed out after ${timeoutMs}ms`));
|
||||
reject(new Error(`provider timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
const bounded = Array.isArray(items) ? items.slice(0, limit) : [];
|
||||
return {
|
||||
items: bounded,
|
||||
diagnostic: {
|
||||
provider: provider.name,
|
||||
status: bounded.length ? "success" : "empty",
|
||||
duration_ms: Math.max(0, Math.round(now() - started)),
|
||||
result_count: bounded.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
items: [],
|
||||
diagnostic: {
|
||||
provider: provider.name,
|
||||
status: "error",
|
||||
duration_ms: Math.max(0, Math.round(now() - started)),
|
||||
result_count: 0,
|
||||
error: classifyProviderError(error)
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
56
docker/web-search/overrides/duckduckgo.js
Normal file
56
docker/web-search/overrides/duckduckgo.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { HTTP_TIMEOUT } from "../constants.js";
|
||||
import { fetchWithTimeout } from "../utils/http.js";
|
||||
import { getRandomUserAgent, getAcceptLanguageHeader } from "../utils/user-agent.js";
|
||||
import { searchCache, createCacheKey } from "../utils/cache.js";
|
||||
|
||||
export class DuckDuckGoProvider {
|
||||
name = "duckduckgo";
|
||||
|
||||
decodeDuckDuckGoRedirect(href) {
|
||||
try {
|
||||
const url = new URL(href, "https://duckduckgo.com/");
|
||||
if (url.hostname === "duckduckgo.com" && url.pathname.startsWith("/l/")) {
|
||||
const target = url.searchParams.get("uddg");
|
||||
if (target) return decodeURIComponent(target);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
async search(q, limit, lang, signal) {
|
||||
const cacheKey = createCacheKey("ddg", q, limit, lang);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const url = new URL("https://html.duckduckgo.com/html/");
|
||||
url.searchParams.set("q", q);
|
||||
const headers = { "User-Agent": getRandomUserAgent(), ...getAcceptLanguageHeader(lang) };
|
||||
const response = await fetchWithTimeout(url, { headers, signal }, HTTP_TIMEOUT);
|
||||
if (!response.ok) throw new Error(`DuckDuckGo HTML ${response.status}`);
|
||||
const dom = new JSDOM(await response.text(), { url: `https://duckduckgo.com/?q=${encodeURIComponent(q)}` });
|
||||
const anchors = Array.from(dom.window.document.querySelectorAll("a.result__a"));
|
||||
const snippets = Array.from(dom.window.document.querySelectorAll(".result__snippet"));
|
||||
const items = [];
|
||||
for (let index = 0; index < anchors.length && items.length < limit; index += 1) {
|
||||
const title = (anchors[index].textContent || "").trim();
|
||||
const href = this.decodeDuckDuckGoRedirect(anchors[index].getAttribute("href") || "");
|
||||
if (!title || !href) continue;
|
||||
try {
|
||||
items.push({
|
||||
title,
|
||||
url: new URL(href).toString(),
|
||||
snippet: (snippets[index]?.textContent || "").trim() || undefined,
|
||||
source: "duckduckgo"
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
searchCache.set(cacheKey, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
65
docker/web-search/overrides/registry.js
Normal file
65
docker/web-search/overrides/registry.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DuckDuckGoProvider } from "./duckduckgo.js";
|
||||
import { BingProvider } from "./bing.js";
|
||||
import { SearXNGProvider } from "./searxng.js";
|
||||
import { BraveProvider } from "./brave.js";
|
||||
import { DEFAULT_SEARCH_PROVIDER } from "../constants.js";
|
||||
import { attemptProvider } from "./diagnostics.js";
|
||||
|
||||
const PROVIDERS = ["searxng", "brave", "duckduckgo", "bing"];
|
||||
const PROVIDER_TIMEOUT_MS = Number(process.env.SEARCH_PROVIDER_TIMEOUT_MS || "15000");
|
||||
const MAX_PROVIDER_ATTEMPTS = Math.max(1, Math.min(Number(process.env.MAX_PROVIDER_ATTEMPTS || "4"), 4));
|
||||
|
||||
export class ProviderRegistry {
|
||||
constructor(providers) {
|
||||
this.providers = providers || new Map([
|
||||
["duckduckgo", new DuckDuckGoProvider()],
|
||||
["bing", new BingProvider()],
|
||||
["searxng", new SearXNGProvider()],
|
||||
["brave", new BraveProvider()]
|
||||
]);
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.providers.get(name);
|
||||
}
|
||||
|
||||
async searchWithFallback(q, limit, lang, preferredProvider) {
|
||||
const defaultProvider = preferredProvider || DEFAULT_SEARCH_PROVIDER;
|
||||
const order = [defaultProvider, ...PROVIDERS.filter(name => name !== defaultProvider)].slice(0, MAX_PROVIDER_ATTEMPTS);
|
||||
const attempts = [];
|
||||
const started = performance.now();
|
||||
for (const providerName of order) {
|
||||
const provider = this.providers.get(providerName);
|
||||
if (!provider) continue;
|
||||
const attempt = await attemptProvider(provider, q, limit, lang, { timeoutMs: PROVIDER_TIMEOUT_MS });
|
||||
attempts.push(attempt.diagnostic);
|
||||
if (attempt.items.length) {
|
||||
return {
|
||||
items: attempt.items,
|
||||
providerUsed: providerName,
|
||||
fallbackUsed: providerName !== defaultProvider,
|
||||
triedProviders: attempts.map(item => item.provider),
|
||||
diagnostics: {
|
||||
attempts,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
exhausted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [],
|
||||
providerUsed: defaultProvider,
|
||||
fallbackUsed: attempts.length > 1,
|
||||
triedProviders: attempts.map(item => item.provider),
|
||||
diagnostics: {
|
||||
attempts,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
exhausted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const providerRegistry = new ProviderRegistry();
|
||||
export { DuckDuckGoProvider, BingProvider, SearXNGProvider, BraveProvider };
|
||||
47
docker/web-search/overrides/searxng.js
Normal file
47
docker/web-search/overrides/searxng.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { HTTP_TIMEOUT, SEARXNG_URL } from "../constants.js";
|
||||
import { fetchWithTimeout } from "../utils/http.js";
|
||||
import { getRandomUserAgent, getAcceptLanguageHeader } from "../utils/user-agent.js";
|
||||
import { searchCache, createCacheKey } from "../utils/cache.js";
|
||||
|
||||
export class SearXNGProvider {
|
||||
name = "searxng";
|
||||
|
||||
constructor(instanceUrl) {
|
||||
this.instanceUrl = instanceUrl || SEARXNG_URL;
|
||||
}
|
||||
|
||||
async search(q, limit, lang, signal) {
|
||||
const cacheKey = createCacheKey("searxng", q, limit, lang);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const params = new URLSearchParams({ q, format: "json", language: lang, safesearch: "0" });
|
||||
const response = await fetchWithTimeout(`${this.instanceUrl}/search?${params}`, {
|
||||
headers: { "User-Agent": getRandomUserAgent(), ...getAcceptLanguageHeader(lang) },
|
||||
signal
|
||||
}, HTTP_TIMEOUT);
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error("SearXNG JSON API disabled");
|
||||
throw new Error(`SearXNG error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items = (data.results || []).slice(0, limit).map(result => ({
|
||||
title: result.title || "",
|
||||
url: result.url || "",
|
||||
snippet: result.content || undefined,
|
||||
source: "searxng"
|
||||
}));
|
||||
searchCache.set(cacheKey, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${this.instanceUrl}/search?q=test&format=json`, {
|
||||
headers: { Accept: "application/json", "User-Agent": getRandomUserAgent() }
|
||||
}, 5000);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@ const replacements = [
|
||||
[
|
||||
"max_download_bytes: z.number().int().min(1).max(26214400).optional()",
|
||||
"max_download_bytes: z.number().int().min(1).max(MAX_BYTES).optional()"
|
||||
],
|
||||
[
|
||||
'provider: z.enum(["duckduckgo", "bing", "searxng"]).optional()',
|
||||
'provider: z.enum(["duckduckgo", "bing", "searxng", "brave"]).optional()'
|
||||
],
|
||||
[
|
||||
"Search the web using multiple providers (DuckDuckGo, Bing, SearXNG). Automatically falls back to other providers if the default fails. No API keys required for DuckDuckGo and SearXNG.",
|
||||
"Search the web with bounded provider fallback and per-attempt diagnostics. SearXNG is local; Brave is available when BRAVE_SEARCH_API_KEY is configured."
|
||||
]
|
||||
];
|
||||
|
||||
@@ -26,3 +34,44 @@ for (const [before, after] of replacements) {
|
||||
}
|
||||
|
||||
fs.writeFileSync(serverPath, source);
|
||||
|
||||
const httpPath = "/usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/fetch/http.js";
|
||||
let httpSource = fs.readFileSync(httpPath, "utf8");
|
||||
const privateTransport = "async function fetchViaVettedAddress(url, timeoutMs)";
|
||||
if (!httpSource.includes(privateTransport)) throw new Error(`mcp-web-search patch target not found: ${privateTransport}`);
|
||||
httpSource = httpSource.replace(privateTransport, "export async function fetchViaVettedAddress(url, timeoutMs)");
|
||||
fs.writeFileSync(httpPath, httpSource);
|
||||
|
||||
const utilityHttpPath = "/usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/utils/http.js";
|
||||
let utilityHttpSource = fs.readFileSync(utilityHttpPath, "utf8");
|
||||
const uncombinedSignal = 'return await fetch(input, { ...init, signal: controller.signal });';
|
||||
const combinedSignal = 'const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;\n return await fetch(input, { ...init, signal });';
|
||||
if (!utilityHttpSource.includes(uncombinedSignal)) throw new Error(`mcp-web-search patch target not found: ${uncombinedSignal}`);
|
||||
utilityHttpSource = utilityHttpSource.replace(uncombinedSignal, combinedSignal);
|
||||
fs.writeFileSync(utilityHttpPath, utilityHttpSource);
|
||||
|
||||
const extractPath = "/usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/extract.js";
|
||||
let extractSource = fs.readFileSync(extractPath, "utf8");
|
||||
const extractReplacements = [
|
||||
[
|
||||
'import { assertSafeUrl } from "./fetch/security.js";',
|
||||
'import { assertSafeUrl } from "./fetch/security.js";\nimport { fetchBrowserResource } from "./fetch/browser.js";\nimport { boundFetchCollections } from "./fetch/bounds.js";'
|
||||
],
|
||||
[
|
||||
"fetchCache.set(cacheKey, siteResult);\n return siteResult;",
|
||||
"const boundedSiteResult = boundFetchCollections(siteResult);\n fetchCache.set(cacheKey, boundedSiteResult);\n return boundedSiteResult;"
|
||||
],
|
||||
[
|
||||
"const resource = await fetchResource(parsedUrl, options?.timeout_ms, transport, options);",
|
||||
'const resource = options?.engine === "browser"\n ? await fetchBrowserResource(parsedUrl, options?.timeout_ms)\n : await fetchResource(parsedUrl, options?.timeout_ms, transport, options);'
|
||||
],
|
||||
[
|
||||
"fetchCache.set(cacheKey, result);\n return result;",
|
||||
"result = boundFetchCollections(result);\n fetchCache.set(cacheKey, result);\n return result;"
|
||||
]
|
||||
];
|
||||
for (const [before, after] of extractReplacements) {
|
||||
if (!extractSource.includes(before)) throw new Error(`mcp-web-search extract patch target not found: ${before}`);
|
||||
extractSource = extractSource.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(extractPath, extractSource);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use_default_settings: true
|
||||
use_default_settings:
|
||||
engines:
|
||||
keep_only:
|
||||
- duckduckgo
|
||||
- bing
|
||||
- google
|
||||
|
||||
general:
|
||||
debug: false
|
||||
|
||||
Reference in New Issue
Block a user