@andrhamm/web-mcp 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,207 @@
1
+ // The only Claude integration: the unmodified `claude` binary in print mode, on the operator's own sign-in.
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { MODEL_RESULT, ToolError } from "./contract.js";
7
+ import { limiter, QueueFull } from "./limit.js";
8
+ // Nesting markers (a stdio MCP server inherits them from Claude Code) and API keys (subscription only: never bill the API).
9
+ const STRIP = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_CHILD_SESSION", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
10
+ export function childEnv(env = process.env) {
11
+ const e = { ...env };
12
+ for (const k of STRIP)
13
+ delete e[k];
14
+ return e;
15
+ }
16
+ const bin = () => process.env.CLAUDE_BIN || "claude";
17
+ const envInt = (name, fallback, min) => {
18
+ const v = Number.parseInt(process.env[name] ?? "", 10);
19
+ return Number.isFinite(v) && v >= min ? v : fallback;
20
+ };
21
+ // Every run spends the operator's subscription, so concurrency is small and the wait queue is bounded.
22
+ const run = limiter(envInt("WEB_MCP_CONCURRENCY", 2, 1), envInt("WEB_MCP_QUEUE", 8, 0));
23
+ export function claudeArgs(o) {
24
+ return [
25
+ "-p",
26
+ "--model",
27
+ process.env.WEB_MCP_MODEL || "haiku",
28
+ "--output-format",
29
+ "stream-json",
30
+ "--verbose",
31
+ "--json-schema",
32
+ o.schema,
33
+ "--system-prompt",
34
+ o.system,
35
+ "--max-turns",
36
+ String(o.maxTurns),
37
+ "--permission-prompts",
38
+ "none",
39
+ "--tools",
40
+ o.tools.join(","),
41
+ // --tools only makes tools available; without pre-approval --permission-prompts none denies them.
42
+ ...(o.tools.length ? ["--allowed-tools", o.tools.join(",")] : []),
43
+ // No user settings, hooks, plugins, MCP servers, slash commands or session files: only the tools above.
44
+ "--strict-mcp-config",
45
+ "--setting-sources",
46
+ "",
47
+ "--exclude-dynamic-system-prompt-sections",
48
+ "--disable-slash-commands",
49
+ "--no-session-persistence",
50
+ ];
51
+ }
52
+ const NO_RESULT = "Claude ended without a result";
53
+ const blocks = (e) => (Array.isArray(e.message?.content) ? e.message.content : []);
54
+ /** Read the stream-json events: real tool calls for meta, and the final result. */
55
+ export function parseStream(stdout) {
56
+ const calls = new Map();
57
+ const ok = new Set();
58
+ let result;
59
+ for (const line of stdout.split("\n")) {
60
+ if (!line.trim())
61
+ continue;
62
+ let e;
63
+ try {
64
+ e = JSON.parse(line);
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ if (e.type === "assistant") {
70
+ for (const b of blocks(e)) {
71
+ if (b.type !== "tool_use")
72
+ continue;
73
+ if (!b.id)
74
+ continue;
75
+ if (b.name === "WebSearch" && b.input?.query)
76
+ calls.set(b.id, { name: b.name, arg: String(b.input.query) });
77
+ if (b.name === "WebFetch" && b.input?.url)
78
+ calls.set(b.id, { name: b.name, arg: String(b.input.url) });
79
+ }
80
+ }
81
+ else if (e.type === "user") {
82
+ for (const b of blocks(e)) {
83
+ if (b?.type === "tool_result" && b.is_error !== true && b.tool_use_id)
84
+ ok.add(b.tool_use_id);
85
+ }
86
+ }
87
+ else if (e.type === "result")
88
+ result = e;
89
+ }
90
+ // Count only calls that ran (a denied or failed call has an error result).
91
+ const ran = [...calls].filter(([id]) => ok.has(id)).map(([, c]) => c);
92
+ const queries = ran.filter((c) => c.name === "WebSearch").map((c) => c.arg);
93
+ const pages = ran.filter((c) => c.name === "WebFetch").map((c) => c.arg);
94
+ if (result?.permission_denials?.length)
95
+ console.error(`[web-mcp] claude denied ${result.permission_denials.length} tool calls`);
96
+ if (!result)
97
+ throw new ToolError("bad_output", NO_RESULT);
98
+ if (result.is_error || result.structured_output === undefined) {
99
+ // Classify failures only: a successful answer may well mention limits or authentication.
100
+ const text = String(result.result ?? "");
101
+ if (result.api_error_status === 429 || /usage limit|rate limit/i.test(text))
102
+ throw new ToolError("rate_limited", "The Claude subscription limit was reached", "Try again after the limit resets");
103
+ if (/not logged in|please run \/login|invalid api key|oauth token/i.test(text))
104
+ throw new ToolError("claude_not_signed_in", "Claude Code is not signed in", "Run `claude` once and sign in");
105
+ const why = String(result.terminal_reason ?? result.subtype ?? "");
106
+ throw new ToolError("bad_output", `Claude returned no structured result (${why || "unknown reason"})`, /turn/i.test(why) ? "Ask a narrower question or use more depth" : undefined);
107
+ }
108
+ const parsed = MODEL_RESULT.safeParse(result.structured_output);
109
+ if (!parsed.success)
110
+ throw new ToolError("bad_output", "Claude's result did not match the schema");
111
+ return { output: parsed.data, queries, pages, model: Object.keys(result.modelUsage ?? {})[0] ?? "haiku" };
112
+ }
113
+ const cancelled = () => new ToolError("cancelled", "The caller cancelled the request");
114
+ /** How long a stopped claude process gets to exit after SIGTERM before it is killed. */
115
+ const KILL_GRACE_MS = 3000;
116
+ export async function runClaude(o) {
117
+ const deadline = Date.now() + o.timeoutMs;
118
+ const wait = AbortSignal.any([AbortSignal.timeout(o.timeoutMs), ...(o.signal ? [o.signal] : [])]);
119
+ try {
120
+ return await run(() => spawnClaude(o, deadline), wait);
121
+ }
122
+ catch (e) {
123
+ if (e instanceof ToolError)
124
+ throw e;
125
+ if (e instanceof QueueFull)
126
+ throw new ToolError("busy", `Too many research calls at once: ${e.message}`, "Try again shortly");
127
+ if (o.signal?.aborted)
128
+ throw cancelled();
129
+ if (wait.aborted)
130
+ throw new ToolError("timeout", `No free slot within ${Math.round(o.timeoutMs / 1000)} s`, "The server is busy; try again shortly");
131
+ throw e;
132
+ }
133
+ }
134
+ async function spawnClaude(o, deadline) {
135
+ if (o.signal?.aborted)
136
+ throw cancelled();
137
+ const remaining = deadline - Date.now();
138
+ if (remaining <= 0)
139
+ throw new ToolError("timeout", "No time left after waiting for a free slot", "The server is busy; try again shortly");
140
+ const cwd = await mkdtemp(join(tmpdir(), "web-mcp-")); // empty: no project CLAUDE.md or .mcp.json
141
+ try {
142
+ let stderr = "";
143
+ const stdout = await new Promise((resolve, reject) => {
144
+ const p = spawn(bin(), claudeArgs(o), { cwd, env: childEnv(), stdio: ["pipe", "pipe", "pipe"] });
145
+ let out = "";
146
+ let err = "";
147
+ let killer;
148
+ const stop = (e) => {
149
+ p.kill("SIGTERM");
150
+ killer = setTimeout(() => p.kill("SIGKILL"), KILL_GRACE_MS); // force a run that ignores the polite stop
151
+ killer.unref();
152
+ reject(e);
153
+ };
154
+ const onAbort = () => stop(cancelled());
155
+ const timer = setTimeout(() => stop(new ToolError("timeout", `Research took longer than ${Math.round(o.timeoutMs / 1000)} s`, "Ask a narrower question or use less depth")), remaining);
156
+ o.signal?.addEventListener("abort", onAbort, { once: true });
157
+ const done = () => {
158
+ clearTimeout(timer);
159
+ o.signal?.removeEventListener("abort", onAbort);
160
+ };
161
+ p.stdout.on("data", (d) => (out += d));
162
+ p.stderr.on("data", (d) => (err += d));
163
+ p.on("error", (e) => {
164
+ done();
165
+ reject(e.code === "ENOENT"
166
+ ? new ToolError("no_backend", "The claude command was not found", "Install Claude Code, or set CLAUDE_BIN")
167
+ : e);
168
+ });
169
+ p.on("close", () => {
170
+ clearTimeout(killer);
171
+ done();
172
+ stderr = err.trim();
173
+ if (stderr)
174
+ console.error(`[web-mcp] claude stderr: ${stderr.slice(0, 500)}`);
175
+ resolve(out);
176
+ });
177
+ p.stdin.on("error", () => { }); // the child may exit before reading stdin; its exit is handled above
178
+ p.stdin.end(o.prompt);
179
+ });
180
+ try {
181
+ return parseStream(stdout);
182
+ }
183
+ catch (e) {
184
+ if (e instanceof ToolError && e.message === NO_RESULT && stderr)
185
+ throw new ToolError("bad_output", `claude failed: ${(stderr.split("\n")[0] ?? "").slice(0, 200)}`);
186
+ throw e;
187
+ }
188
+ }
189
+ finally {
190
+ await rm(cwd, { recursive: true, force: true });
191
+ }
192
+ }
193
+ let signedIn = false;
194
+ /** True once `claude auth status` reports a sign-in; rechecked on every call until it does. Never reads tokens. */
195
+ export async function checkSignedIn() {
196
+ if (signedIn)
197
+ return;
198
+ const out = await new Promise((resolve) => execFile(bin(), ["auth", "status", "--json"], { env: childEnv(), timeout: 15_000 }, (e, stdout) => resolve(e ? "" : stdout)));
199
+ try {
200
+ signedIn = JSON.parse(out).loggedIn === true;
201
+ }
202
+ catch {
203
+ signedIn = false;
204
+ }
205
+ if (!signedIn)
206
+ throw new ToolError("claude_not_signed_in", "Claude Code is not signed in, or the claude command is missing", "Run `claude` once and sign in, or set CLAUDE_BIN");
207
+ }
@@ -0,0 +1,79 @@
1
+ // Tool inputs and the result shape. Consumers may copy this file to validate `format: "json"` replies.
2
+ import { z } from "zod";
3
+ export const MAX_TOKENS = z
4
+ .number()
5
+ .int()
6
+ .min(50)
7
+ .max(4000)
8
+ .describe("Ceiling on the reply size in tokens. The reply is as short as the goal allows, never longer than this");
9
+ const QUESTION = z.string().trim().min(1).max(500).describe("The question to answer from the web");
10
+ const GOAL = z
11
+ .string()
12
+ .trim()
13
+ .min(1)
14
+ .max(500)
15
+ .describe("What you will do with the answer; only what serves it is kept. Never put secrets here: it leaves the machine");
16
+ const URL_ = z.string().url().describe("The page to read");
17
+ const FORMAT = z
18
+ .enum(["markdown", "json"])
19
+ .default("markdown")
20
+ .describe("markdown (default, fewest tokens) or json (the result object, for code)");
21
+ const DEPTH = z.enum(["quick", "standard", "deep"]).default("quick").describe("How many searches and page reads it may spend");
22
+ const RECENCY = z
23
+ .enum(["any", "day", "week", "month", "year"])
24
+ .default("any")
25
+ .describe("Prefer sources from this window. Exact for the Brave head start, best effort for other searches");
26
+ const DOMAINS = z
27
+ .object({ include: z.array(z.string()).max(20).optional(), exclude: z.array(z.string()).max(20).optional() })
28
+ .optional()
29
+ .describe("Hosts to search only, or to leave out");
30
+ const CACHE = z.enum(["use", "refresh"]).default("use").describe("refresh: ignore cached answers and pages");
31
+ export const LOOKUP_INPUT = z.object({ question: QUESTION, max_tokens: MAX_TOKENS.default(300) });
32
+ export const READ_INPUT = z.object({
33
+ url: URL_,
34
+ question: z.string().trim().min(1).max(500).optional().describe("What you want from the page; omit for a summary"),
35
+ max_tokens: MAX_TOKENS.default(300),
36
+ });
37
+ export const RESEARCH_INPUT = z.object({ question: QUESTION, goal: GOAL, max_tokens: MAX_TOKENS.default(400), format: FORMAT });
38
+ export const PAGE_INPUT = z.object({ url: URL_, goal: GOAL, max_tokens: MAX_TOKENS.default(400), format: FORMAT });
39
+ export const INVESTIGATE_INPUT = RESEARCH_INPUT.extend({ depth: DEPTH, recency: RECENCY, domains: DOMAINS, cache: CACHE });
40
+ export const SEARCH_INPUT = z.object({
41
+ query: z.string().trim().min(1).max(400).describe("Search query"),
42
+ count: z.number().int().min(1).max(20).default(8).describe("Number of results"),
43
+ recency: RECENCY,
44
+ domains: DOMAINS,
45
+ });
46
+ function modelResult(l) {
47
+ const text = (max) => (l ? z.string().max(max) : z.string());
48
+ return z.object({
49
+ answer: text(l?.answerChars ?? 0),
50
+ confidence: z.enum(["high", "medium", "low"]),
51
+ findings: z
52
+ .array(z.object({
53
+ claim: text(200),
54
+ source: z.number().int().min(1),
55
+ quote: text(220).optional(),
56
+ date: z.string().optional(),
57
+ }))
58
+ .max(l ? l.findings : 50),
59
+ sources: z.array(z.object({ url: z.string(), title: z.string(), published: z.string().optional() })).max(l ? l.findings + 3 : 50),
60
+ gaps: z.array(text(160)).max(l ? 3 : 20),
61
+ });
62
+ }
63
+ /** What the model returns. `source` is the 1-based position in `sources`, matching [n] markers in `answer`. Parsing is lenient. */
64
+ export const MODEL_RESULT = modelResult();
65
+ /** The JSON schema handed to `claude --json-schema` for one request, with its length limits. */
66
+ export function modelResultSchema(limits) {
67
+ // Claude Code's validator rejects the draft 2020-12 `$schema` stamp zod adds; the schema itself is plain draft-07 compatible.
68
+ const { $schema: _drop, ...schema } = z.toJSONSchema(modelResult(limits));
69
+ return JSON.stringify(schema);
70
+ }
71
+ export class ToolError extends Error {
72
+ code;
73
+ hint;
74
+ constructor(code, message, hint) {
75
+ super(message);
76
+ this.code = code;
77
+ this.hint = hint;
78
+ }
79
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
4
+ import { createMcpHandler } from "@modelcontextprotocol/server";
5
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
6
+ import { buildServer, TIERS, VERSION } from "./server.js";
7
+ const USAGE = `web-mcp ${VERSION}
8
+ Usage: web-mcp [--http [host:]port] [--tiers basic,standard,advanced]
9
+ --http serve Streamable HTTP at /mcp instead of stdio (default host 127.0.0.1)
10
+ --tiers which tool tiers to expose (default: all)
11
+ Environment: BRAVE_API_KEY or BRAVE_API_KEY_FILE, CLAUDE_BIN, WEB_MCP_CACHE_DIR,
12
+ WEB_MCP_CONCURRENCY, WEB_MCP_MODEL, WEB_MCP_HTTP_TOKEN`;
13
+ function arg(name) {
14
+ const i = process.argv.indexOf(name);
15
+ return i >= 0 ? (process.argv[i + 1] ?? "") : undefined;
16
+ }
17
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
18
+ console.error(USAGE);
19
+ process.exit(0);
20
+ }
21
+ const tierArg = arg("--tiers");
22
+ const tiers = new Set(tierArg ? tierArg.split(",").map((t) => t.trim()) : TIERS);
23
+ for (const t of tiers) {
24
+ if (!TIERS.includes(t)) {
25
+ console.error(`Unknown tier "${t}". ${USAGE}`);
26
+ process.exit(2);
27
+ }
28
+ }
29
+ const factory = (ctx) => buildServer(tiers, ctx.requestInfo?.signal);
30
+ const http = arg("--http");
31
+ if (http === undefined) {
32
+ serveStdio(factory, { onerror: (e) => console.error(`[web-mcp] ${e.message}`) });
33
+ }
34
+ else {
35
+ const [host, port] = http.includes(":")
36
+ ? [http.slice(0, http.lastIndexOf(":")), http.slice(http.lastIndexOf(":") + 1)]
37
+ : ["127.0.0.1", http];
38
+ const token = process.env.WEB_MCP_HTTP_TOKEN;
39
+ const loopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host);
40
+ if (!loopback && !token) {
41
+ console.error(`Refusing to listen on ${host} without WEB_MCP_HTTP_TOKEN: the server spends your Claude subscription.`);
42
+ process.exit(2);
43
+ }
44
+ const handle = toNodeHandler(createMcpHandler(factory));
45
+ const validHost = localhostHostValidation();
46
+ const validOrigin = localhostOriginValidation();
47
+ createServer((req, res) => {
48
+ if (new URL(req.url ?? "/", "http://x").pathname !== "/mcp") {
49
+ res.writeHead(404).end();
50
+ return;
51
+ }
52
+ if (token) {
53
+ if (req.headers.authorization !== `Bearer ${token}`) {
54
+ res.writeHead(401, { "www-authenticate": "Bearer" }).end();
55
+ return;
56
+ }
57
+ }
58
+ else if (!validHost(req, res) || !validOrigin(req, res))
59
+ return; // DNS-rebinding guard for tokenless loopback use
60
+ void handle(req, res);
61
+ }).listen(Number(port), host, () => console.error(`[web-mcp] ${VERSION} listening on http://${host}:${port}/mcp`));
62
+ }
@@ -0,0 +1,41 @@
1
+ /** Raised when every slot is busy and the wait queue is full. */
2
+ export class QueueFull extends Error {
3
+ }
4
+ /**
5
+ * Counting semaphore: at most `n` tasks run at once and up to `maxQueue` more wait in arrival order.
6
+ * A finishing task hands its slot straight to the next waiter, so a newcomer can never jump the queue.
7
+ * A waiter whose `signal` aborts leaves the queue and rejects with the signal's reason.
8
+ */
9
+ export function limiter(n, maxQueue = Number.POSITIVE_INFINITY) {
10
+ let active = 0;
11
+ const waiting = [];
12
+ return async function run(task, signal) {
13
+ signal?.throwIfAborted();
14
+ if (active < n)
15
+ active++;
16
+ else {
17
+ if (waiting.length >= maxQueue)
18
+ throw new QueueFull(`${waiting.length} calls are already waiting`);
19
+ await new Promise((resolve, reject) => {
20
+ waiting.push(resolve);
21
+ signal?.addEventListener("abort", () => {
22
+ const i = waiting.indexOf(resolve);
23
+ if (i < 0)
24
+ return; // already started
25
+ waiting.splice(i, 1);
26
+ reject(signal.reason);
27
+ }, { once: true });
28
+ });
29
+ }
30
+ try {
31
+ return await task();
32
+ }
33
+ finally {
34
+ const next = waiting.shift();
35
+ if (next)
36
+ next();
37
+ else
38
+ active--;
39
+ }
40
+ };
41
+ }
@@ -0,0 +1,187 @@
1
+ // Fetch one public page, extract its main text as markdown, and keep the passages that serve a goal.
2
+ import { lookup } from "node:dns/promises";
3
+ import { isIP } from "node:net";
4
+ import { Readability } from "@mozilla/readability";
5
+ import { parseHTML } from "linkedom";
6
+ import MiniSearch from "minisearch";
7
+ import TurndownService from "turndown";
8
+ import { ToolError } from "./contract.js";
9
+ const MAX_BYTES = 2 * 1024 * 1024;
10
+ const TIMEOUT_MS = 20_000;
11
+ const MAX_REDIRECTS = 5;
12
+ /** Loopback, private, link-local, CGNAT, unspecified and multicast ranges, IPv4 and IPv6 (including IPv4-mapped). */
13
+ export function isPrivateAddress(ip) {
14
+ const v = ip.toLowerCase().replace(/^\[|\]$/g, "");
15
+ const mapped = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
16
+ if (mapped?.[1])
17
+ return isPrivateAddress(mapped[1]);
18
+ if (isIP(v) === 4) {
19
+ const [a = 0, b = 0] = v.split(".").map(Number);
20
+ return (a === 0 ||
21
+ a === 10 ||
22
+ a === 127 ||
23
+ (a === 100 && b >= 64 && b <= 127) ||
24
+ (a === 169 && b === 254) ||
25
+ (a === 172 && b >= 16 && b <= 31) ||
26
+ (a === 192 && b === 168) ||
27
+ a >= 224);
28
+ }
29
+ if (isIP(v) === 6)
30
+ return v === "::" || v === "::1" || /^f[cd]/.test(v) || /^fe[89ab]/.test(v) || /^ff/.test(v);
31
+ return true; // not an IP: callers resolve first
32
+ }
33
+ async function assertPublic(url) {
34
+ if (url.protocol !== "http:" && url.protocol !== "https:")
35
+ throw new ToolError("blocked_url", `Only http and https URLs are read (${url.protocol})`);
36
+ const host = url.hostname.replace(/^\[|\]$/g, "");
37
+ if (host === "localhost" || host.endsWith(".localhost"))
38
+ throw new ToolError("blocked_url", "Local addresses are not read");
39
+ // shortcut: checks DNS before the fetch, so a rebinding host could still flip; pin the resolved address if this runs beside sensitive services.
40
+ const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
41
+ if (!addrs.length)
42
+ throw new ToolError("fetch_failed", `Could not resolve ${host}`);
43
+ if (addrs.some(isPrivateAddress))
44
+ throw new ToolError("blocked_url", "Private and local network addresses are not read");
45
+ }
46
+ export async function fetchPage(rawUrl) {
47
+ let url = new URL(rawUrl);
48
+ let res;
49
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
50
+ await assertPublic(url);
51
+ res = await fetch(url, {
52
+ redirect: "manual",
53
+ signal: AbortSignal.timeout(TIMEOUT_MS),
54
+ headers: { "user-agent": "web-mcp/0.1", accept: "text/html,application/xhtml+xml,text/plain;q=0.9" },
55
+ }).catch((e) => {
56
+ throw new ToolError(e.name === "TimeoutError" ? "timeout" : "fetch_failed", `Could not fetch ${url.host}: ${e.message}`);
57
+ });
58
+ const loc = res.headers.get("location");
59
+ if (res.status >= 300 && res.status < 400 && loc) {
60
+ url = new URL(loc, url);
61
+ continue;
62
+ }
63
+ break;
64
+ }
65
+ if (!res?.ok)
66
+ throw new ToolError("fetch_failed", `The page answered HTTP ${res?.status ?? "redirect loop"}`);
67
+ const type = res.headers.get("content-type") ?? "";
68
+ if (!/text\/html|application\/xhtml|text\/plain/.test(type))
69
+ throw new ToolError("fetch_failed", `Unsupported content type ${type.split(";")[0] || "unknown"}`, "Only HTML and plain text pages are read");
70
+ const host = url.host;
71
+ const bytes = await readCapped(res).catch((e) => {
72
+ // The timeout signal also covers the body, so a slow download fails here, after the headers arrived.
73
+ throw new ToolError(e.name === "TimeoutError" ? "timeout" : "fetch_failed", `Could not read ${host}: ${e.message}`);
74
+ });
75
+ const body = decode(bytes, type);
76
+ return /text\/plain/.test(type) ? { url: url.href, title: url.href, markdown: body.trim() } : extract(body, url.href);
77
+ }
78
+ /**
79
+ * Decode with the charset from the Content-Type header, else from a <meta> tag near the top, else UTF-8.
80
+ * Labels follow the WHATWG Encoding standard (so "iso-8859-1" decodes as windows-1252); unknown labels fall back to UTF-8.
81
+ */
82
+ export function decode(bytes, contentType) {
83
+ const head = new TextDecoder("windows-1252").decode(bytes.subarray(0, 2048)); // ASCII-safe view for sniffing
84
+ const label = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1] ?? /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1] ?? "utf-8";
85
+ try {
86
+ return new TextDecoder(label).decode(bytes);
87
+ }
88
+ catch {
89
+ return new TextDecoder("utf-8").decode(bytes);
90
+ }
91
+ }
92
+ async function readCapped(res) {
93
+ if (!res.body)
94
+ return new Uint8Array();
95
+ const reader = res.body.getReader();
96
+ const parts = [];
97
+ let size = 0;
98
+ for (;;) {
99
+ const { done, value } = await reader.read();
100
+ if (done)
101
+ break;
102
+ const room = MAX_BYTES - size;
103
+ parts.push(value.byteLength > room ? value.subarray(0, room) : value);
104
+ size += Math.min(value.byteLength, room);
105
+ if (size >= MAX_BYTES) {
106
+ await reader.cancel();
107
+ break; // keep what fits; the main text is usually near the top
108
+ }
109
+ }
110
+ return Buffer.concat(parts);
111
+ }
112
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" });
113
+ turndown.remove(["script", "style", "noscript", "iframe"]);
114
+ turndown.remove((n) => n.nodeName.toLowerCase() === "svg");
115
+ // Token economy: link targets and images are noise for a reader model; tables become one pipe row per line.
116
+ turndown.addRule("linkText", { filter: "a", replacement: (content) => content });
117
+ turndown.addRule("noImages", { filter: "img", replacement: () => "" }); // added rules outrank remove() for void elements
118
+ turndown.addRule("cell", { filter: ["th", "td"], replacement: (content) => ` ${content.replace(/\s+/g, " ").trim()} |` });
119
+ turndown.addRule("row", { filter: "tr", replacement: (content) => `\n|${content}\n` });
120
+ turndown.addRule("table", { filter: "table", replacement: (content) => `\n\n${content.replace(/\n+/g, "\n").trim()}\n\n` });
121
+ export function extract(html, url) {
122
+ // linkedom roots a document without <html> at its first tag, which Readability rejects.
123
+ const source = /<html[\s>]/i.test(html) ? html : `<!doctype html><html><head></head><body>${html}</body></html>`;
124
+ const { document } = parseHTML(source);
125
+ const title = document.querySelector("title")?.textContent?.trim() || url;
126
+ let article = null;
127
+ try {
128
+ article = new Readability(document).parse();
129
+ }
130
+ catch (e) {
131
+ console.error(`[web-mcp] readability failed on ${url}: ${e.message}`);
132
+ }
133
+ const markdown = article?.content ? turndown.turndown(article.content) : (parseHTML(source).document.body?.textContent ?? "");
134
+ return {
135
+ url,
136
+ title: article?.title?.trim() || title,
137
+ markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(),
138
+ published: article?.publishedTime ?? undefined,
139
+ };
140
+ }
141
+ /** Split markdown into passages of about 120 words, each carrying its nearest heading. */
142
+ export function chunk(markdown) {
143
+ const chunks = [];
144
+ let heading = "";
145
+ let buf = [];
146
+ const words = () => buf.join(" ").split(/\s+/).length;
147
+ const flush = () => {
148
+ if (buf.length)
149
+ chunks.push((heading && !buf[0]?.startsWith("#") ? `${heading}\n` : "") + buf.join("\n\n"));
150
+ buf = [];
151
+ };
152
+ for (const para of markdown.split(/\n\s*\n/)) {
153
+ const p = para.trim();
154
+ if (!p)
155
+ continue;
156
+ if (/^#{1,6}\s/.test(p)) {
157
+ flush();
158
+ heading = p.split("\n")[0] ?? "";
159
+ }
160
+ buf.push(p);
161
+ if (words() >= 120)
162
+ flush();
163
+ }
164
+ flush();
165
+ return chunks;
166
+ }
167
+ /** Keep the first passage and the passages that best match `query`, in page order, within `maxChars`. */
168
+ export function select(chunks, query, maxChars) {
169
+ if (chunks.join("\n\n").length <= maxChars)
170
+ return chunks.join("\n\n");
171
+ const ms = new MiniSearch({ fields: ["text"], storeFields: [] });
172
+ ms.addAll(chunks.map((text, id) => ({ id, text })));
173
+ const ranked = ms.search(query, { prefix: true, fuzzy: 0.2, combineWith: "OR" }).map((h) => h.id);
174
+ const len = (i) => chunks[i]?.length ?? 0;
175
+ const keep = new Set([0]);
176
+ let used = len(0);
177
+ for (const id of ranked) {
178
+ if (keep.has(id) || used + len(id) > maxChars)
179
+ continue;
180
+ keep.add(id);
181
+ used += len(id) + 2;
182
+ }
183
+ return [...keep]
184
+ .sort((a, b) => a - b)
185
+ .map((i) => chunks[i] ?? "")
186
+ .join("\n\n");
187
+ }
@@ -0,0 +1,48 @@
1
+ /** The reply body, without the meta line. This is what the token cap is measured on. */
2
+ export function renderBody(r, format) {
3
+ if (format === "json")
4
+ return JSON.stringify(toContract(r, false));
5
+ const out = [`Answer: ${r.answer}`];
6
+ if (format === "markdown") {
7
+ out.push(`Confidence: ${r.meta.confidence}`);
8
+ if (r.findings.length) {
9
+ out.push("", "Findings");
10
+ for (const f of r.findings)
11
+ out.push(`- ${f.claim} [${f.source}]${f.quote ? ` "${f.quote}"` : ""}${f.date ? ` ${f.date}` : ""}`);
12
+ }
13
+ }
14
+ if (r.sources.length) {
15
+ out.push("", "Sources");
16
+ for (const [i, s] of r.sources.entries())
17
+ out.push(`[${i + 1}] ${s.title}. ${s.url}${s.published ? ` (${s.published})` : ""}`);
18
+ }
19
+ if (r.gaps.length) {
20
+ if (format === "basic")
21
+ out.push("", `Not found: ${r.gaps.join("; ")}`);
22
+ else
23
+ out.push("", "Gaps", ...r.gaps.map((g) => `- ${g}`));
24
+ }
25
+ return out.join("\n");
26
+ }
27
+ export function render(r, format) {
28
+ if (format === "json")
29
+ return JSON.stringify(toContract(r, true));
30
+ const m = r.meta;
31
+ const quotes = r.findings.filter((f) => f.quote).length;
32
+ const line = [
33
+ m.model,
34
+ `searches ${m.queries.length}`,
35
+ `pages ${m.pages_read}`,
36
+ ...(m.head_start ? ["brave"] : []),
37
+ ...(quotes ? [`verified ${m.verified}/${quotes}`] : []),
38
+ `cached ${m.cached ? "yes" : "no"}`,
39
+ `trimmed ${m.trimmed ? "yes" : "no"}`,
40
+ `${(m.elapsed_ms / 1000).toFixed(1)} s`,
41
+ ].join(", ");
42
+ return `${renderBody(r, format)}\n\nmeta: ${line}`;
43
+ }
44
+ /** The documented JSON contract: `findings[].source` is a 0-based index into `sources`. */
45
+ function toContract(r, withMeta) {
46
+ const findings = r.findings.map((f) => ({ ...f, source: f.source - 1 }));
47
+ return withMeta ? { ...r, findings } : { answer: r.answer, findings, sources: r.sources, gaps: r.gaps };
48
+ }