infobroker 2026.8.23
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.
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/infobroker.js +5 -0
- package/dist/chain.d.ts +14 -0
- package/dist/chain.js +63 -0
- package/dist/chain.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +271 -0
- package/dist/config.js.map +1 -0
- package/dist/corroborate.d.ts +25 -0
- package/dist/corroborate.js +421 -0
- package/dist/corroborate.js.map +1 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +25 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +909 -0
- package/dist/index.js.map +1 -0
- package/dist/kb-crypto.d.ts +43 -0
- package/dist/kb-crypto.js +199 -0
- package/dist/kb-crypto.js.map +1 -0
- package/dist/kb.d.ts +100 -0
- package/dist/kb.js +1009 -0
- package/dist/kb.js.map +1 -0
- package/dist/lib/html.d.ts +2 -0
- package/dist/lib/html.js +13 -0
- package/dist/lib/html.js.map +1 -0
- package/dist/lib/url-guard.d.ts +14 -0
- package/dist/lib/url-guard.js +75 -0
- package/dist/lib/url-guard.js.map +1 -0
- package/dist/normalizer.d.ts +2 -0
- package/dist/normalizer.js +53 -0
- package/dist/normalizer.js.map +1 -0
- package/dist/providers/arxiv.d.ts +2 -0
- package/dist/providers/arxiv.js +78 -0
- package/dist/providers/arxiv.js.map +1 -0
- package/dist/providers/brave.d.ts +2 -0
- package/dist/providers/brave.js +79 -0
- package/dist/providers/brave.js.map +1 -0
- package/dist/providers/core.d.ts +2 -0
- package/dist/providers/core.js +62 -0
- package/dist/providers/core.js.map +1 -0
- package/dist/providers/duckduckgo.d.ts +2 -0
- package/dist/providers/duckduckgo.js +93 -0
- package/dist/providers/duckduckgo.js.map +1 -0
- package/dist/providers/exa.d.ts +2 -0
- package/dist/providers/exa.js +68 -0
- package/dist/providers/exa.js.map +1 -0
- package/dist/providers/generic-http.d.ts +2 -0
- package/dist/providers/generic-http.js +96 -0
- package/dist/providers/generic-http.js.map +1 -0
- package/dist/providers/github.d.ts +2 -0
- package/dist/providers/github.js +58 -0
- package/dist/providers/github.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.js +59 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/internet_archive.d.ts +2 -0
- package/dist/providers/internet_archive.js +59 -0
- package/dist/providers/internet_archive.js.map +1 -0
- package/dist/providers/jina.d.ts +2 -0
- package/dist/providers/jina.js +34 -0
- package/dist/providers/jina.js.map +1 -0
- package/dist/providers/marginalia.d.ts +2 -0
- package/dist/providers/marginalia.js +47 -0
- package/dist/providers/marginalia.js.map +1 -0
- package/dist/providers/mojeek.d.ts +2 -0
- package/dist/providers/mojeek.js +47 -0
- package/dist/providers/mojeek.js.map +1 -0
- package/dist/providers/openstreetmap.d.ts +2 -0
- package/dist/providers/openstreetmap.js +43 -0
- package/dist/providers/openstreetmap.js.map +1 -0
- package/dist/providers/searxng.d.ts +2 -0
- package/dist/providers/searxng.js +52 -0
- package/dist/providers/searxng.js.map +1 -0
- package/dist/providers/semantic_scholar.d.ts +2 -0
- package/dist/providers/semantic_scholar.js +54 -0
- package/dist/providers/semantic_scholar.js.map +1 -0
- package/dist/providers/stack_exchange.d.ts +2 -0
- package/dist/providers/stack_exchange.js +83 -0
- package/dist/providers/stack_exchange.js.map +1 -0
- package/dist/providers/tavily.d.ts +2 -0
- package/dist/providers/tavily.js +66 -0
- package/dist/providers/tavily.js.map +1 -0
- package/dist/providers/wiby.d.ts +2 -0
- package/dist/providers/wiby.js +46 -0
- package/dist/providers/wiby.js.map +1 -0
- package/dist/providers/wikidata.d.ts +2 -0
- package/dist/providers/wikidata.js +46 -0
- package/dist/providers/wikidata.js.map +1 -0
- package/dist/providers/wikipedia.d.ts +2 -0
- package/dist/providers/wikipedia.js +71 -0
- package/dist/providers/wikipedia.js.map +1 -0
- package/dist/providers/wiktionary.d.ts +2 -0
- package/dist/providers/wiktionary.js +48 -0
- package/dist/providers/wiktionary.js.map +1 -0
- package/dist/providers/yep.d.ts +2 -0
- package/dist/providers/yep.js +104 -0
- package/dist/providers/yep.js.map +1 -0
- package/dist/quota.d.ts +37 -0
- package/dist/quota.js +116 -0
- package/dist/quota.js.map +1 -0
- package/dist/rate-limiter.d.ts +7 -0
- package/dist/rate-limiter.js +37 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +51 -0
- package/dist/retry.js.map +1 -0
- package/dist/types.d.ts +231 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
// @implements REQ-001 REQ-002 REQ-004 REQ-013 REQ-020 REQ-020a REQ-020b REQ-020c REQ-020d REQ-021 REQ-024 REQ-024a REQ-024b REQ-024c REQ-026 REQ-030 REQ-031 REQ-032 REQ-034 REQ-035 REQ-036 REQ-040 REQ-060 REQ-060a REQ-060b REQ-060c REQ-060d REQ-060e REQ-060f REQ-064 REQ-065 REQ-066 REQ-067 REQ-070 REQ-074 REQ-075 REQ-076 REQ-079 REQ-081 REQ-083 REQ-086
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir, homedir } from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { loadConfig, reloadConfig, getConfig, getDispatchChain } from "./config.js";
|
|
10
|
+
import { configureAllProviders, throttle } from "./rate-limiter.js";
|
|
11
|
+
import { increment, checkQuota, loadQuotaState, getQuotaStatePath } from "./quota.js";
|
|
12
|
+
import { PROVIDERS, resolveProvider } from "./providers/index.js";
|
|
13
|
+
import { retryWithBackoff, ParseError } from "./retry.js";
|
|
14
|
+
import { corroborate } from "./corroborate.js";
|
|
15
|
+
import { ignoredParams, selectChain } from "./chain.js";
|
|
16
|
+
import { assertPublicUrl, fetchFollowRedirects } from "./lib/url-guard.js";
|
|
17
|
+
import { initKb, isKbConfigured, kbSearch, kbIngest, kbStats, kbDelete, kbList, kbGet, resolveReportIdentity, autoIndex, flushKbWrites, getKbLockError, getKbEncryptionState, sealReportBytes, generateKeyFile, verifyStoreKey, backupKeyFile, kbEncryptionStatus, rekeyStoreTo } from "./kb.js";
|
|
18
|
+
import { readKeyFile } from "./kb-crypto.js";
|
|
19
|
+
const START_TIME = Date.now();
|
|
20
|
+
const SPEC_REVIEW_TIME = Date.now();
|
|
21
|
+
const BUILD_VERSION = readPackageVersion();
|
|
22
|
+
let totalRequests = 0;
|
|
23
|
+
function readPackageVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const pkgPath = join(fileURLToPath(import.meta.url), "..", "..", "package.json");
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
27
|
+
return pkg.version ?? "unknown";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const requestLatencies = {};
|
|
34
|
+
const providerLastSuccess = {};
|
|
35
|
+
const providerLastError = {};
|
|
36
|
+
const responseBytes = [];
|
|
37
|
+
const RESPONSE_WINDOW = 100;
|
|
38
|
+
loadConfig();
|
|
39
|
+
configureAllProviders(getConfig());
|
|
40
|
+
loadQuotaState();
|
|
41
|
+
startupHealthCheck();
|
|
42
|
+
const kbConfig = getConfig().kb;
|
|
43
|
+
if (kbConfig) {
|
|
44
|
+
initKb(kbConfig);
|
|
45
|
+
console.error("[infobroker] Knowledge base initialized");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.error("[infobroker] Knowledge base not configured — KB tools disabled");
|
|
49
|
+
}
|
|
50
|
+
function trackRequest(provider, latencyMs) {
|
|
51
|
+
totalRequests++;
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
const windowSize = config.output.latency_window_size;
|
|
54
|
+
if (!requestLatencies[provider]) {
|
|
55
|
+
requestLatencies[provider] = { latencies: [], timestamps: [] };
|
|
56
|
+
}
|
|
57
|
+
const entry = requestLatencies[provider];
|
|
58
|
+
entry.latencies.push(latencyMs);
|
|
59
|
+
entry.timestamps.push(Date.now());
|
|
60
|
+
while (entry.latencies.length > windowSize) {
|
|
61
|
+
entry.latencies.shift();
|
|
62
|
+
entry.timestamps.shift();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function avgLatency(provider) {
|
|
66
|
+
const entry = requestLatencies[provider];
|
|
67
|
+
if (!entry || entry.latencies.length === 0)
|
|
68
|
+
return 0;
|
|
69
|
+
return entry.latencies.reduce((a, b) => a + b, 0) / entry.latencies.length;
|
|
70
|
+
}
|
|
71
|
+
function compactMode() {
|
|
72
|
+
return getConfig().output.verbose === false;
|
|
73
|
+
}
|
|
74
|
+
function ok(provider, results, meta = {}) {
|
|
75
|
+
const base = {
|
|
76
|
+
status: "ok",
|
|
77
|
+
provider,
|
|
78
|
+
results,
|
|
79
|
+
};
|
|
80
|
+
if (!compactMode()) {
|
|
81
|
+
base.meta = {
|
|
82
|
+
query_time_ms: 0,
|
|
83
|
+
fallback_used: false,
|
|
84
|
+
quota_remaining: undefined,
|
|
85
|
+
...meta,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return base;
|
|
89
|
+
}
|
|
90
|
+
function err(provider, code, message, remediation, details) {
|
|
91
|
+
return {
|
|
92
|
+
status: "error",
|
|
93
|
+
provider,
|
|
94
|
+
error: { code, message, remediation, ...(details ? { details } : {}) },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function maybeTruncate(text, maxChars) {
|
|
98
|
+
if (text.length <= maxChars)
|
|
99
|
+
return { text, truncated: false };
|
|
100
|
+
const tmpDir = join(tmpdir(), "infobroker");
|
|
101
|
+
if (!existsSync(tmpDir)) {
|
|
102
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
const fname = `trunc-${Date.now()}.txt`;
|
|
105
|
+
const fpath = join(tmpDir, fname);
|
|
106
|
+
writeFileSync(fpath, text);
|
|
107
|
+
try {
|
|
108
|
+
chmodSync(fpath, 0o600);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// best effort
|
|
112
|
+
}
|
|
113
|
+
return { text: text.slice(0, maxChars) + "...", truncated: true, outputPath: fpath };
|
|
114
|
+
}
|
|
115
|
+
function slugify(title) {
|
|
116
|
+
return title
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
119
|
+
.replace(/^-+|-+$/g, "")
|
|
120
|
+
.slice(0, 80) || "report";
|
|
121
|
+
}
|
|
122
|
+
function saveReportToDisk(title, text, format) {
|
|
123
|
+
const base = getConfig().kb?.reports_dir || join(homedir(), "Infobroker", "reports");
|
|
124
|
+
const dir = base.startsWith("~/") ? join(homedir(), base.slice(2)) : base;
|
|
125
|
+
if (!existsSync(dir))
|
|
126
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
const ext = format === "json" ? "json" : "md";
|
|
128
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
129
|
+
const fpath = join(dir, `${date}-${slugify(title)}.${ext}`);
|
|
130
|
+
// Reports are the sensitive class: encrypt them at rest when encryption is
|
|
131
|
+
// enabled (REQ-084), and always restrict permissions to the owner.
|
|
132
|
+
const bytes = sealReportBytes(Buffer.from(text, "utf-8"));
|
|
133
|
+
writeFileSync(fpath, bytes);
|
|
134
|
+
try {
|
|
135
|
+
chmodSync(fpath, 0o600);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// best effort
|
|
139
|
+
}
|
|
140
|
+
return fpath;
|
|
141
|
+
}
|
|
142
|
+
function json(data) {
|
|
143
|
+
const text = JSON.stringify(data, null, 2);
|
|
144
|
+
responseBytes.push(text.length);
|
|
145
|
+
if (responseBytes.length > RESPONSE_WINDOW)
|
|
146
|
+
responseBytes.shift();
|
|
147
|
+
return text;
|
|
148
|
+
}
|
|
149
|
+
function medianResponseBytes() {
|
|
150
|
+
if (responseBytes.length === 0)
|
|
151
|
+
return 0;
|
|
152
|
+
const sorted = [...responseBytes].sort((a, b) => a - b);
|
|
153
|
+
const mid = Math.floor(sorted.length / 2);
|
|
154
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
155
|
+
}
|
|
156
|
+
async function startupHealthCheck() {
|
|
157
|
+
const config = getConfig();
|
|
158
|
+
const checks = [];
|
|
159
|
+
for (const [slug, provider] of Object.entries(config.providers)) {
|
|
160
|
+
if (!provider.enabled)
|
|
161
|
+
continue;
|
|
162
|
+
const keyEnv = provider.auth_env;
|
|
163
|
+
if (keyEnv && !process.env[keyEnv]) {
|
|
164
|
+
if (provider.tier === "keyed_http") {
|
|
165
|
+
console.error(`[infobroker] ${slug}: inactive (no_api_key)`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const urlEnv = provider.url_env;
|
|
170
|
+
if (urlEnv && !process.env[urlEnv]) {
|
|
171
|
+
console.error(`[infobroker] ${slug}: inactive (no_url)`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const p = resolveProvider(slug);
|
|
175
|
+
if (!p) {
|
|
176
|
+
console.error(`[infobroker] ${slug}: active (no health check available)`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
checks.push((async () => {
|
|
180
|
+
try {
|
|
181
|
+
const h = await p.health();
|
|
182
|
+
console.error(`[infobroker] ${slug}: ${h.status} (${h.avgLatencyMs}ms)`);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
console.error(`[infobroker] ${slug}: inactive (health check failed)`);
|
|
186
|
+
}
|
|
187
|
+
})());
|
|
188
|
+
}
|
|
189
|
+
await Promise.allSettled(checks);
|
|
190
|
+
}
|
|
191
|
+
const TASK_TYPE_KEYWORDS = {
|
|
192
|
+
"general_web": ["search", "find", "look up", "research", "information about"],
|
|
193
|
+
"small_web": ["blog", "personal", "non-commercial", "indie", "small web"],
|
|
194
|
+
"encyclopedia": ["encyclopedia", "wiki"],
|
|
195
|
+
"definition": ["definition", "define", "meaning", "etymology", "dictionary", "word"],
|
|
196
|
+
"structured_fact": ["date", "statistic", "identifier", "population", "birth", "death"],
|
|
197
|
+
"location": ["where is", "location", "map", "address", "city", "place", "geocode"],
|
|
198
|
+
"academic": ["paper", "study", "research paper", "academic", "scholar", "journal", "thesis"],
|
|
199
|
+
"code": ["code", "programming", "error", "debug", "function", "api", "docs", "stack overflow"],
|
|
200
|
+
"news": ["news", "recent", "latest", "today", "current"],
|
|
201
|
+
"archive": ["archive", "historical", "old", "past version"],
|
|
202
|
+
"semantic": ["like", "similar to", "semantic", "neural", "conceptual"],
|
|
203
|
+
"synthesis": ["synthesize", "comprehensive", "summarize sources", "rag"],
|
|
204
|
+
"privacy_critical": ["private", "anonymous", "no tracking", "self-host"],
|
|
205
|
+
};
|
|
206
|
+
function classifyTaskType(task) {
|
|
207
|
+
const lower = task.toLowerCase();
|
|
208
|
+
for (const [type, keywords] of Object.entries(TASK_TYPE_KEYWORDS)) {
|
|
209
|
+
if (keywords.some((kw) => lower.includes(kw)))
|
|
210
|
+
return type;
|
|
211
|
+
}
|
|
212
|
+
return "general_web";
|
|
213
|
+
}
|
|
214
|
+
// Server-side content-type classification by URL pattern. Providers cannot all
|
|
215
|
+
// honor a content_type filter natively, so Infobroker applies it as a
|
|
216
|
+
// post-filter over normalized URLs.
|
|
217
|
+
function classifyContentType(url) {
|
|
218
|
+
const u = url.toLowerCase();
|
|
219
|
+
if (/(^|\/)(docs?\.|docs\/(.+))|developer|documentation/.test(u))
|
|
220
|
+
return "docs";
|
|
221
|
+
if (/\/issues?\/|github\.com\/[^/]+\/[^/]+\/issues|\/pull\/\d+/.test(u))
|
|
222
|
+
return "issue";
|
|
223
|
+
if (/changelog|release-notes|releases|what-?s-new/.test(u))
|
|
224
|
+
return "changelog";
|
|
225
|
+
if (/(^|\.)blog\.|(^|\/)(blog|posts|articles)(\/|$)/.test(u))
|
|
226
|
+
return "blog";
|
|
227
|
+
if (/\brfc\d+|\bspec\b|standard\b|w3\.org|ietf\.org/.test(u))
|
|
228
|
+
return "spec";
|
|
229
|
+
return "all";
|
|
230
|
+
}
|
|
231
|
+
function filterByContentType(results, contentType) {
|
|
232
|
+
if (!contentType || contentType === "all")
|
|
233
|
+
return results;
|
|
234
|
+
return results.filter((r) => classifyContentType(r.url) === contentType);
|
|
235
|
+
}
|
|
236
|
+
async function doWebSearch(query, preferredProvider, maxResults = 8, safeSearch = "on", timeRange, page = 1, priority, suggest = false, contentType, region) {
|
|
237
|
+
const config = getConfig();
|
|
238
|
+
if (suggest) {
|
|
239
|
+
let lastErr = null;
|
|
240
|
+
for (const [slug, provider] of Object.entries(PROVIDERS)) {
|
|
241
|
+
if (!provider?.suggest)
|
|
242
|
+
continue;
|
|
243
|
+
if (config.providers[slug] && !config.providers[slug].enabled)
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const suggestions = await provider.suggest(query);
|
|
247
|
+
return `[OK] ${json({
|
|
248
|
+
status: "ok",
|
|
249
|
+
provider: slug,
|
|
250
|
+
results: suggestions.map((s) => ({ title: s, url: "", snippet: s })),
|
|
251
|
+
meta: { query_time_ms: 0, fallback_used: false },
|
|
252
|
+
})}`;
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
lastErr = e instanceof Error ? e.message : String(e);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return `[ERROR] ${json(err("duckduckgo", "provider_unavailable", lastErr ?? "No suggestion-capable provider available", "Retry later"))}`;
|
|
259
|
+
}
|
|
260
|
+
if (isKbConfigured()) {
|
|
261
|
+
try {
|
|
262
|
+
const kbResults = kbSearch(query, maxResults);
|
|
263
|
+
if (kbResults.length > 0) {
|
|
264
|
+
const relevanceThreshold = config.kb.kb_first_relevance_threshold;
|
|
265
|
+
const confidenceThreshold = config.kb.kb_first_confidence_threshold;
|
|
266
|
+
const sufficient = kbResults.some((r) => r.freshness_score >= confidenceThreshold && (r.score ?? 0) >= relevanceThreshold);
|
|
267
|
+
if (sufficient) {
|
|
268
|
+
const kbProvider = "knowledge_base";
|
|
269
|
+
return `[OK] ${json(ok(kbProvider, kbResults.map((r) => ({
|
|
270
|
+
title: r.title,
|
|
271
|
+
url: r.source_url,
|
|
272
|
+
snippet: r.snippet,
|
|
273
|
+
source_type: r.freshness_tier,
|
|
274
|
+
})), {
|
|
275
|
+
query_time_ms: 0,
|
|
276
|
+
fallback_used: false,
|
|
277
|
+
quota_remaining: undefined,
|
|
278
|
+
}))}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// KB search failed — proceed to external providers
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const taskType = classifyTaskType(query);
|
|
287
|
+
let chain;
|
|
288
|
+
if (preferredProvider && config.providers[preferredProvider]?.enabled) {
|
|
289
|
+
chain = [preferredProvider, ...getDispatchChain(taskType).filter((p) => p !== preferredProvider)];
|
|
290
|
+
}
|
|
291
|
+
else if (preferredProvider) {
|
|
292
|
+
chain = [preferredProvider, ...getDispatchChain(taskType)];
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
chain = getDispatchChain(taskType);
|
|
296
|
+
}
|
|
297
|
+
if (chain.length === 0) {
|
|
298
|
+
chain = getDispatchChain("general_web");
|
|
299
|
+
}
|
|
300
|
+
chain = selectChain(chain, priority, avgLatency);
|
|
301
|
+
if (chain.length === 0) {
|
|
302
|
+
return `[ERROR] ${json(err("none", "config_error", "No active search providers configured", "Check config.json"))}`;
|
|
303
|
+
}
|
|
304
|
+
const opts = { max_results: maxResults, safe_search: safeSearch, time_range: timeRange, page, region, content_type: contentType };
|
|
305
|
+
let lastError = null;
|
|
306
|
+
const quotaExhausted = {};
|
|
307
|
+
for (const slug of chain) {
|
|
308
|
+
try {
|
|
309
|
+
const quota = checkQuota(slug, config.providers[slug]?.rate_limit);
|
|
310
|
+
if (quota.exhausted) {
|
|
311
|
+
quotaExhausted[slug] = 0;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
await throttle(slug);
|
|
315
|
+
const start = Date.now();
|
|
316
|
+
const provider = resolveProvider(slug);
|
|
317
|
+
if (!provider?.search) {
|
|
318
|
+
throw new Error(`Provider ${slug} has no search function`);
|
|
319
|
+
}
|
|
320
|
+
const doCall = async () => provider.search(query, opts);
|
|
321
|
+
const timeoutMs = config.providers[slug]?.timeout;
|
|
322
|
+
const timedCall = async () => Promise.race([
|
|
323
|
+
doCall(),
|
|
324
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Provider ${slug} timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
325
|
+
]);
|
|
326
|
+
const results = (await retryWithBackoff(timedCall)).slice(0, maxResults);
|
|
327
|
+
const elapsed = Date.now() - start;
|
|
328
|
+
increment(slug, config.providers[slug]?.rate_limit);
|
|
329
|
+
trackRequest(slug, elapsed);
|
|
330
|
+
providerLastSuccess[slug] = Date.now();
|
|
331
|
+
// REQ-031: an empty result set is a failure for chain advancement, not a
|
|
332
|
+
// successful answer. Fall through to the next provider — but keep a
|
|
333
|
+
// provider's empty as distinct from a total failure so the final
|
|
334
|
+
// all-empty case is reported as a legitimate zero-result answer.
|
|
335
|
+
if (results.length === 0) {
|
|
336
|
+
providerLastError[slug] = { message: "empty result set", timestamp: Date.now() };
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const filtered = contentType ? filterByContentType(results, contentType) : results;
|
|
340
|
+
if (filtered.length === 0) {
|
|
341
|
+
providerLastError[slug] = { message: "content_type filter removed all results", timestamp: Date.now() };
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
autoIndex(filtered, slug, undefined, undefined, query, timeRange);
|
|
345
|
+
return `[OK] ${json(ok(slug, filtered, {
|
|
346
|
+
query_time_ms: elapsed,
|
|
347
|
+
fallback_used: lastError !== null,
|
|
348
|
+
quota_remaining: checkQuota(slug, config.providers[slug]?.rate_limit).daily.remaining,
|
|
349
|
+
ignored_params: ignoredParams(slug, { safe_search: safeSearch, time_range: timeRange, page, content_type: contentType, region }),
|
|
350
|
+
}))}`;
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
354
|
+
providerLastError[slug] = { message: msg, timestamp: Date.now() };
|
|
355
|
+
if (e instanceof ParseError) {
|
|
356
|
+
lastError = err(slug, "parse_error", msg, "Trying next provider in fallback chain");
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
lastError = err(slug, "provider_unavailable", msg, "Trying next provider in fallback chain");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// A chain that ended on provider errors is a failure; a chain whose
|
|
364
|
+
// providers all returned empty is a legitimate zero-result answer.
|
|
365
|
+
if (lastError !== null) {
|
|
366
|
+
const quotaExhaustedList = Object.keys(quotaExhausted);
|
|
367
|
+
const details = {
|
|
368
|
+
exhausted_chain: chain,
|
|
369
|
+
quota_exhausted: quotaExhaustedList,
|
|
370
|
+
};
|
|
371
|
+
return `[ERROR] ${json(err("none", "all_providers_exhausted", `Fallback chain exhausted: ${chain.join(", ")}`, "Retry later or check provider configuration", details))}`;
|
|
372
|
+
}
|
|
373
|
+
return `[OK] ${json(ok("none", [], { query_time_ms: 0, fallback_used: true, ignored_params: [] }))}`;
|
|
374
|
+
}
|
|
375
|
+
async function doFetchPage(url, renderer, maxLength) {
|
|
376
|
+
const config = getConfig();
|
|
377
|
+
const renderers = renderer ? [renderer] : getDispatchChain("content_fetch");
|
|
378
|
+
const effectiveMax = maxLength ?? config.output.max_chars;
|
|
379
|
+
const allowPrivate = config.fetch?.allow_private_urls === true;
|
|
380
|
+
try {
|
|
381
|
+
assertPublicUrl(url, allowPrivate);
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
return `[ERROR] ${json(err("none", "invalid_input", e instanceof Error ? e.message : String(e), `Set fetch.allow_private_urls=true to permit private targets`))}`;
|
|
385
|
+
}
|
|
386
|
+
for (const slug of renderers) {
|
|
387
|
+
try {
|
|
388
|
+
await throttle(slug);
|
|
389
|
+
const start = Date.now();
|
|
390
|
+
const doCall = async () => {
|
|
391
|
+
if (slug === "native_fetch") {
|
|
392
|
+
// REQ-021a: follow redirects hop-by-hop, re-applying the SSRF guard
|
|
393
|
+
// to each resolved location, so a public URL that redirects to a
|
|
394
|
+
// private/internal target is refused rather than fetched.
|
|
395
|
+
return fetchFollowRedirects(url, allowPrivate, fetch, getConfig().output.max_redirect_hops);
|
|
396
|
+
}
|
|
397
|
+
const provider = resolveProvider(slug);
|
|
398
|
+
if (!provider?.fetchPage) {
|
|
399
|
+
throw new Error(`Provider ${slug} has no fetchPage function`);
|
|
400
|
+
}
|
|
401
|
+
return await provider.fetchPage(url);
|
|
402
|
+
};
|
|
403
|
+
let content;
|
|
404
|
+
try {
|
|
405
|
+
const timeoutMs = config.providers[slug]?.timeout;
|
|
406
|
+
const timedCall = async () => Promise.race([
|
|
407
|
+
doCall(),
|
|
408
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Provider ${slug} timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
409
|
+
]);
|
|
410
|
+
content = await retryWithBackoff(timedCall);
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
providerLastError[slug] = { message: e instanceof Error ? e.message : String(e), timestamp: Date.now() };
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const elapsed = Date.now() - start;
|
|
417
|
+
increment(slug, config.providers[slug]?.rate_limit);
|
|
418
|
+
trackRequest(slug, elapsed);
|
|
419
|
+
providerLastSuccess[slug] = Date.now();
|
|
420
|
+
const truncated = maybeTruncate(content, effectiveMax);
|
|
421
|
+
autoIndex([{ title: new URL(url).hostname, url, snippet: content }], slug, undefined, "fetch_page");
|
|
422
|
+
return `[OK] ${json({
|
|
423
|
+
status: "ok",
|
|
424
|
+
provider: slug,
|
|
425
|
+
results: [{
|
|
426
|
+
title: new URL(url).hostname,
|
|
427
|
+
url,
|
|
428
|
+
snippet: truncated.text,
|
|
429
|
+
}],
|
|
430
|
+
meta: {
|
|
431
|
+
query_time_ms: elapsed,
|
|
432
|
+
fallback_used: false,
|
|
433
|
+
},
|
|
434
|
+
...(truncated.truncated ? { truncated: true, output_path: truncated.outputPath } : {}),
|
|
435
|
+
})}`;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// continue to next renderer
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return `[ERROR] ${json(err("none", "all_providers_exhausted", `All content renderers exhausted for: ${url}`, "Check network connectivity"))}`;
|
|
442
|
+
}
|
|
443
|
+
function providerOperational(p) {
|
|
444
|
+
if (!p.enabled)
|
|
445
|
+
return false;
|
|
446
|
+
if (p.auth_env && !process.env[p.auth_env])
|
|
447
|
+
return false;
|
|
448
|
+
if (p.url_env && !process.env[p.url_env])
|
|
449
|
+
return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
function providerInactiveReason(p) {
|
|
453
|
+
if (!p.enabled)
|
|
454
|
+
return "disabled";
|
|
455
|
+
if (p.auth_env && !process.env[p.auth_env])
|
|
456
|
+
return "no_api_key";
|
|
457
|
+
if (p.url_env && !process.env[p.url_env])
|
|
458
|
+
return "no_url";
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function doListProviders(filter) {
|
|
462
|
+
const config = getConfig();
|
|
463
|
+
const entries = Object.entries(config.providers);
|
|
464
|
+
const filtered = filter === "active"
|
|
465
|
+
? entries.filter(([, p]) => providerOperational(p))
|
|
466
|
+
: entries;
|
|
467
|
+
const list = filtered.map(([slug, p]) => {
|
|
468
|
+
const quota = checkQuota(slug, p.rate_limit);
|
|
469
|
+
const operational = providerOperational(p);
|
|
470
|
+
const reason = quota.exhausted ? "exhausted" : (operational ? null : providerInactiveReason(p));
|
|
471
|
+
return {
|
|
472
|
+
slug,
|
|
473
|
+
tier: p.tier,
|
|
474
|
+
capabilities: p.capabilities,
|
|
475
|
+
enabled: p.enabled,
|
|
476
|
+
status: quota.exhausted ? "exhausted" : (operational ? "active" : "inactive"),
|
|
477
|
+
...(reason ? { inactive_reason: reason } : {}),
|
|
478
|
+
quota_used: quota.daily.used,
|
|
479
|
+
quota_remaining: quota.daily.remaining,
|
|
480
|
+
quota_warning: quota.warning,
|
|
481
|
+
quota_exhausted: quota.exhausted,
|
|
482
|
+
priority: p.priority,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
return `[OK] ${json({
|
|
486
|
+
status: "ok",
|
|
487
|
+
provider: "system",
|
|
488
|
+
results: list,
|
|
489
|
+
})}`;
|
|
490
|
+
}
|
|
491
|
+
async function doProviderHealth(providerSlug) {
|
|
492
|
+
const config = getConfig();
|
|
493
|
+
const p = config.providers[providerSlug];
|
|
494
|
+
if (!p) {
|
|
495
|
+
return `[ERROR] ${json(err(providerSlug, "invalid_input", `Provider "${providerSlug}" not found in config`, "Use the providers tool (action list) to see available providers"))}`;
|
|
496
|
+
}
|
|
497
|
+
const quota = checkQuota(providerSlug, p.rate_limit);
|
|
498
|
+
const keyEnv = p.auth_env;
|
|
499
|
+
const authOk = keyEnv ? !!process.env[keyEnv] : true;
|
|
500
|
+
let status = authOk ? "active" : "inactive";
|
|
501
|
+
let avgLatencyMs;
|
|
502
|
+
const registeredProvider = resolveProvider(providerSlug);
|
|
503
|
+
if (registeredProvider && authOk) {
|
|
504
|
+
try {
|
|
505
|
+
const h = await registeredProvider.health();
|
|
506
|
+
status = h.status;
|
|
507
|
+
avgLatencyMs = h.avgLatencyMs;
|
|
508
|
+
providerLastSuccess[providerSlug] = Date.now();
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
providerLastError[providerSlug] = { message: e instanceof Error ? e.message : String(e), timestamp: Date.now() };
|
|
512
|
+
if (status === "active") {
|
|
513
|
+
status = "degraded";
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
avgLatencyMs = avgLatency(providerSlug);
|
|
519
|
+
}
|
|
520
|
+
if (quota.exhausted) {
|
|
521
|
+
status = "exhausted";
|
|
522
|
+
}
|
|
523
|
+
else if (quota.warning && status === "active") {
|
|
524
|
+
status = "degraded";
|
|
525
|
+
}
|
|
526
|
+
const report = {
|
|
527
|
+
status: status,
|
|
528
|
+
slug: providerSlug,
|
|
529
|
+
tier: p.tier,
|
|
530
|
+
capabilities: p.capabilities,
|
|
531
|
+
quota_used: quota.daily.used,
|
|
532
|
+
quota_remaining: quota.daily.remaining,
|
|
533
|
+
quota_reset_at: quota.daily.resetAt,
|
|
534
|
+
avg_latency_ms: avgLatencyMs,
|
|
535
|
+
auth_ok: authOk,
|
|
536
|
+
};
|
|
537
|
+
if (providerLastError[providerSlug]) {
|
|
538
|
+
report.last_error = new Date(providerLastError[providerSlug].timestamp).toISOString();
|
|
539
|
+
}
|
|
540
|
+
if (providerLastSuccess[providerSlug]) {
|
|
541
|
+
report.last_success = new Date(providerLastSuccess[providerSlug]).toISOString();
|
|
542
|
+
}
|
|
543
|
+
return `[OK] ${json({
|
|
544
|
+
status: "ok",
|
|
545
|
+
provider: providerSlug,
|
|
546
|
+
results: [report],
|
|
547
|
+
})}`;
|
|
548
|
+
}
|
|
549
|
+
function doSpecHealth() {
|
|
550
|
+
const config = getConfig();
|
|
551
|
+
const activeCount = Object.entries(config.providers).filter(([, p]) => p.enabled).length;
|
|
552
|
+
const kbStatsData = isKbConfigured() ? kbStats() : null;
|
|
553
|
+
const toolCount = Object.keys(server._registeredTools).length;
|
|
554
|
+
let toolSchemaBytes = 0;
|
|
555
|
+
const registered = server._registeredTools;
|
|
556
|
+
if (registered) {
|
|
557
|
+
for (const tool of Object.values(registered)) {
|
|
558
|
+
const shape = tool?.inputSchema?.shape;
|
|
559
|
+
toolSchemaBytes += shape ? JSON.stringify(shape).length : 0;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return `[OK] ${json({
|
|
563
|
+
status: "ok",
|
|
564
|
+
provider: "system",
|
|
565
|
+
results: [{
|
|
566
|
+
build_version: BUILD_VERSION,
|
|
567
|
+
provider_count: Object.keys(config.providers).filter(k => k !== "native_fetch").length,
|
|
568
|
+
active_provider_count: activeCount,
|
|
569
|
+
tool_count: toolCount,
|
|
570
|
+
token_footprint: {
|
|
571
|
+
tool_schema_bytes: toolSchemaBytes,
|
|
572
|
+
median_response_bytes: medianResponseBytes(),
|
|
573
|
+
},
|
|
574
|
+
kb: kbStatsData ? {
|
|
575
|
+
chunk_count: kbStatsData.chunk_count,
|
|
576
|
+
collections: kbStatsData.collections,
|
|
577
|
+
freshness_tiers: kbStatsData.freshness_tiers,
|
|
578
|
+
last_ingestion: kbStatsData.last_ingestion,
|
|
579
|
+
} : undefined,
|
|
580
|
+
uptime_seconds: Math.floor((Date.now() - START_TIME) / 1000),
|
|
581
|
+
total_requests_served: totalRequests,
|
|
582
|
+
last_spec_review: new Date(SPEC_REVIEW_TIME).toISOString(),
|
|
583
|
+
quota_state_path: getQuotaStatePath(),
|
|
584
|
+
config_path: process.env["INFOBROKER_CONFIG"] || "./config.json",
|
|
585
|
+
}],
|
|
586
|
+
})}`;
|
|
587
|
+
}
|
|
588
|
+
const server = new McpServer({
|
|
589
|
+
name: "infobroker",
|
|
590
|
+
version: "2026.08.23",
|
|
591
|
+
});
|
|
592
|
+
// --- web_search ---
|
|
593
|
+
server.registerTool("infobroker_web_search", {
|
|
594
|
+
title: "Web Search",
|
|
595
|
+
description: "Unified provider search with task-type routing, fallback chain, and optional suggestions.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
query: z.string().describe("Search query"),
|
|
598
|
+
provider: z.string().optional().describe("Provider slug (auto-select if omitted)"),
|
|
599
|
+
max_results: z.number().min(1).max(30).optional().default(8),
|
|
600
|
+
safe_search: z.enum(["on", "off", "strict"]).optional().default("on"),
|
|
601
|
+
time_range: z.enum(["day", "week", "month", "year"]).optional(),
|
|
602
|
+
page: z.number().min(1).optional().default(1),
|
|
603
|
+
priority: z.enum(["speed", "quality", "privacy", "free_only"]).optional(),
|
|
604
|
+
suggest: z.boolean().optional().default(false),
|
|
605
|
+
content_type: z.enum(["docs", "issue", "changelog", "blog", "spec", "all"]).optional().default("all"),
|
|
606
|
+
region: z.string().optional().describe("ISO region/country code (e.g. 'us-en', 'DE')"),
|
|
607
|
+
},
|
|
608
|
+
}, async (params) => {
|
|
609
|
+
const content = await doWebSearch(String(params.query), params.provider, Number(params.max_results), params.safe_search ?? "on", params.time_range, Number(params.page), params.priority, Boolean(params.suggest), params.content_type, params.region);
|
|
610
|
+
return { content: [{ type: "text", text: content }] };
|
|
611
|
+
});
|
|
612
|
+
// --- fetch_page ---
|
|
613
|
+
server.registerTool("infobroker_fetch_page", {
|
|
614
|
+
title: "Fetch Page Content",
|
|
615
|
+
description: "Fetch and extract URL content. Jina Reader by default, native HTTP fallback.",
|
|
616
|
+
inputSchema: {
|
|
617
|
+
url: z.string().describe("URL to fetch"),
|
|
618
|
+
renderer: z.enum(["jina", "native_fetch", "wikipedia", "internet_archive", "arxiv", "stack_exchange"]).optional(),
|
|
619
|
+
max_length: z.number().optional().default(50000),
|
|
620
|
+
},
|
|
621
|
+
}, async (params) => {
|
|
622
|
+
const content = await doFetchPage(String(params.url), params.renderer, params.max_length !== undefined ? Number(params.max_length) : undefined);
|
|
623
|
+
return { content: [{ type: "text", text: content }] };
|
|
624
|
+
});
|
|
625
|
+
// --- providers ---
|
|
626
|
+
server.registerTool("infobroker_providers", {
|
|
627
|
+
title: "Providers",
|
|
628
|
+
description: "Operational state over configured providers: list, health, or spec actions.",
|
|
629
|
+
inputSchema: {
|
|
630
|
+
action: z.enum(["list", "health", "spec"]).describe("Operation to perform"),
|
|
631
|
+
provider: z.string().optional().describe("Provider slug (required for health)"),
|
|
632
|
+
status: z.enum(["active", "all"]).optional().describe("Filter for list action"),
|
|
633
|
+
},
|
|
634
|
+
}, async (params) => {
|
|
635
|
+
const action = params.action;
|
|
636
|
+
let content;
|
|
637
|
+
if (action === "list") {
|
|
638
|
+
content = doListProviders(params.status);
|
|
639
|
+
}
|
|
640
|
+
else if (action === "health") {
|
|
641
|
+
if (!params.provider) {
|
|
642
|
+
content = `[ERROR] ${json(err("system", "invalid_input", "provider is required for health action", "Provide a provider slug"))}`;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
content = await doProviderHealth(String(params.provider));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
content = doSpecHealth();
|
|
650
|
+
}
|
|
651
|
+
return { content: [{ type: "text", text: content }] };
|
|
652
|
+
});
|
|
653
|
+
// --- corroborate ---
|
|
654
|
+
server.registerTool("infobroker_corroborate", {
|
|
655
|
+
title: "Corroborate (Multi-Source Truth-Finding)",
|
|
656
|
+
description: "Multi-pass truth-finding search with cross-source verification.",
|
|
657
|
+
inputSchema: {
|
|
658
|
+
query: z.string().describe("Search query"),
|
|
659
|
+
max_iterations: z.number().min(1).max(10).optional().default(5),
|
|
660
|
+
confidence_threshold: z.number().min(0).max(1).optional().default(0.8),
|
|
661
|
+
providers: z.array(z.string()).optional().describe("Optional array of provider slugs to limit the search to"),
|
|
662
|
+
},
|
|
663
|
+
}, async (params) => {
|
|
664
|
+
try {
|
|
665
|
+
const result = await corroborate(String(params.query), {
|
|
666
|
+
max_iterations: Number(params.max_iterations),
|
|
667
|
+
confidence_threshold: Number(params.confidence_threshold),
|
|
668
|
+
providers: params.providers,
|
|
669
|
+
});
|
|
670
|
+
autoIndex(result.findings.map((f) => ({ title: f.topic, url: f.sources[0]?.url || "", snippet: f.claim })), "corroborate", undefined, "corroborate");
|
|
671
|
+
if (compactMode()) {
|
|
672
|
+
delete result.provenance;
|
|
673
|
+
}
|
|
674
|
+
return { content: [{ type: "text", text: `[OK] ${json(result)}` }] };
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
return {
|
|
678
|
+
content: [
|
|
679
|
+
{
|
|
680
|
+
type: "text",
|
|
681
|
+
text: `[ERROR] ${json(err("none", "corroboration_error", e instanceof Error ? e.message : String(e), "Retry with different query"))}`,
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
// --- kb ---
|
|
688
|
+
server.registerTool("infobroker_kb", {
|
|
689
|
+
title: "Knowledge Base",
|
|
690
|
+
description: "Manage the local knowledge base: search, ingest, list, get, stats, or delete. Use ingest with source_type 'report' and save_to 'kb' (default) to archive generated reports; use list/get to revisit them. Use the 'encryption' action to enable/disable at-rest encryption, generate or back up a key, verify a key, or re-key the store.",
|
|
691
|
+
inputSchema: {
|
|
692
|
+
action: z.enum(["search", "ingest", "list", "get", "stats", "delete", "encryption"]).describe("Operation to perform"),
|
|
693
|
+
operation: z.enum(["status", "generate_key", "verify", "backup", "rekey"]).optional().describe("Sub-operation for the 'encryption' action"),
|
|
694
|
+
key_file: z.string().optional().describe("Path to a key file (generate_key writes here; rekey reads the target key from here). Never pass the secret itself — only a file path."),
|
|
695
|
+
query: z.string().optional().describe("Search query (for search action)"),
|
|
696
|
+
text: z.string().optional().describe("Raw text to index (for ingest action)"),
|
|
697
|
+
url: z.string().optional().describe("URL to fetch and index (for ingest action)"),
|
|
698
|
+
title: z.string().optional().describe("Document/report title"),
|
|
699
|
+
collection: z.string().optional().describe("Collection name"),
|
|
700
|
+
source_type: z.string().optional().describe("Source type (tag on ingest; filter on search/list)"),
|
|
701
|
+
freshness_tier: z.string().optional().describe("Freshness tier tag on ingest (e.g. 'report', 'evergreen')"),
|
|
702
|
+
save_to: z.enum(["kb", "disk", "both"]).optional().describe("Where to save (ingest action): kb, disk, or both. Default kb"),
|
|
703
|
+
format: z.enum(["markdown", "text", "json"]).optional().default("markdown").describe("File format for disk save"),
|
|
704
|
+
source_url: z.string().optional().describe("Source URL filter (get/delete action) or identity for ingest"),
|
|
705
|
+
max_results: z.number().min(1).max(50).optional().default(8),
|
|
706
|
+
},
|
|
707
|
+
}, async (params) => {
|
|
708
|
+
if (!isKbConfigured()) {
|
|
709
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("system", "config_error", "Knowledge base not configured", "Add a kb section to config.json"))}` }] };
|
|
710
|
+
}
|
|
711
|
+
const action = params.action;
|
|
712
|
+
// The encryption action is the recovery surface: it must remain reachable
|
|
713
|
+
// even when the store is locked so the user can verify a key, restore a
|
|
714
|
+
// backup, or re-key. All other actions honor the lock.
|
|
715
|
+
if (action !== "encryption") {
|
|
716
|
+
const lock = getKbLockError();
|
|
717
|
+
if (lock) {
|
|
718
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", lock.code, lock.message, lock.remediation))}` }] };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
if (action === "encryption") {
|
|
723
|
+
const op = params.operation || "status";
|
|
724
|
+
const keyFile = params.key_file;
|
|
725
|
+
if (op === "status") {
|
|
726
|
+
const status = kbEncryptionStatus();
|
|
727
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "knowledge_base", results: [status] })}` }] };
|
|
728
|
+
}
|
|
729
|
+
if (op === "generate_key") {
|
|
730
|
+
if (!keyFile) {
|
|
731
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "key_file path is required for generate_key", "Provide a writeable key file path"))}` }] };
|
|
732
|
+
}
|
|
733
|
+
const path = generateKeyFile(keyFile);
|
|
734
|
+
const msg = json({ status: "ok", provider: "knowledge_base", results: [{ title: "key generated", url: `file://${path}`, snippet: `Key written to ${path}. Back it up now — without it the store is unrecoverable.` }] });
|
|
735
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
736
|
+
}
|
|
737
|
+
if (op === "verify") {
|
|
738
|
+
const ok = verifyStoreKey();
|
|
739
|
+
const msg = json({ status: "ok", provider: "knowledge_base", results: [{ title: ok ? "key verified" : "key mismatch", url: "", snippet: ok ? "The configured key opens the store." : "The configured key does not open the store (or no key is configured / store is not encrypted). The store has not been modified." }] });
|
|
740
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
741
|
+
}
|
|
742
|
+
if (op === "backup") {
|
|
743
|
+
if (!keyFile) {
|
|
744
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "key_file path is required for backup (destination)", "Provide a backup file path; the active key is copied there"))}` }] };
|
|
745
|
+
}
|
|
746
|
+
const path = backupKeyFile(keyFile);
|
|
747
|
+
if (!path) {
|
|
748
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "No file-based key to back up", "The active key source is not kb.encryption.key_file, so nothing was copied. Back up INFOBROKER_KB_KEY / INFOBROKER_KB_PASSPHRASE manually."))}` }] };
|
|
749
|
+
}
|
|
750
|
+
const msg = json({ status: "ok", provider: "knowledge_base", results: [{ title: "key backed up", url: `file://${path}`, snippet: `Key copied to ${path}. Keep this file somewhere safe and separate from the store.` }] });
|
|
751
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
752
|
+
}
|
|
753
|
+
if (op === "rekey") {
|
|
754
|
+
if (!keyFile) {
|
|
755
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "key_file path is required for rekey (new target key file)", "Generate a new key file first (generate_key), then pass its path here"))}` }] };
|
|
756
|
+
}
|
|
757
|
+
let target = null;
|
|
758
|
+
try {
|
|
759
|
+
target = { kind: "raw", dek: readKeyFile(keyFile) };
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
target = null;
|
|
763
|
+
}
|
|
764
|
+
if (!target) {
|
|
765
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "Could not read the target key file", "Ensure the key file exists and contains a valid key"))}` }] };
|
|
766
|
+
}
|
|
767
|
+
const result = rekeyStoreTo(null, target);
|
|
768
|
+
if (!result) {
|
|
769
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "Store is not encrypted or no source key is loaded", "Re-key requires an encrypted store and an active key"))}` }] };
|
|
770
|
+
}
|
|
771
|
+
const msg = json({ status: "ok", provider: "knowledge_base", results: [{ title: "re-keyed", url: "", snippet: "Store re-keyed to the new key file. Update kb.encryption.key_file in config.local.json to point at the new key, then reload." }] });
|
|
772
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
773
|
+
}
|
|
774
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", `Unknown encryption operation "${op}"`, "Use one of: status, generate_key, verify, backup, rekey"))}` }] };
|
|
775
|
+
}
|
|
776
|
+
if (action === "search") {
|
|
777
|
+
const results = kbSearch(String(params.query ?? ""), Number(params.max_results), params.collection, params.source_type);
|
|
778
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "knowledge_base", results })}` }] };
|
|
779
|
+
}
|
|
780
|
+
if (action === "list") {
|
|
781
|
+
const entries = kbList(params.collection, params.source_type);
|
|
782
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "knowledge_base", results: entries })}` }] };
|
|
783
|
+
}
|
|
784
|
+
if (action === "get") {
|
|
785
|
+
const sourceUrl = params.source_url;
|
|
786
|
+
if (!sourceUrl) {
|
|
787
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "source_url is required for get action", "Provide source_url (see list action for identities)"))}` }] };
|
|
788
|
+
}
|
|
789
|
+
const doc = kbGet(sourceUrl);
|
|
790
|
+
if (!doc) {
|
|
791
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "not_found", `No document with source_url "${sourceUrl}"`, "Use list action to enumerate stored documents"))}` }] };
|
|
792
|
+
}
|
|
793
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "knowledge_base", results: [doc] })}` }] };
|
|
794
|
+
}
|
|
795
|
+
if (action === "ingest") {
|
|
796
|
+
const text = params.text;
|
|
797
|
+
const url = params.url;
|
|
798
|
+
if (!text && !url) {
|
|
799
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "At least one of text or url must be provided", "Provide text or url parameter"))}` }] };
|
|
800
|
+
}
|
|
801
|
+
let content = text || "";
|
|
802
|
+
if (url && !text) {
|
|
803
|
+
const fetched = await doFetchPage(url);
|
|
804
|
+
if (fetched.startsWith("[ERROR]")) {
|
|
805
|
+
return { content: [{ type: "text", text: fetched }] };
|
|
806
|
+
}
|
|
807
|
+
const parsed = JSON.parse(fetched.slice(5));
|
|
808
|
+
content = parsed.results?.[0]?.snippet || "";
|
|
809
|
+
}
|
|
810
|
+
const title = params.title || url || "untitled";
|
|
811
|
+
const sourceType = params.source_type || "explicit";
|
|
812
|
+
const isReport = sourceType === "report";
|
|
813
|
+
const collection = params.collection || (isReport ? "reports" : undefined);
|
|
814
|
+
const freshnessTier = params.freshness_tier || (isReport ? "report" : undefined);
|
|
815
|
+
const saveTo = params.save_to || getConfig().kb?.default_save_destination || "kb";
|
|
816
|
+
let finalUrl = resolveReportIdentity(title, url || params.source_url);
|
|
817
|
+
let diskPath;
|
|
818
|
+
if (saveTo === "disk" || saveTo === "both") {
|
|
819
|
+
diskPath = saveReportToDisk(title, content, String(params.format || "markdown"));
|
|
820
|
+
finalUrl = `file://${diskPath}`;
|
|
821
|
+
}
|
|
822
|
+
else if (!url) {
|
|
823
|
+
finalUrl = isReport ? resolveReportIdentity(title, undefined) : "";
|
|
824
|
+
}
|
|
825
|
+
const count = kbIngest(content, title, finalUrl, "explicit", collection, sourceType, freshnessTier);
|
|
826
|
+
const msg = json({
|
|
827
|
+
status: "ok",
|
|
828
|
+
provider: "knowledge_base",
|
|
829
|
+
results: [{ title: "ingested", url: finalUrl, snippet: `${count} chunks ingested` }],
|
|
830
|
+
meta: { chunks_ingested: count, source_type: sourceType, freshness_tier: freshnessTier, saved_to: saveTo, ...(collection ? { collection } : {}), ...(diskPath ? { disk_path: diskPath } : {}) },
|
|
831
|
+
});
|
|
832
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
833
|
+
}
|
|
834
|
+
if (action === "stats") {
|
|
835
|
+
const stats = kbStats();
|
|
836
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "knowledge_base", results: [stats] })}` }] };
|
|
837
|
+
}
|
|
838
|
+
const collection = params.collection;
|
|
839
|
+
const sourceUrl = params.source_url;
|
|
840
|
+
if (!collection && !sourceUrl) {
|
|
841
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "invalid_input", "At least one of collection or source_url must be provided", "Provide a filter parameter"))}` }] };
|
|
842
|
+
}
|
|
843
|
+
const count = kbDelete(collection, sourceUrl);
|
|
844
|
+
const msg = json({ status: "ok", provider: "knowledge_base", results: [{ title: "deleted", url: "", snippet: `${count} chunks removed` }], meta: { chunks_removed: count } });
|
|
845
|
+
return { content: [{ type: "text", text: `[OK] ${msg}` }] };
|
|
846
|
+
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
return { content: [{ type: "text", text: `[ERROR] ${json(err("knowledge_base", "internal_error", e instanceof Error ? e.message : String(e), "Check knowledge base configuration"))}` }] };
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
// --- reload_config ---
|
|
852
|
+
server.registerTool("infobroker_reload_config", {
|
|
853
|
+
title: "Reload Configuration",
|
|
854
|
+
description: "Re-read config without restarting. Active connections are preserved.",
|
|
855
|
+
inputSchema: {},
|
|
856
|
+
}, async () => {
|
|
857
|
+
try {
|
|
858
|
+
const newConfig = reloadConfig();
|
|
859
|
+
configureAllProviders(newConfig);
|
|
860
|
+
if (newConfig.kb) {
|
|
861
|
+
flushKbWrites();
|
|
862
|
+
initKb(newConfig.kb);
|
|
863
|
+
console.error("[infobroker] Knowledge base re-initialized");
|
|
864
|
+
}
|
|
865
|
+
const lock = getKbLockError();
|
|
866
|
+
const state = getKbEncryptionState();
|
|
867
|
+
const guidance = state === "enabled"
|
|
868
|
+
? " Enabling encryption makes the store unrecoverable without the key — back up your key now (kb 'encryption' action, 'backup')."
|
|
869
|
+
: state === "disabled"
|
|
870
|
+
? " Encryption disabled. If the store was previously encrypted it is now decrypted on disk; remove the key material only after confirming (kb 'encryption' action, 'status')."
|
|
871
|
+
: "";
|
|
872
|
+
const message = `Configuration reloaded. Knowledge base encryption: ${state}.${guidance}` +
|
|
873
|
+
(lock ? ` Knowledge base LOCKED: ${lock.message}` : "");
|
|
874
|
+
return { content: [{ type: "text", text: `[OK] ${json({ status: "ok", provider: "system", results: [{ message, provider_count: Object.keys(newConfig.providers).length }] })}` }] };
|
|
875
|
+
}
|
|
876
|
+
catch (e) {
|
|
877
|
+
return {
|
|
878
|
+
content: [
|
|
879
|
+
{
|
|
880
|
+
type: "text",
|
|
881
|
+
text: `[ERROR] ${json(err("system", "config_error", (e instanceof Error ? e.message : String(e)) + ". Previous config remains active.", "Fix config.json and retry"))}`,
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
process.on("SIGHUP", () => {
|
|
888
|
+
try {
|
|
889
|
+
const newConfig = reloadConfig();
|
|
890
|
+
configureAllProviders(newConfig);
|
|
891
|
+
if (newConfig.kb) {
|
|
892
|
+
flushKbWrites();
|
|
893
|
+
initKb(newConfig.kb);
|
|
894
|
+
}
|
|
895
|
+
console.error("[infobroker] Configuration reloaded via SIGHUP");
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
console.error("[infobroker] SIGHUP reload failed:", e instanceof Error ? e.message : String(e));
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
async function main() {
|
|
902
|
+
const transport = new StdioServerTransport();
|
|
903
|
+
await server.connect(transport);
|
|
904
|
+
}
|
|
905
|
+
main().catch((e) => {
|
|
906
|
+
console.error("Infobroker failed to start:", e);
|
|
907
|
+
process.exit(1);
|
|
908
|
+
});
|
|
909
|
+
//# sourceMappingURL=index.js.map
|