search-rotation 0.4.9

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/abort.js +14 -0
  4. package/dist/config.js +109 -0
  5. package/dist/engines/base.js +72 -0
  6. package/dist/engines/duckduckgo.js +98 -0
  7. package/dist/engines/exa.js +113 -0
  8. package/dist/engines/firecrawl.js +78 -0
  9. package/dist/engines/googlecse.js +41 -0
  10. package/dist/engines/hosted-mcp.js +24 -0
  11. package/dist/engines/index.js +13 -0
  12. package/dist/engines/jina.js +28 -0
  13. package/dist/engines/parallel.js +74 -0
  14. package/dist/engines/tavily.js +74 -0
  15. package/dist/history.js +59 -0
  16. package/dist/index.js +182 -0
  17. package/dist/mcp/engine-status.js +58 -0
  18. package/dist/mcp/http.js +35 -0
  19. package/dist/mcp/server.js +105 -0
  20. package/dist/persistence.js +80 -0
  21. package/dist/quota.js +37 -0
  22. package/dist/router.js +183 -0
  23. package/dist/search-time.js +31 -0
  24. package/dist/status.js +141 -0
  25. package/dist/types.js +1 -0
  26. package/dist/usage.js +95 -0
  27. package/dist/version.js +5 -0
  28. package/dist/web/app.js +155 -0
  29. package/dist/web/security.js +116 -0
  30. package/docs/assets/brand/FONT-LICENSE.txt +93 -0
  31. package/docs/assets/brand/README.md +18 -0
  32. package/docs/assets/brand/icon-128.png +0 -0
  33. package/docs/assets/brand/icon-32.png +0 -0
  34. package/docs/assets/brand/icon-512.png +0 -0
  35. package/docs/assets/brand/icon-dark-128.png +0 -0
  36. package/docs/assets/brand/icon-dark-512.png +0 -0
  37. package/docs/assets/brand/icon-dark.svg +1 -0
  38. package/docs/assets/brand/icon-light-128.png +0 -0
  39. package/docs/assets/brand/icon-light-512.png +0 -0
  40. package/docs/assets/brand/icon-light.svg +1 -0
  41. package/docs/assets/brand/icon-mono.svg +1 -0
  42. package/docs/assets/brand/icon.svg +1 -0
  43. package/docs/assets/brand/preview.png +0 -0
  44. package/docs/assets/brand/preview.svg +1 -0
  45. package/docs/assets/brand/wordmark-dark.svg +1 -0
  46. package/docs/assets/brand/wordmark-light.svg +1 -0
  47. package/docs/assets/dashboard-dark.png +0 -0
  48. package/docs/assets/dashboard-light.png +0 -0
  49. package/docs/assets/dashboard.png +0 -0
  50. package/docs/assets/mcp-tools-dark.png +0 -0
  51. package/docs/assets/mcp-tools-light.png +0 -0
  52. package/docs/assets/mcp-tools.png +0 -0
  53. package/docs/assets/search-rotation.svg +52 -0
  54. package/docs/clients.md +93 -0
  55. package/docs/operations.md +66 -0
  56. package/docs/provider-access.md +38 -0
  57. package/docs/release-notes.md +52 -0
  58. package/package.json +65 -0
  59. package/static/app.js +1126 -0
  60. package/static/engine-logos/README.md +11 -0
  61. package/static/engine-logos/duckduckgo.png +0 -0
  62. package/static/engine-logos/exa.png +0 -0
  63. package/static/engine-logos/firecrawl.png +0 -0
  64. package/static/engine-logos/google-cse.png +0 -0
  65. package/static/engine-logos/jina.png +0 -0
  66. package/static/engine-logos/parallel.png +0 -0
  67. package/static/engine-logos/tavily.png +0 -0
  68. package/static/i18n.js +709 -0
  69. package/static/index.html +95 -0
  70. package/static/style.css +1258 -0
@@ -0,0 +1,24 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { VERSION } from "../version.js";
4
+ export async function withHostedMcp(endpoint, ctx, timeoutMs, fn) {
5
+ const timeout = AbortSignal.timeout(timeoutMs);
6
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeout]) : timeout;
7
+ signal.throwIfAborted();
8
+ const client = new Client({ name: "search-rotation", version: VERSION });
9
+ const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
10
+ // Der SDK-Request-Abbruch allein beendet keinen hängenden HTTP-Handshake.
11
+ fetch: (input, init) => {
12
+ const inputSignal = input instanceof Request ? input.signal : undefined;
13
+ const signals = [signal, init?.signal, inputSignal].filter((s) => Boolean(s));
14
+ return fetch(input, { ...init, signal: AbortSignal.any(signals) });
15
+ },
16
+ });
17
+ try {
18
+ await client.connect(transport, { signal, timeout: timeoutMs });
19
+ return await fn(client, signal);
20
+ }
21
+ finally {
22
+ await client.close().catch(() => { });
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ import { TAVILY } from "./tavily.js";
2
+ import { FIRECRAWL } from "./firecrawl.js";
3
+ import { PARALLEL } from "./parallel.js";
4
+ import { EXA } from "./exa.js";
5
+ import { GOOGLE_CSE } from "./googlecse.js";
6
+ import { JINA } from "./jina.js";
7
+ import { DUCKDUCKGO } from "./duckduckgo.js";
8
+ export const ADAPTERS = [TAVILY, FIRECRAWL, PARALLEL, EXA, GOOGLE_CSE, JINA, DUCKDUCKGO];
9
+ /** Alle Engine-Ids — auch fetch-only (Jina), die tauchen nicht in SEARCH_ORDER auf. */
10
+ export const KNOWN_IDS = ADAPTERS.map((a) => a.meta.id);
11
+ export const SEARCH_ORDER = ["tavily", "firecrawl", "parallel", "exa", "google-cse", "duckduckgo"];
12
+ export const FETCH_ORDER = ["jina", "firecrawl", "parallel", "tavily", "exa"];
13
+ export const DEFAULT_ENABLED = Object.fromEntries(ADAPTERS.map(a => [a.meta.id, a.meta.defaultEnabled ?? a.meta.keyless === "ip"]));
@@ -0,0 +1,28 @@
1
+ import { httpText } from "./base.js";
2
+ async function fetchUrl(input, ctx) {
3
+ const headers = { accept: "text/markdown" };
4
+ if (ctx.apiKey)
5
+ headers.authorization = `Bearer ${ctx.apiKey}`;
6
+ const text = await httpText(`https://r.jina.ai/${input.url}`, { headers }, {
7
+ signal: ctx.signal,
8
+ timeoutMs: 45_000,
9
+ });
10
+ if (!text.trim())
11
+ throw new Error("jina: leere Antwort");
12
+ return text;
13
+ }
14
+ export const JINA = {
15
+ meta: {
16
+ id: "jina",
17
+ label: "Jina Reader",
18
+ homepage: "https://jina.ai/reader",
19
+ signupUrl: "https://jina.ai/reader",
20
+ keyless: "ip",
21
+ capabilities: ["fetch"],
22
+ monthlyFree: 0,
23
+ quota: { period: "ip", unit: "requests", estimated: true },
24
+ quotaEndpoint: false,
25
+ notes: "Ohne Key IP-basiert (~20 Requests/Minute), mit Key höhere Limits. Nur fürs Abrufen von Seiten.",
26
+ },
27
+ fetchUrl,
28
+ };
@@ -0,0 +1,74 @@
1
+ import { optionalCap as cap, httpJson, NeedsKeyError } from "./base.js";
2
+ import { withHostedMcp } from "./hosted-mcp.js";
3
+ const SIGNUP = "https://platform.parallel.ai";
4
+ const BASE = "https://api.parallel.ai/v1";
5
+ async function search(input, ctx) {
6
+ const j = !ctx.apiKey ? await withHostedMcp("https://search.parallel.ai/mcp", ctx, 30_000, async (client, signal) => {
7
+ const result = await client.callTool({ name: "web_search", arguments: { objective: input.query, search_queries: [input.query] } }, undefined, { timeout: 30_000, signal });
8
+ if (result.isError)
9
+ throw new Error("parallel-mcp: " + result.content.filter(c => c.type === "text").map(c => c.text).join("\n").slice(0, 300));
10
+ const data = result.structuredContent;
11
+ if (!Array.isArray(data?.results))
12
+ throw new Error("parallel-mcp: invalid search response");
13
+ return data;
14
+ }) : await httpJson(`${BASE}/search`, {
15
+ method: "POST",
16
+ headers: { "content-type": "application/json", "x-api-key": ctx.apiKey },
17
+ body: JSON.stringify({
18
+ objective: input.query,
19
+ search_queries: [input.query],
20
+ mode: "fast",
21
+ max_results: cap(input.numResults),
22
+ max_chars_per_result: 400,
23
+ }),
24
+ }, { signal: ctx.signal });
25
+ const items = (Array.isArray(j?.results) ? j.results : []).slice(0, cap(input.numResults)).map((r) => ({
26
+ title: r?.title || r?.url || "(ohne Titel)",
27
+ url: String(r?.url ?? ""),
28
+ snippet: Array.isArray(r?.excerpts) ? r.excerpts.join(" … ").slice(0, 600) : undefined,
29
+ published: typeof r?.publish_date === "string" ? r.publish_date : undefined,
30
+ }));
31
+ return { items };
32
+ }
33
+ async function fetchUrl(input, ctx) {
34
+ if (!ctx.apiKey)
35
+ throw new NeedsKeyError("parallel", SIGNUP);
36
+ const j = await httpJson(`${BASE}/extract`, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json", "x-api-key": ctx.apiKey },
39
+ body: JSON.stringify({ urls: [input.url], advanced_settings: { full_content: true } }),
40
+ }, { signal: ctx.signal, timeoutMs: 45_000 });
41
+ const r0 = j?.results?.[0];
42
+ const md = typeof r0?.full_content === "string" && r0.full_content
43
+ ? r0.full_content
44
+ : Array.isArray(r0?.excerpts)
45
+ ? r0.excerpts.join("\n\n")
46
+ : "";
47
+ if (!md.trim()) {
48
+ // Parallel liefert Fehler pro URL im errors[]-Array — mit ausgeben.
49
+ const errs = Array.isArray(j?.errors) ? j.errors : [];
50
+ const detail = errs
51
+ .map((e) => `${e?.error_type ?? "error"}: ${e?.content ?? ""}`.trim())
52
+ .filter(Boolean)
53
+ .join("; ");
54
+ throw new Error("parallel: kein Inhalt extrahiert" + (detail ? ` (${detail.slice(0, 200)})` : ""));
55
+ }
56
+ return md;
57
+ }
58
+ export const PARALLEL = {
59
+ meta: {
60
+ id: "parallel",
61
+ label: "Parallel",
62
+ homepage: "https://parallel.ai",
63
+ signupUrl: SIGNUP,
64
+ keyless: "ip",
65
+ keylessCapabilities: ["search"],
66
+ capabilities: ["search", "fetch"],
67
+ monthlyFree: 5000,
68
+ quota: { period: "month", unit: "requests", limit: 5000, estimated: true },
69
+ quotaEndpoint: false,
70
+ notes: "Suche ohne Key über gehosteten Parallel-MCP. Fetch nutzt die direkte API mit Key. Ohne Key werden nur lokale Aufrufe gezählt; Anbieterlimit und Gesamtverbrauch unbekannt.",
71
+ },
72
+ search,
73
+ fetchUrl,
74
+ };
@@ -0,0 +1,74 @@
1
+ import { bearer, optionalCap as cap, httpJson, NeedsKeyError } from "./base.js";
2
+ const SIGNUP = "https://app.tavily.com";
3
+ const BASE = "https://api.tavily.com";
4
+ async function search(input, ctx) {
5
+ const j = await httpJson(`${BASE}/search`, {
6
+ method: "POST",
7
+ headers: { "content-type": "application/json", "X-Client-Name": "search-rotation",
8
+ ...(ctx.apiKey ? bearer(ctx.apiKey) : { "X-Tavily-Access-Mode": "keyless" }) },
9
+ body: JSON.stringify({
10
+ query: input.query,
11
+ start_date: input.startDate,
12
+ end_date: input.endDate,
13
+ max_results: cap(input.numResults),
14
+ ...(ctx.apiKey ? { include_answer: true } : { chunks_per_source: 3 }),
15
+ search_depth: "basic",
16
+ }),
17
+ }, { signal: ctx.signal });
18
+ const items = (Array.isArray(j?.results) ? j.results : []).map((r) => ({
19
+ title: r?.title || r?.url || "(ohne Titel)",
20
+ url: String(r?.url ?? ""),
21
+ snippet: typeof r?.content === "string" ? r.content.slice(0, 500) : undefined,
22
+ published: typeof r?.published_date === "string" ? r.published_date : undefined,
23
+ }));
24
+ return { items, answer: typeof j?.answer === "string" && j.answer ? j.answer : undefined };
25
+ }
26
+ async function fetchUrl(input, ctx) {
27
+ if (!ctx.apiKey)
28
+ throw new NeedsKeyError("tavily", SIGNUP);
29
+ const j = await httpJson(`${BASE}/extract`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json", ...bearer(ctx.apiKey) },
32
+ body: JSON.stringify({ urls: [input.url] }),
33
+ }, { signal: ctx.signal });
34
+ const content = j?.results?.[0]?.raw_content;
35
+ if (typeof content !== "string" || !content.trim())
36
+ throw new Error("tavily: kein Inhalt extrahiert");
37
+ return content;
38
+ }
39
+ async function remoteQuota(ctx) {
40
+ if (!ctx.apiKey)
41
+ throw new NeedsKeyError("tavily", SIGNUP);
42
+ const j = await httpJson(`${BASE}/usage`, { headers: bearer(ctx.apiKey) }, { signal: ctx.signal });
43
+ // Response: { key: { usage, limit, search_usage, ... }, account: { current_plan, plan_usage, plan_limit, ... } }
44
+ const ku = Array.isArray(j?.key_usage) ? j.key_usage[0] : j?.key_usage;
45
+ const used = pickNumber(j?.key?.usage, ku?.usage, j?.usage);
46
+ const limit = pickNumber(j?.key?.limit, ku?.limit, j?.limit);
47
+ return { used, limit, remaining: used !== undefined && limit !== undefined ? limit - used : undefined };
48
+ }
49
+ function pickNumber(...vals) {
50
+ for (const v of vals) {
51
+ if (typeof v === "number" && Number.isFinite(v))
52
+ return v;
53
+ }
54
+ return undefined;
55
+ }
56
+ export const TAVILY = {
57
+ meta: {
58
+ id: "tavily",
59
+ label: "Tavily",
60
+ homepage: "https://www.tavily.com",
61
+ signupUrl: SIGNUP,
62
+ keyless: "ip",
63
+ keylessCapabilities: ["search"],
64
+ capabilities: ["search", "fetch"],
65
+ monthlyFree: 1000,
66
+ quota: { period: "month", unit: "credits", limit: 1000, estimated: true, costs: { search: 1, fetch: 0.2 } },
67
+ quotaEndpoint: true,
68
+ notes: "Suche ohne Key im Keyless-Modus; Limit und Gesamtverbrauch unbekannt. Fetch benötigt einen Key. Mit Key ist die Quota per API abrufbar.",
69
+ },
70
+ supportsSearchTime: () => true,
71
+ search,
72
+ fetchUrl,
73
+ remoteQuota,
74
+ };
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs";
2
+ import { atomicWriteFile, withFileLock } from "./persistence.js";
3
+ const MAX_ITEMS = 20;
4
+ const MAX_MARKDOWN = 3000;
5
+ /**
6
+ * Verlauf der Such-/Fetch-Aufrufe als Ring-Puffer in einer JSON-Datei.
7
+ * Serialisiert Lesen, Schreiben und Löschen über eine Prozesssperre.
8
+ */
9
+ export class HistoryStore {
10
+ file;
11
+ maxEntries;
12
+ constructor(file, maxEntries = 500) {
13
+ this.file = file;
14
+ this.maxEntries = maxEntries;
15
+ }
16
+ read() {
17
+ try {
18
+ const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
19
+ if (Array.isArray(raw))
20
+ return raw;
21
+ }
22
+ catch (error) {
23
+ // Keep missing/corrupt-file recovery; an I/O failure must not cause a
24
+ // successful later write to replace an unread existing snapshot.
25
+ if (!(error instanceof SyntaxError) && error.code !== "ENOENT")
26
+ throw error;
27
+ }
28
+ return [];
29
+ }
30
+ write(entries) {
31
+ atomicWriteFile(this.file, JSON.stringify(entries));
32
+ }
33
+ record(entry) {
34
+ const capped = { ...entry };
35
+ if (capped.result?.items && capped.result.items.length > MAX_ITEMS) {
36
+ capped.result = { ...capped.result, items: capped.result.items.slice(0, MAX_ITEMS) };
37
+ }
38
+ if (capped.result?.markdown && capped.result.markdown.length > MAX_MARKDOWN) {
39
+ capped.result = { ...capped.result, markdown: capped.result.markdown.slice(0, MAX_MARKDOWN) };
40
+ }
41
+ try {
42
+ withFileLock(this.file, () => {
43
+ const entries = this.read().filter((e) => e && typeof e.ts === "string");
44
+ entries.unshift({ ...capped, ts: new Date().toISOString() });
45
+ this.write(entries.slice(0, this.maxEntries));
46
+ });
47
+ }
48
+ catch {
49
+ // Optional history must never turn a successful provider request into failover.
50
+ process.stderr.write("search-rotation: history persistence failed\n");
51
+ }
52
+ }
53
+ list(limit = 50) {
54
+ return this.read().slice(0, Math.min(Math.max(limit, 1), 200));
55
+ }
56
+ clear() {
57
+ withFileLock(this.file, () => fs.rmSync(this.file, { force: true }));
58
+ }
59
+ }
package/dist/index.js ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { createServer } from "node:net";
4
+ import { spawn } from "node:child_process";
5
+ import path from "node:path";
6
+ import { serve } from "@hono/node-server";
7
+ import { Hono } from "hono";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { ConfigStore, configDir } from "./config.js";
10
+ import { ADAPTERS, SEARCH_ORDER, FETCH_ORDER, DEFAULT_ENABLED, KNOWN_IDS } from "./engines/index.js";
11
+ import { UsageStore } from "./usage.js";
12
+ import { HistoryStore } from "./history.js";
13
+ import { SearchRouter, RouterError } from "./router.js";
14
+ import { buildStatus, clearRemoteQuotaCache } from "./status.js";
15
+ import { buildMcpServer } from "./mcp/server.js";
16
+ import { mountMcpHttp } from "./mcp/http.js";
17
+ import { buildWebApp } from "./web/app.js";
18
+ import { VERSION } from "./version.js";
19
+ import { mountSecurity } from "./web/security.js";
20
+ function findFreePort(start, host, tries = 20) {
21
+ return new Promise((resolve, reject) => {
22
+ const attempt = (port) => {
23
+ if (port > start + tries) {
24
+ reject(new Error(`Kein freier Port ab ${start} gefunden`));
25
+ return;
26
+ }
27
+ const srv = createServer();
28
+ srv.unref();
29
+ srv.on("error", () => attempt(port + 1));
30
+ srv.listen(port, host, () => {
31
+ srv.close(() => resolve(port));
32
+ });
33
+ };
34
+ attempt(start);
35
+ });
36
+ }
37
+ function openBrowser(url) {
38
+ try {
39
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
40
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
41
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
42
+ // spawn failures arrive asynchronously; never log the one-time login URL.
43
+ child.on("error", () => console.error("[search-rotation] Browser konnte nicht geöffnet werden; Dashboard-URL manuell öffnen."));
44
+ child.unref();
45
+ }
46
+ catch {
47
+ console.error("[search-rotation] Browser konnte nicht geöffnet werden; Dashboard-URL manuell öffnen.");
48
+ }
49
+ }
50
+ async function main() {
51
+ const args = parseArgs({
52
+ options: {
53
+ http: { type: "boolean", default: false },
54
+ port: { type: "string" },
55
+ host: { type: "string" },
56
+ "public-origin": { type: "string" },
57
+ token: { type: "string" },
58
+ open: { type: "boolean", default: false },
59
+ "no-dashboard": { type: "boolean", default: false },
60
+ },
61
+ }).values;
62
+ const store = new ConfigStore();
63
+ const defaults = {
64
+ knownIds: KNOWN_IDS,
65
+ searchOrder: SEARCH_ORDER,
66
+ fetchOrder: FETCH_ORDER,
67
+ defaultEnabled: DEFAULT_ENABLED,
68
+ requiredKeyIds: ADAPTERS.filter(a => a.meta.keyless === "no").map(a => a.meta.id),
69
+ };
70
+ let cfg = store.load(defaults);
71
+ const getConfig = () => (cfg = store.load(defaults));
72
+ const usage = new UsageStore(configDir());
73
+ const history = new HistoryStore(path.join(configDir(), "history.json"));
74
+ const router = new SearchRouter({ getConfig, usage, adapters: ADAPTERS, history });
75
+ const month = () => usage.monthKey();
76
+ const status = () => buildStatus(getConfig(), usage, ADAPTERS);
77
+ const testEngine = async (id, kind, arg, signal) => {
78
+ const adapter = ADAPTERS.find(a => a.meta.id === id);
79
+ if (!adapter)
80
+ return { ok: false, ms: 0, error: "unbekannte Engine" };
81
+ if (kind === "search" && !adapter.search)
82
+ return { ok: false, ms: 0, error: "Engine kann nicht suchen" };
83
+ if (kind === "fetch" && !adapter.fetchUrl)
84
+ return { ok: false, ms: 0, error: "Engine kann nicht fetchen" };
85
+ const start = Date.now();
86
+ try {
87
+ if (kind === "search") {
88
+ const out = await router.search({ query: arg || "model context protocol", numResults: 3 }, { onlyEngine: id, signal });
89
+ return { ok: true, ms: Date.now() - start, count: out.items.length,
90
+ preview: out.items.slice(0, 3).map(item => `${item.title} — ${item.url}`).join("\n") };
91
+ }
92
+ const out = await router.fetchUrl({ url: arg || "https://example.com" }, { onlyEngine: id, signal });
93
+ return { ok: true, ms: Date.now() - start, chars: out.markdown.length, preview: out.markdown.slice(0, 400) };
94
+ }
95
+ catch (err) {
96
+ const detail = err instanceof RouterError ? err.attempts.map(a => a.error).filter(Boolean).join("; ") : "";
97
+ const message = err instanceof Error ? err.message : String(err);
98
+ return { ok: false, ms: Date.now() - start, error: detail || message };
99
+ }
100
+ };
101
+ const token = args.token ?? process.env.SEARCH_ROTATION_TOKEN ?? cfg.settings.token;
102
+ const host = args.host ?? "127.0.0.1";
103
+ const dashboardEnabled = !args["no-dashboard"];
104
+ const loopback = ["127.0.0.1", "localhost", "::1"].includes(host);
105
+ if (!loopback && !token)
106
+ throw new Error("Remote-Bind benötigt --token oder SEARCH_ROTATION_TOKEN");
107
+ // HTTP-Server nötig für das Dashboard UND für --http — sonst hat der Prozess
108
+ // kein Keep-Alive-Handle und beendet sich sofort (--http --no-dashboard-Fix).
109
+ const httpNeeded = dashboardEnabled || args.http;
110
+ const wantedPort = args.port !== undefined ? Number(args.port) : cfg.settings.port;
111
+ const basePort = Number.isInteger(wantedPort) && wantedPort >= 1024 && wantedPort <= 65535
112
+ ? wantedPort
113
+ : cfg.settings.port;
114
+ const port = httpNeeded ? await findFreePort(basePort, host) : 0;
115
+ const browserHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
116
+ const localOrigin = `http://${browserHost.includes(":") ? `[${browserHost}]` : browserHost}:${port}`;
117
+ const publicOrigin = args["public-origin"] ? new URL(args["public-origin"]) : new URL(localOrigin);
118
+ if (!["http:", "https:"].includes(publicOrigin.protocol) || publicOrigin.username || publicOrigin.password || publicOrigin.search || publicOrigin.hash || publicOrigin.pathname !== "/") {
119
+ throw new Error("--public-origin muss eine HTTP(S)-Origin ohne Pfad, Zugangsdaten oder Query sein");
120
+ }
121
+ const origin = publicOrigin.origin;
122
+ const dashboardUrl = () => dashboardEnabled ? `${origin}/` : null;
123
+ const app = new Hono();
124
+ const security = mountSecurity(app, { token, origin, additionalOrigins: [localOrigin], dashboardEnabled });
125
+ const mcpDeps = {
126
+ router, status, month, dashboardUrl,
127
+ openDashboard: () => {
128
+ if (dashboardEnabled)
129
+ openBrowser(security.browserUrl());
130
+ },
131
+ };
132
+ app.route("/", buildWebApp({
133
+ configPath: store.file,
134
+ getConfig,
135
+ saveConfig: (next) => {
136
+ store.save(next);
137
+ cfg = next;
138
+ // Neuer Key? Dann Remote-Quota sofort frisch ziehen statt 5 Min Cache.
139
+ clearRemoteQuotaCache();
140
+ },
141
+ adapters: ADAPTERS,
142
+ status,
143
+ month,
144
+ testEngine,
145
+ historyList: (limit) => history.list(limit),
146
+ historyClear: () => history.clear(),
147
+ }));
148
+ mountMcpHttp(app, mcpDeps, security);
149
+ if (httpNeeded) {
150
+ const srv = serve({ fetch: app.fetch, port, hostname: host });
151
+ // TOCTOU-Restrisiko: wird der Port zwischen findFreePort und Binden doch
152
+ // belegt, laut statt still mit Stacktrace sterben.
153
+ srv.on("error", (err) => {
154
+ console.error(`[search-rotation] fatal: HTTP-Server auf ${host}:${port} fehlgeschlagen (${err.code ?? err.message})`);
155
+ process.exit(1);
156
+ });
157
+ if (dashboardEnabled)
158
+ console.error(`[search-rotation] Dashboard: ${dashboardUrl()}`);
159
+ }
160
+ if (args.http) {
161
+ console.error(`[search-rotation] v${VERSION} — MCP (Streamable HTTP): http://${host}:${port}/mcp` +
162
+ (token ? " (Bearer-Token nötig)" : ""));
163
+ }
164
+ else {
165
+ // Exit, sobald der MCP-Client stdin schließt — vor connect registrieren,
166
+ // damit kein Fenster bleibt, in dem der Prozess hängen bleibt.
167
+ const exit = () => process.exit(0);
168
+ process.stdin.on("end", exit);
169
+ process.stdin.on("close", exit);
170
+ process.stdin.on("error", exit);
171
+ const mcp = buildMcpServer(mcpDeps);
172
+ await mcp.connect(new StdioServerTransport());
173
+ console.error(`[search-rotation] v${VERSION} — MCP stdio aktiv.` +
174
+ (dashboardEnabled ? ` Dashboard: ${dashboardUrl()}` : ""));
175
+ }
176
+ if (args.open && dashboardEnabled)
177
+ mcpDeps.openDashboard();
178
+ }
179
+ main().catch((err) => {
180
+ console.error(`[search-rotation] fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
181
+ process.exit(1);
182
+ });
@@ -0,0 +1,58 @@
1
+ /** Model-facing facts. Configuration and stored diagnostics are not live health checks. */
2
+ export function engineStatus(rows, month, version) {
3
+ return {
4
+ version,
5
+ localUsageMonth: month,
6
+ interpretation: [
7
+ "enabled is a configuration toggle, not a health check. Disabled does not mean an API key is missing; the original reason for disabling is not recorded.",
8
+ "Access is capability-specific. keyless search does not imply keyless fetch. Recommend a key only for a capability marked api_key_required, or when the user explicitly wants authenticated access.",
9
+ "Historical errors are stored observations, not evidence of a current failure or the current authentication requirements. No live search or fetch is performed by this tool.",
10
+ "includedInRotation describes configuration eligibility only. Runtime cooldowns, quota checks and failover may skip an engine.",
11
+ "Local usage counts successful calls from this installation, separately from errors. Unknown provider limits and total usage are null, not zero or unlimited. Provider account balances must not be described as monthly consumption.",
12
+ ],
13
+ engines: rows.map(row => {
14
+ const supported = row.supportedCapabilities ?? row.capabilities ?? [];
15
+ const keyless = row.keylessCapabilities ?? (row.keyless === "ip" ? row.capabilities ?? [] : []);
16
+ const missingExtraConfiguration = (row.extraFields ?? []).filter(field => !row.extrasSet?.[field.key]).map(field => field.key);
17
+ const access = Object.fromEntries(["search", "fetch"].map(capability => {
18
+ const mode = !supported.includes(capability) ? "unsupported"
19
+ : !row.hasKey && !keyless.includes(capability) ? "api_key_required"
20
+ : missingExtraConfiguration.length ? "extra_configuration_required"
21
+ : row.hasKey ? "configured_key" : "keyless";
22
+ const position = capability === "search" ? row.searchPosition : row.fetchPosition;
23
+ return [capability, { mode, includedInRotation: row.enabled && (mode === "keyless" || mode === "configured_key") && position >= 0,
24
+ configuredOrder: position >= 0 ? position + 1 : null }];
25
+ }));
26
+ const q = row.quota;
27
+ const anonymous = !row.hasKey;
28
+ const remote = !anonymous && q?.source === "remote";
29
+ return {
30
+ id: row.id,
31
+ name: row.label,
32
+ enabled: row.enabled,
33
+ disabledReason: row.enabled ? null : "disabled_in_configuration; original reason not recorded",
34
+ apiKeyConfigured: row.hasKey,
35
+ missingExtraConfiguration,
36
+ access,
37
+ localUsage: { successfulSearches: row.used.search, successfulFetches: row.used.fetch, failedAttempts: row.used.errors ?? 0 },
38
+ quota: {
39
+ scope: anonymous ? "provider_limit_unknown" : remote ? "provider_account_balance" : "local_allowance",
40
+ period: anonymous || remote ? null : q?.period ?? null,
41
+ unit: anonymous ? "requests" : q?.unit ?? "requests",
42
+ limit: anonymous ? null : q?.limit ?? null,
43
+ used: anonymous ? null : q?.used ?? null,
44
+ providerTotalUsed: remote ? q.used : null,
45
+ source: anonymous ? "unknown" : q?.source ?? "unknown",
46
+ estimated: anonymous ? false : q?.estimated ?? true,
47
+ timeZone: q?.timeZone ?? null,
48
+ },
49
+ diagnostics: {
50
+ currentHealth: "not_tested",
51
+ lastRecordedError: row.lastError ?? null,
52
+ lastSuccessfulCallAt: row.used.lastUsed ?? null,
53
+ quotaLookupError: row.remoteError ?? null,
54
+ },
55
+ };
56
+ }),
57
+ };
58
+ }
@@ -0,0 +1,35 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
+ import { buildMcpServer } from "./server.js";
3
+ /**
4
+ * Mountet den MCP-Endpoint (Streamable HTTP, stateless) unter /mcp.
5
+ * Pro Request wird eine frische Transport-/Server-Instanz erzeugt
6
+ * (dokumentiertes Stateless-Pattern des SDK).
7
+ */
8
+ export function mountMcpHttp(app, deps, security) {
9
+ app.all("/mcp", async (c) => {
10
+ // Stateless-Modus: es gibt keinen langlebigen SSE-Kanal. Ein GET mit
11
+ // Accept: text/event-stream würde vom Client als reconnectabler Stream
12
+ // interpretiert → Reconnect-Loop. Deshalb sofort 405.
13
+ if (c.req.method === "GET") {
14
+ return c.text("405 — SSE-Streaming im Stateless-Modus nicht unterstützt; JSON-RPC per POST senden.", 405, { allow: "POST" });
15
+ }
16
+ const server = buildMcpServer({ ...deps, requestSignal: c.req.raw.signal });
17
+ const transport = new WebStandardStreamableHTTPServerTransport({
18
+ sessionIdGenerator: undefined,
19
+ enableJsonResponse: true,
20
+ enableDnsRebindingProtection: true,
21
+ allowedHosts: security?.allowedHosts ?? ["127.0.0.1", "localhost"],
22
+ allowedOrigins: security?.allowedOrigins ?? ["http://127.0.0.1", "http://localhost"],
23
+ });
24
+ await server.connect(transport);
25
+ try {
26
+ return await transport.handleRequest(c.req.raw);
27
+ }
28
+ finally {
29
+ setTimeout(() => {
30
+ void transport.close().catch(() => { });
31
+ void server.close().catch(() => { });
32
+ }, 0);
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,105 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { RouterError } from "../router.js";
4
+ import { engineStatus } from "./engine-status.js";
5
+ import { dateSchema, timeRangeSchema, describeSearchTime } from "../search-time.js";
6
+ import { VERSION } from "../version.js";
7
+ import { readFileSync } from "node:fs";
8
+ // Inline packaged icons: clients need no dashboard connection or external image host.
9
+ const icons = ["light", "dark"].map(theme => ({
10
+ src: `data:image/png;base64,${readFileSync(new URL(`../../docs/assets/brand/icon-${theme}-128.png`, import.meta.url)).toString("base64")}`,
11
+ mimeType: "image/png",
12
+ sizes: ["128x128"],
13
+ theme,
14
+ }));
15
+ async function withRouterDiagnostics(operation) {
16
+ try {
17
+ return await operation;
18
+ }
19
+ catch (error) {
20
+ if (error instanceof RouterError && error.attempts.length) {
21
+ throw new Error(`${error.message}\n${error.attempts.map(a => `${a.engine}: ${a.error ?? (a.ok ? "ok" : "fehlgeschlagen")}`).join("\n")}`);
22
+ }
23
+ throw error;
24
+ }
25
+ }
26
+ export function buildMcpServer(deps) {
27
+ const server = new McpServer({ name: "search-rotation", version: VERSION, icons });
28
+ server.registerTool("web_search", {
29
+ title: "Web Search",
30
+ description: "Web search with automatic round-robin across multiple free search APIs (Tavily, Firecrawl, Parallel, Exa, Google PSE, DuckDuckGo) including transparent failover. Returns numbered results with title, URL, snippet and publication date when available. Time filters restrict rotation and failover to compatible providers; never silently dropped.",
31
+ inputSchema: {
32
+ query: z.string().describe("Search query"),
33
+ numResults: z.number().int().min(1).max(20).optional().describe("Optional result count (1–20). Omit to use the dashboard setting: a custom count or the provider default."),
34
+ timeRange: timeRangeSchema.optional().describe("Relative UTC date window: day=1, week=7, month=30, year=365 days through today. Cannot combine with startDate/endDate; day precision, not rolling hours."),
35
+ startDate: dateSchema.optional().describe("Start date YYYY-MM-DD. May be used alone or with endDate; cannot combine with timeRange."),
36
+ endDate: dateSchema.optional().describe("End date YYYY-MM-DD (through this date, subject to provider boundary semantics). Cannot combine with timeRange."),
37
+ engine: z.string().optional().describe("Preferred engine id; failover to the other engines stays active"),
38
+ },
39
+ }, async ({ query, numResults, engine, timeRange, startDate, endDate }, extra) => {
40
+ const r = await withRouterDiagnostics(deps.router.search({ query, numResults, timeRange, startDate, endDate }, { preferEngine: engine, signal: deps.requestSignal ? AbortSignal.any([extra.signal, deps.requestSignal]) : extra.signal }));
41
+ const lines = r.items.map((it, i) => {
42
+ const snip = it.snippet ? `\n ${it.snippet.replace(/\s+/g, " ").slice(0, 400)}` : "";
43
+ return `${i + 1}. ${it.title}\n ${it.url}${it.published ? `\n Published: ${it.published}` : ""}${snip}`;
44
+ });
45
+ const failover = r.attempts.filter((a) => !a.ok).map((a) => `${a.engine}: ${a.error}`);
46
+ const head = [`Search "${query}" via ${r.engine} (${r.items.length} results)`];
47
+ const period = describeSearchTime({ query, timeRange, startDate, endDate });
48
+ if (period)
49
+ head.push(`Time filter: ${period} (provider date semantics apply)`);
50
+ if (r.answer)
51
+ head.push(`Answer: ${r.answer}`);
52
+ if (failover.length)
53
+ head.push(`Failover after: ${failover.join("; ")}`);
54
+ return { content: [{ type: "text", text: [...head, lines.join("\n")].join("\n\n") }] };
55
+ });
56
+ server.registerTool("fetch_url", {
57
+ title: "Fetch URL",
58
+ description: "Fetch a web page and return its content as markdown. Rotates across extraction providers (Jina Reader, Firecrawl, Parallel, Tavily, Exa) with failover.",
59
+ inputSchema: {
60
+ url: z.string().url().describe("URL to fetch"),
61
+ },
62
+ }, async ({ url }, extra) => {
63
+ const r = await withRouterDiagnostics(deps.router.fetchUrl({ url }, { signal: deps.requestSignal ? AbortSignal.any([extra.signal, deps.requestSignal]) : extra.signal }));
64
+ const MAX = 50_000;
65
+ const text = r.markdown.length > MAX
66
+ ? `${r.markdown.slice(0, MAX)}\n\n[… truncated, ${r.markdown.length} chars total]`
67
+ : r.markdown;
68
+ const failover = r.attempts.filter((a) => !a.ok).map((a) => `${a.engine}: ${a.error}`);
69
+ const head = `Fetched ${url} via ${r.engine}` + (failover.length ? ` (failover after: ${failover.join("; ")})` : "");
70
+ return { content: [{ type: "text", text: `${head}\n\n${text}` }] };
71
+ });
72
+ server.registerTool("engine_status", {
73
+ title: "Engine Status",
74
+ annotations: { readOnlyHint: true, destructiveHint: false },
75
+ description: "Read engine configuration, per-capability authentication requirements, local usage and quota sources. This is not a live health check. Historical errors do not prove a current failure; disabled engines are not necessarily missing credentials.",
76
+ inputSchema: {},
77
+ }, async () => {
78
+ const status = engineStatus(await deps.status(), deps.month(), VERSION);
79
+ return {
80
+ structuredContent: status,
81
+ content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
82
+ };
83
+ });
84
+ server.registerTool("open_dashboard", {
85
+ title: "Open Dashboard",
86
+ description: "Open the local dashboard (API keys, engine order and toggles, quota view) in a web browser. Use when the user wants to configure engines or check quota.",
87
+ inputSchema: {},
88
+ }, async () => {
89
+ const url = deps.dashboardUrl();
90
+ if (!url) {
91
+ return {
92
+ isError: true,
93
+ content: [
94
+ {
95
+ type: "text",
96
+ text: "Dashboard ist deaktiviert (gestartet mit --no-dashboard). Ohne Dashboard-Flag neu starten, um Keys/Reihenfolge/Kontingente zu konfigurieren.",
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ deps.openDashboard();
102
+ return { content: [{ type: "text", text: `Dashboard: ${url}` }] };
103
+ });
104
+ return server;
105
+ }