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:
2026-07-25 08:49:26 -07:00
parent 29bcb123fa
commit 51dceee224
60 changed files with 3207 additions and 107 deletions

22
scripts/docs-rebuild.mjs Normal file
View File

@@ -0,0 +1,22 @@
import { rpc } from "../docker/web-search/mcp-probe.mjs";
const [url, ...sources] = process.argv.slice(2);
if (!url) throw new Error("usage: node scripts/docs-rebuild.mjs <mcp-url> [source ...]");
await rpc(url, 1, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "context-kit-docs-rebuild", version: "1" }
}, 10_000);
const result = await rpc(url, 2, "tools/call", {
name: "docs_rebuild",
arguments: sources.length ? { sources } : {}
}, 600_000);
if (result.isError) {
const text = (result.content || []).map(part => part.text || "").join("\n");
throw new Error(text || "docs_rebuild failed");
}
const structured = result.structuredContent || JSON.parse(
(result.content || []).find(part => part.type === "text")?.text || "{}"
);
console.log(JSON.stringify(structured, null, 2));

309
scripts/docs_snapshot.py Normal file
View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import html
import json
import os
import re
import tempfile
from dataclasses import dataclass
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
from urllib.error import HTTPError
from urllib.request import Request, urlopen
GENERATOR_VERSION = "1"
_LINK = re.compile(r"^\s*[-*]\s+\[([^]]+)]\(([^)]+)\)(?::\s*(.*))?\s*$")
@dataclass(frozen=True)
class MenuEntry:
title: str
url: str
description: str
@dataclass(frozen=True)
class FetchedPage:
requested_url: str
resolved_url: str
body: bytes
content_type: str
etag: str | None
last_modified: str | None
def parse_menu(content: str, source_url: str = "") -> list[MenuEntry]:
entries: list[MenuEntry] = []
for line in content.splitlines():
match = _LINK.match(line)
if match:
entries.append(
MenuEntry(
title=match.group(1).strip(),
url=urljoin(source_url, match.group(2).strip()),
description=(match.group(3) or "").strip(),
)
)
return entries
class _ReadableHTML(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.all_parts: list[str] = []
self.main_parts: list[str] = []
self.main_depth = 0
self.skip_depth = 0
self.heading_level = 0
def handle_starttag(self, tag: str, attrs) -> None:
tag = tag.lower()
if tag in {"script", "style", "svg", "noscript", "nav", "footer"}:
self.skip_depth += 1
return
if tag in {"main", "article"}:
self.main_depth += 1
if self.skip_depth:
return
if tag in {"p", "div", "section", "br", "table", "tr", "pre"}:
self._append("\n")
elif tag == "li":
self._append("\n- ")
elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self.heading_level = int(tag[1])
self._append(f"\n\n{'#' * self.heading_level} ")
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in {"script", "style", "svg", "noscript", "nav", "footer"} and self.skip_depth:
self.skip_depth -= 1
return
if not self.skip_depth and tag in {"p", "div", "section", "li", "tr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"}:
self._append("\n")
if tag in {"main", "article"} and self.main_depth:
self.main_depth -= 1
if tag.startswith("h"):
self.heading_level = 0
def handle_data(self, data: str) -> None:
if not self.skip_depth:
self._append(data)
def _append(self, text: str) -> None:
self.all_parts.append(text)
if self.main_depth:
self.main_parts.append(text)
def rendered(self) -> str:
preferred = self.main_parts if any(part.strip() for part in self.main_parts) else self.all_parts
text = html.unescape("".join(preferred)).replace("\r", "")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r" *\n *", "\n", text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def page_to_markdown(page: FetchedPage) -> str:
text = page.body.decode("utf-8", errors="replace")
content_type = page.content_type.lower()
if "html" not in content_type and not re.search(r"<html|<main|<article", text[:1000], re.I):
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
parser = _ReadableHTML()
parser.feed(text)
return parser.rendered()
class CachedFetcher:
def __init__(self, cache_dir: Path, offline: bool = False, timeout: float = 30):
self.cache_dir = cache_dir
self.offline = offline
self.timeout = timeout
self.cache_dir.mkdir(parents=True, exist_ok=True)
def close(self) -> None:
return None
def fetch(self, url: str) -> FetchedPage:
key = hashlib.sha256(url.encode()).hexdigest()
body_path = self.cache_dir / f"{key}.body"
metadata_path = self.cache_dir / f"{key}.json"
metadata = json.loads(metadata_path.read_text()) if metadata_path.exists() else {}
if self.offline:
if not body_path.exists():
raise RuntimeError(f"offline cache miss: {url}")
return self._cached(url, body_path, metadata)
headers = {}
if metadata.get("etag"):
headers["If-None-Match"] = metadata["etag"]
if metadata.get("last_modified"):
headers["If-Modified-Since"] = metadata["last_modified"]
headers["User-Agent"] = "context-kit-snapshot/1.0"
try:
response = urlopen(Request(url, headers=headers), timeout=self.timeout)
except HTTPError as error:
if error.code != 304:
raise
response = error
if response.status == 304:
if not body_path.exists():
raise RuntimeError(f"HTTP 304 without cached body: {url}")
return self._cached(url, body_path, metadata)
body = response.read()
metadata = {
"requested_url": url,
"resolved_url": response.geturl(),
"content_type": response.headers.get("content-type", ""),
"etag": response.headers.get("etag"),
"last_modified": response.headers.get("last-modified"),
"sha256": hashlib.sha256(body).hexdigest(),
}
atomic_write(body_path, body)
atomic_write(metadata_path, (json.dumps(metadata, sort_keys=True, indent=2) + "\n").encode())
return self._cached(url, body_path, metadata)
@staticmethod
def _cached(url: str, body_path: Path, metadata: dict) -> FetchedPage:
return FetchedPage(
requested_url=url,
resolved_url=metadata.get("resolved_url", url),
body=body_path.read_bytes(),
content_type=metadata.get("content_type", "text/plain"),
etag=metadata.get("etag"),
last_modified=metadata.get("last_modified"),
)
def build_snapshot(menu_path: Path, fetcher) -> dict:
menu = menu_path.read_text()
entries = parse_menu(menu)
if not entries:
raise RuntimeError(f"no markdown links in {menu_path}")
sections: list[str] = []
documents: list[dict] = []
failures: list[str] = []
for entry in entries:
try:
page = fetcher.fetch(entry.url)
content = page_to_markdown(page)
if not content:
raise RuntimeError("extracted content is empty")
sections.append(f"# {entry.title}\n\nSource: {page.resolved_url}\n\n{content}")
documents.append(
{
"title": entry.title,
"requested_url": entry.url,
"resolved_url": page.resolved_url,
"content_sha256": hashlib.sha256(content.encode()).hexdigest(),
"source_sha256": hashlib.sha256(page.body).hexdigest(),
"etag": page.etag,
"last_modified": page.last_modified,
}
)
except Exception as error:
failures.append(f"{entry.url}: {error}")
if failures:
raise RuntimeError("snapshot fetch failed; previous output preserved:\n" + "\n".join(failures))
output = ("\n\n".join(sections).strip() + "\n").encode()
manifest = {
"schema_version": 1,
"generator_version": GENERATOR_VERSION,
"menu": menu_path.name,
"menu_sha256": hashlib.sha256(menu.encode()).hexdigest(),
"output_sha256": hashlib.sha256(output).hexdigest(),
"document_count": len(documents),
"documents": documents,
}
output_path = menu_path.with_name("llms-full.txt")
manifest_path = menu_path.with_name("llms-full.provenance.json")
atomic_write(output_path, output)
atomic_write(manifest_path, (json.dumps(manifest, sort_keys=True, indent=2) + "\n").encode())
return {"menu": str(menu_path), "output": str(output_path), **manifest}
def validate_snapshot(output_path: Path) -> dict:
manifest_path = output_path.with_name("llms-full.provenance.json")
if not output_path.is_file() or not manifest_path.is_file():
raise RuntimeError("snapshot or provenance manifest is missing")
manifest = json.loads(manifest_path.read_text())
output_hash = hashlib.sha256(output_path.read_bytes()).hexdigest()
if manifest.get("output_sha256") != output_hash:
raise RuntimeError("snapshot hash does not match provenance manifest")
menu_path = output_path.with_name(str(manifest.get("menu") or "llms.txt"))
if not menu_path.is_file():
raise RuntimeError("snapshot source menu is missing")
menu_hash = hashlib.sha256(menu_path.read_bytes()).hexdigest()
if manifest.get("menu_sha256") != menu_hash:
raise RuntimeError("menu hash does not match provenance manifest")
return manifest
def atomic_write(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except Exception:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def snapshot_menus(menus: list[Path], fetcher) -> dict:
"""Snapshot every menu independently so one bad directory cannot block the rest."""
report: dict = {"snapshots": [], "skipped": [], "failures": []}
for menu in menus:
if not parse_menu(menu.read_text()):
report["skipped"].append({"menu": str(menu), "reason": "no markdown links"})
continue
try:
report["snapshots"].append(build_snapshot(menu, fetcher))
except Exception as error:
report["failures"].append({"menu": str(menu), "error": str(error)})
return report
def main() -> None:
parser = argparse.ArgumentParser(description="Build deterministic content snapshots from local llms.txt menus.")
parser.add_argument("--source-root", type=Path)
parser.add_argument("--cache-dir", type=Path)
parser.add_argument("--only", action="append", default=[])
parser.add_argument("--offline", action="store_true")
parser.add_argument("--validate-output", type=Path)
args = parser.parse_args()
if args.validate_output:
print(json.dumps(validate_snapshot(args.validate_output), sort_keys=True))
return
if not args.source_root:
parser.error("--source-root is required unless --validate-output is used")
cache_dir = args.cache_dir or args.source_root / ".snapshot-cache"
menus = sorted(args.source_root.glob("*/llms.txt"))
if args.only:
selected = set(args.only)
menus = [menu for menu in menus if menu.parent.name in selected]
if not menus:
raise SystemExit("no matching llms.txt menus")
fetcher = CachedFetcher(cache_dir, offline=args.offline)
try:
report = snapshot_menus(menus, fetcher)
finally:
fetcher.close()
print(json.dumps(report, sort_keys=True, indent=2))
if report["failures"]:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,11 @@
# Environment Variables
Source: https://docs.example.test/environment
`CONTEXT_KIT_EXACT_IDENTIFIER_20260724` enables the deterministic candidate fixture.
# Durable Persistence
Source: https://docs.example.test/persistence
Checkpoints preserve graph state across process restarts.

View File

@@ -0,0 +1 @@
http://127.0.0.1:8769/fixture/llms-full.txt

View File

@@ -0,0 +1,52 @@
import http from "node:http";
let websocketUpgrades = 0;
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://mock-search.test");
if (url.pathname === "/search") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
results: [{
title: "Deterministic Search Result",
url: "https://example.test/result",
content: `fixture result for ${url.searchParams.get("q")}`
}]
}));
return;
}
if (url.pathname === "/dynamic") {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(`<!doctype html><html><head><title>Dynamic Fixture</title></head>
<body><main id="content">initial content</main>
<script>document.getElementById("content").textContent = "BROWSER_RENDERED_MARKER";</script>
</body></html>`);
return;
}
if (url.pathname === "/redirect-private") {
response.writeHead(302, { Location: "http://127.0.0.1:8765/private" });
response.end();
return;
}
if (url.pathname === "/websocket-attempt") {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(`<!doctype html><html><body><main id="content">starting</main>
<script>
const socket = new WebSocket("ws://mock-search.test:8080/socket");
socket.onerror = () => { document.getElementById("content").textContent = "WEBSOCKET_BLOCKED"; };
</script></body></html>`);
return;
}
if (url.pathname === "/ws-count") {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end(String(websocketUpgrades));
return;
}
response.writeHead(404).end();
});
server.listen(8080, "0.0.0.0");
server.on("upgrade", (_request, socket) => {
websocketUpgrades += 1;
socket.destroy();
});

View File

@@ -267,7 +267,7 @@ bash -n scripts/release-check
bash -n scripts/test-compose-upgrade.sh
bash -n scripts/test-lifecycle.sh
sh -n docker/docs/entrypoint.sh
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-web-search-http.mjs
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/overrides/brave.js docker/web-search/overrides/browser-fetch.js docker/web-search/overrides/registry.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/docs-rebuild.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-docs-candidate.mjs scripts/test-web-search-candidate.mjs scripts/test-web-search-http.mjs scripts/test-web-search-quality.mjs
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
@@ -296,6 +296,8 @@ assert_redaction_check_does_not_disclose_matches
bash scripts/test-compose-upgrade.sh
bash scripts/test-lifecycle.sh
node scripts/test-web-search-http.mjs
node scripts/test-web-search-quality.mjs
python3 scripts/test-doc-snapshots.py
bin/context-kit redaction-check
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
@@ -313,6 +315,12 @@ assert_hostile_requests_rejected
node scripts/smoke-web-search.mjs bin/context-kit web-search
node scripts/smoke-docs.mjs bin/context-kit docs
node scripts/smoke-repomix.mjs bin/context-kit repomix
docker run --rm --entrypoint python "${CONTEXT_KIT_DOCS_IMAGE}" -m unittest discover -s /opt/context-kit/tests -t /opt/context-kit
CONTEXT_KIT_DOCS_CANDIDATE_IMAGE="${CONTEXT_KIT_DOCS_IMAGE}" \
CONTEXT_KIT_DOCS_TEST_MODELS="${CONTEXT_KIT_DATA_DIR}/models" \
bash scripts/test-docs-candidate.sh
CONTEXT_KIT_WEB_SEARCH_CANDIDATE_IMAGE="${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
bash scripts/test-web-search-candidate.sh
assert_web_search_backend_supervision
printf 'pass release-check\n'

View File

@@ -1,4 +1,4 @@
import { requireToolSuccess, runSmoke } from "./mcp-smoke-client.mjs";
import { requireToolSuccess, runSmoke, textFrom } from "./mcp-smoke-client.mjs";
const live = process.env.CONTEXT_KIT_LIVE_CHECKS === "1";
const localSourceSmokeUrl = process.env.CONTEXT_KIT_LOCAL_SOURCE_SMOKE_URL;
@@ -12,7 +12,8 @@ runSmoke({
const toolNames = await client.requireTools(["docs_query", "docs_sources"]);
const sources = requireToolSuccess("docs_sources", await client.callTool("docs_sources"));
if (!Array.isArray(sources?.structuredContent?.result)) {
const sourcesPayload = sources?.structuredContent || JSON.parse(textFrom(sources) || "null");
if (typeof sourcesPayload?.source_count !== "number") {
const sourcesText = JSON.stringify(sources);
throw new Error(`docs_sources returned unexpected payload: ${sourcesText.slice(0, 500)}`);
}

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from docs_snapshot import FetchedPage, build_snapshot, page_to_markdown, snapshot_menus, validate_snapshot
class FakeFetcher:
def __init__(self, pages: dict[str, FetchedPage | Exception]):
self.pages = pages
def fetch(self, url: str) -> FetchedPage:
result = self.pages[url]
if isinstance(result, Exception):
raise result
return result
def page(url: str, body: str, content_type: str = "text/html") -> FetchedPage:
return FetchedPage(url, url, body.encode(), content_type, '"fixture"', "Wed, 01 Jan 2025 00:00:00 GMT")
class SnapshotTest(unittest.TestCase):
def test_html_extraction_prefers_main_and_discards_navigation(self) -> None:
rendered = page_to_markdown(
page(
"https://example.test/page",
"<html><nav>Noise</nav><main><h1>API</h1><p>Useful content.</p></main></html>",
)
)
self.assertNotIn("Noise", rendered)
self.assertIn("# API", rendered)
self.assertIn("Useful content.", rendered)
def test_snapshot_and_manifest_are_deterministic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
menu = Path(directory) / "fixture" / "llms.txt"
menu.parent.mkdir()
menu.write_text("# Menu\n\n- [API](https://example.test/api)\n")
fetcher = FakeFetcher(
{"https://example.test/api": page("https://example.test/api", "<main><h1>API</h1><p>Stable.</p></main>")}
)
first = build_snapshot(menu, fetcher)
first_output = menu.with_name("llms-full.txt").read_bytes()
first_manifest = menu.with_name("llms-full.provenance.json").read_bytes()
second = build_snapshot(menu, fetcher)
self.assertEqual(first["output_sha256"], second["output_sha256"])
self.assertEqual(first_output, menu.with_name("llms-full.txt").read_bytes())
self.assertEqual(first_manifest, menu.with_name("llms-full.provenance.json").read_bytes())
self.assertEqual(first["output_sha256"], validate_snapshot(menu.with_name("llms-full.txt"))["output_sha256"])
menu.with_name("llms-full.txt").write_text("tampered\n")
with self.assertRaisesRegex(RuntimeError, "does not match"):
validate_snapshot(menu.with_name("llms-full.txt"))
def test_failed_build_preserves_last_good_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as directory:
menu = Path(directory) / "fixture" / "llms.txt"
menu.parent.mkdir()
menu.write_text("# Menu\n\n- [API](https://example.test/api)\n")
output = menu.with_name("llms-full.txt")
output.write_text("last good\n")
with self.assertRaisesRegex(RuntimeError, "previous output preserved"):
build_snapshot(menu, FakeFetcher({"https://example.test/api": RuntimeError("offline")}))
self.assertEqual("last good\n", output.read_text())
def test_one_bad_menu_does_not_block_other_directories(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name, body in [
("good", "# Menu\n\n- [API](https://example.test/api)\n"),
("prose-only", "# Workspace Notes\n\nNo links here, just prose.\n"),
("broken", "# Menu\n\n- [Down](https://example.test/down)\n"),
]:
(root / name).mkdir()
(root / name / "llms.txt").write_text(body)
fetcher = FakeFetcher({
"https://example.test/api": page("https://example.test/api", "<main><h1>API</h1><p>Stable.</p></main>"),
"https://example.test/down": RuntimeError("host unreachable"),
})
report = snapshot_menus(sorted(root.glob("*/llms.txt")), fetcher)
self.assertEqual(1, len(report["snapshots"]))
self.assertTrue((root / "good" / "llms-full.txt").exists())
self.assertEqual(1, len(report["skipped"]))
self.assertIn("prose-only", report["skipped"][0]["menu"])
self.assertEqual(1, len(report["failures"]))
self.assertIn("broken", report["failures"][0]["menu"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { probeMcp, rpc } from "../docker/web-search/mcp-probe.mjs";
const url = process.argv[2];
if (!url) throw new Error("usage: node scripts/test-docs-candidate.mjs <mcp-url>");
const required = ["docs_query", "docs_rebuild", "docs_refresh", "docs_sources"];
const tools = await probeMcp(url, { timeoutMs: 10_000, expectedTools: required });
assert.deepEqual(tools, required);
function structured(result) {
if (result.structuredContent) return result.structuredContent;
const text = (result.content || []).find(part => part.type === "text")?.text;
return text ? JSON.parse(text) : null;
}
const refreshed = structured(await rpc(url, 3, "tools/call", {
name: "docs_refresh",
arguments: { force: true }
}, 120_000));
assert.equal(refreshed.sources[0].status, "updated", JSON.stringify(refreshed.sources[0]));
assert(refreshed.sources[0].document_count >= 2);
const search = structured(await rpc(url, 4, "tools/call", {
name: "docs_query",
arguments: { query: "CONTEXT_KIT_EXACT_IDENTIFIER_20260724", limit: 3 }
}, 30_000));
assert.equal(search.search_results[0].title, "Environment Variables");
assert.deepEqual(search.retrieved_content, {});
const identifier = search.search_results[0].id;
const retrieved = structured(await rpc(url, 5, "tools/call", {
name: "docs_query",
arguments: {
query: "CONTEXT_KIT_EXACT_IDENTIFIER_20260724",
retrieve_ids: [identifier],
max_bytes: 12_000
}
}, 30_000));
assert(retrieved.retrieved_content[identifier].content.includes("CONTEXT_KIT_EXACT_IDENTIFIER_20260724"));
console.log("pass docs candidate transport, refresh, hybrid search, and explicit retrieval");

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
IMAGE="${CONTEXT_KIT_DOCS_CANDIDATE_IMAGE:-context-kit/docs-mcp:quality-20260724}"
MODELS="${CONTEXT_KIT_DOCS_TEST_MODELS:-${CONTEXT_KIT_DATA_DIR:-${HOME}/.local/share/context-kit}/models}"
TMP_DIR="$(mktemp -d)"
CONTAINER="context-kit-docs-quality-$RANDOM-$$"
cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
mkdir -p "${TMP_DIR}/data"
docker run -d --name "${CONTAINER}" \
--user "$(id -u):$(id -g)" \
-p 127.0.0.1::8000 \
-e HF_HUB_OFFLINE=1 \
-e TRANSFORMERS_OFFLINE=1 \
-e DOCS_MCP_PREINDEX=0 \
-v "${TMP_DIR}/data:/data" \
-v "${MODELS}:/models:ro" \
-v "${ROOT}/scripts/fixtures/docs/sources.txt:/etc/context-kit/docs-sources.txt:ro" \
-v "${ROOT}/scripts/fixtures/docs/local-sources:/etc/context-kit/local-sources:ro" \
"${IMAGE}" >/dev/null
binding="$(docker port "${CONTAINER}" 8000/tcp)"
port="${binding##*:}"
for _ in {1..120}; do
if curl -fsS "http://127.0.0.1:${port}/status" >/dev/null 2>&1; then
node "${ROOT}/scripts/test-docs-candidate.mjs" "http://127.0.0.1:${port}/mcp"
exit 0
fi
sleep 0.25
done
docker logs "${CONTAINER}" >&2
exit 1

View File

@@ -259,7 +259,7 @@ new_case() {
unset CONTEXT_KIT_DOCKER_CIDFILE CONTEXT_KIT_RUNTIME_DIR FAKE_DOCS_UID FAKE_WEB_UID \
FAKE_REPLACEMENT_REQUIRED FAKE_RESTART_FAIL FAKE_DROP_RUNNING FAKE_SEARXNG_FAIL \
FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH \
FAKE_EXPECT_DOCS_SOURCES FAKE_EXPECT_DOCS_SOURCES_ABSENT
FAKE_EXPECT_DOCS_SOURCES FAKE_EXPECT_DOCS_SOURCES_ABSENT CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR
mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}"
: > "${FAKE_DOCKER_LOG}"
}
@@ -432,6 +432,34 @@ grep -F 'https://example.test/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.tx
|| fail_test "restart did not regenerate the bind-mounted docs source list"
assert_no_docs_sources_artifacts
new_case snapshot-promotion
seed_service searxng
seed_service web-search-mcp
seed_service docs-mcp
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${CASE_ROOT}/local-sources"
mkdir -p "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich"
printf '# source menu\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms.txt"
printf '# generated snapshot\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt"
menu_hash="$(sha256sum "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms.txt")"
menu_hash="${menu_hash%% *}"
output_hash="$(sha256sum "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt")"
output_hash="${output_hash%% *}"
printf '{"menu":"llms.txt","menu_sha256":"%s","output_sha256":"%s"}\n' \
"${menu_hash}" "${output_hash}" \
> "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.provenance.json"
printf 'http://127.0.0.1:8769/immich/llms.txt\n' > "${CASE_ROOT}/sources.txt"
export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/sources.txt"
"${CONTEXT_KIT}" restart
grep -F 'http://127.0.0.1:8769/immich/llms-full.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|| fail_test "restart did not promote a local menu to its generated full snapshot"
if grep -Fx 'http://127.0.0.1:8769/immich/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null; then
fail_test "restart retained the menu URL despite an available full snapshot"
fi
printf '# inconsistent snapshot\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt"
"${CONTEXT_KIT}" restart
grep -Fx 'http://127.0.0.1:8769/immich/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|| fail_test "restart promoted a snapshot whose provenance hash did not match"
new_case restart-failure
seed_service searxng stopped
seed_service web-search-mcp

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { probeMcp, rpc } from "../docker/web-search/mcp-probe.mjs";
const url = process.argv[2];
if (!url) throw new Error("usage: node scripts/test-web-search-candidate.mjs <mcp-url>");
await probeMcp(url, { timeoutMs: 10_000 });
function payload(result) {
const text = (result.content || []).find(part => part.type === "text")?.text;
return text ? JSON.parse(text) : result.structuredContent;
}
const search = payload(await rpc(url, 3, "tools/call", {
name: "search_web",
arguments: { q: "candidate diagnostic fixture", limit: 3, provider: "searxng" }
}, 30_000));
assert.equal(search.items[0].title, "Deterministic Search Result");
assert.equal(search.providerUsed, "searxng");
assert.equal(search.diagnostics.attempts[0].status, "success");
assert.equal(search.diagnostics.attempts[0].result_count, 1);
const httpFetch = payload(await rpc(url, 4, "tools/call", {
name: "fetch_url",
arguments: { url: "http://mock-search.test:8080/dynamic", engine: "http", format: "text" }
}, 30_000));
assert(!httpFetch.content.includes("BROWSER_RENDERED_MARKER"));
const browserFetch = payload(await rpc(url, 5, "tools/call", {
name: "fetch_url",
arguments: { url: "http://mock-search.test:8080/dynamic", engine: "browser", format: "text", timeout_ms: 20_000 }
}, 60_000));
assert(browserFetch.content.includes("BROWSER_RENDERED_MARKER"));
const blocked = await rpc(url, 6, "tools/call", {
name: "fetch_url",
arguments: { url: "http://127.0.0.1:8765/private", engine: "browser" }
}, 30_000);
assert.equal(blocked.isError, true);
assert((blocked.content || []).some(part => part.text?.includes("Blocked localhost/private URL")));
const blockedRedirect = await rpc(url, 7, "tools/call", {
name: "fetch_url",
arguments: { url: "http://mock-search.test:8080/redirect-private", engine: "browser" }
}, 30_000);
assert.equal(blockedRedirect.isError, true);
await rpc(url, 8, "tools/call", {
name: "fetch_url",
arguments: { url: "http://mock-search.test:8080/websocket-attempt", engine: "browser", fresh: true }
}, 30_000);
const websocketCount = payload(await rpc(url, 9, "tools/call", {
name: "fetch_url",
arguments: { url: "http://mock-search.test:8080/ws-count", engine: "http", format: "text", fresh: true }
}, 30_000));
assert.equal(websocketCount.content.trim(), "0");
console.log("pass web-search candidate diagnostics, browser rendering, and SSRF rejection");

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
IMAGE="${CONTEXT_KIT_WEB_SEARCH_CANDIDATE_IMAGE:-context-kit/web-search-mcp:quality-20260724}"
NETWORK="context-kit-web-quality-$RANDOM-$$"
MOCK="${NETWORK}-mock"
SERVER="${NETWORK}-server"
cleanup() {
docker rm -f "${SERVER}" "${MOCK}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker network create --subnet 203.0.113.0/24 "${NETWORK}" >/dev/null
docker run -d --name "${MOCK}" --network "${NETWORK}" --ip 203.0.113.10 \
--network-alias mock-search.test \
-v "${ROOT}/scripts/fixtures/web/mock-server.mjs:/fixture/mock-server.mjs:ro" \
node:22-bookworm-slim node /fixture/mock-server.mjs >/dev/null
docker run -d --name "${SERVER}" --network "${NETWORK}" --ip 203.0.113.11 \
-p 127.0.0.1::8000 \
-e SEARXNG_URL=http://mock-search.test:8080 \
-e DEFAULT_SEARCH_PROVIDER=searxng \
"${IMAGE}" >/dev/null
binding="$(docker port "${SERVER}" 8000/tcp)"
port="${binding##*:}"
for _ in {1..120}; do
if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
node "${ROOT}/scripts/test-web-search-candidate.mjs" "http://127.0.0.1:${port}/mcp"
exit 0
fi
sleep 0.25
done
docker logs "${SERVER}" >&2
exit 1

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { boundFetchCollections } from "../docker/web-search/overrides/bounds.mjs";
import {
attemptProvider,
classifyProviderError
} from "../docker/web-search/overrides/diagnostics.mjs";
assert.deepEqual(classifyProviderError(new Error("HTTP 429 rate limit")), {
category: "rate_limited",
message: "HTTP 429 rate limit"
});
assert.equal(classifyProviderError(new Error("captcha challenge")).category, "blocked");
const unavailable = await attemptProvider({ name: "brave", configured: false }, "q", 3, "en");
assert.equal(unavailable.diagnostic.status, "unavailable");
assert.equal(unavailable.diagnostic.result_count, 0);
const empty = await attemptProvider({
name: "empty",
async search() { return []; }
}, "q", 3, "en");
assert.equal(empty.diagnostic.status, "empty");
const failed = await attemptProvider({
name: "failed",
async search() { throw new Error("network socket failed"); }
}, "q", 3, "en");
assert.equal(failed.diagnostic.status, "error");
assert.equal(failed.diagnostic.error.category, "network");
let underlyingAborted = false;
const timedOut = await attemptProvider({
name: "slow",
async search(_query, _limit, _lang, signal) {
await new Promise((resolve, reject) => {
signal.addEventListener("abort", () => {
underlyingAborted = true;
reject(signal.reason);
}, { once: true });
});
}
}, "q", 3, "en", { timeoutMs: 20 });
assert.equal(timedOut.diagnostic.error.category, "timeout");
assert.equal(underlyingAborted, true);
const result = boundFetchCollections({
links: Array.from({ length: 550 }, (_, index) => ({ url: `https://example.test/${index}` })),
media: {
images: Array.from({ length: 250 }, (_, index) => ({ url: `https://example.test/${index}.png` })),
videos: [],
audio: []
},
warnings: []
});
assert.equal(result.links.length, 500);
assert.equal(result.media.images.length, 200);
assert(result.warnings.some(warning => warning.includes("links truncated")));
assert(result.warnings.some(warning => warning.includes("images truncated")));
console.log("pass web-search diagnostics and collection bounds tests");