@andrhamm/web-mcp 0.0.0-stage → 0.2.1

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,224 @@
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", usage: usageOf(result) };
112
+ }
113
+ /** Tokens and estimated cost of the whole run, summed over every model it used (WebFetch reads pages with its own call). */
114
+ function usageOf(result) {
115
+ const models = Object.values(result.modelUsage ?? {});
116
+ const sum = (k) => models.reduce((n, m) => n + (m[k] ?? 0), 0);
117
+ return {
118
+ input_tokens: sum("inputTokens"),
119
+ output_tokens: sum("outputTokens"),
120
+ cache_read_tokens: sum("cacheReadInputTokens"),
121
+ cache_write_tokens: sum("cacheCreationInputTokens"),
122
+ cost_usd: Math.round((result.total_cost_usd ?? sum("costUSD")) * 1e5) / 1e5,
123
+ };
124
+ }
125
+ const cancelled = () => new ToolError("cancelled", "The caller cancelled the request");
126
+ /** How long a stopped claude process gets to exit after SIGTERM before it is killed. */
127
+ const KILL_GRACE_MS = 3000;
128
+ export const runClaude = (o) => withSlot(o, (deadline) => spawnClaude(o, deadline));
129
+ /**
130
+ * Run `task` in a Claude slot: wait in the bounded queue, then run with a deadline counted from the call.
131
+ * Work that only matters if Claude will run (the Brave head start) belongs inside `task`, so a `busy` refusal costs nothing.
132
+ */
133
+ export async function withSlot(o, task) {
134
+ const deadline = Date.now() + o.timeoutMs;
135
+ const wait = AbortSignal.any([AbortSignal.timeout(o.timeoutMs), ...(o.signal ? [o.signal] : [])]);
136
+ try {
137
+ return await run(() => task(deadline), wait);
138
+ }
139
+ catch (e) {
140
+ if (e instanceof ToolError)
141
+ throw e;
142
+ if (e instanceof QueueFull)
143
+ throw new ToolError("busy", `Too many research calls at once: ${e.message}`, "Try again shortly");
144
+ if (o.signal?.aborted)
145
+ throw cancelled();
146
+ if (wait.aborted)
147
+ throw new ToolError("timeout", `No free slot within ${Math.round(o.timeoutMs / 1000)} s`, "The server is busy; try again shortly");
148
+ throw e;
149
+ }
150
+ }
151
+ export async function spawnClaude(o, deadline) {
152
+ if (o.signal?.aborted)
153
+ throw cancelled();
154
+ const remaining = deadline - Date.now();
155
+ if (remaining <= 0)
156
+ throw new ToolError("timeout", "No time left after waiting for a free slot", "The server is busy; try again shortly");
157
+ const cwd = await mkdtemp(join(tmpdir(), "web-mcp-")); // empty: no project CLAUDE.md or .mcp.json
158
+ try {
159
+ let stderr = "";
160
+ const stdout = await new Promise((resolve, reject) => {
161
+ const p = spawn(bin(), claudeArgs(o), { cwd, env: childEnv(), stdio: ["pipe", "pipe", "pipe"] });
162
+ let out = "";
163
+ let err = "";
164
+ let killer;
165
+ const stop = (e) => {
166
+ p.kill("SIGTERM");
167
+ killer = setTimeout(() => p.kill("SIGKILL"), KILL_GRACE_MS); // force a run that ignores the polite stop
168
+ killer.unref();
169
+ reject(e);
170
+ };
171
+ const onAbort = () => stop(cancelled());
172
+ 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);
173
+ o.signal?.addEventListener("abort", onAbort, { once: true });
174
+ const done = () => {
175
+ clearTimeout(timer);
176
+ o.signal?.removeEventListener("abort", onAbort);
177
+ };
178
+ p.stdout.on("data", (d) => (out += d));
179
+ p.stderr.on("data", (d) => (err += d));
180
+ p.on("error", (e) => {
181
+ done();
182
+ reject(e.code === "ENOENT"
183
+ ? new ToolError("no_backend", "The claude command was not found", "Install Claude Code, or set CLAUDE_BIN")
184
+ : e);
185
+ });
186
+ p.on("close", () => {
187
+ clearTimeout(killer);
188
+ done();
189
+ stderr = err.trim();
190
+ if (stderr)
191
+ console.error(`[web-mcp] claude stderr: ${stderr.slice(0, 500)}`);
192
+ resolve(out);
193
+ });
194
+ p.stdin.on("error", () => { }); // the child may exit before reading stdin; its exit is handled above
195
+ p.stdin.end(o.prompt);
196
+ });
197
+ try {
198
+ return parseStream(stdout);
199
+ }
200
+ catch (e) {
201
+ if (e instanceof ToolError && e.message === NO_RESULT && stderr)
202
+ throw new ToolError("bad_output", `claude failed: ${(stderr.split("\n")[0] ?? "").slice(0, 200)}`);
203
+ throw e;
204
+ }
205
+ }
206
+ finally {
207
+ await rm(cwd, { recursive: true, force: true });
208
+ }
209
+ }
210
+ let signedIn = false;
211
+ /** True once `claude auth status` reports a sign-in; rechecked on every call until it does. Never reads tokens. */
212
+ export async function checkSignedIn() {
213
+ if (signedIn)
214
+ return;
215
+ const out = await new Promise((resolve) => execFile(bin(), ["auth", "status", "--json"], { env: childEnv(), timeout: 15_000 }, (e, stdout) => resolve(e ? "" : stdout)));
216
+ try {
217
+ signedIn = JSON.parse(out).loggedIn === true;
218
+ }
219
+ catch {
220
+ signedIn = false;
221
+ }
222
+ if (!signedIn)
223
+ 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");
224
+ }
@@ -0,0 +1,84 @@
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 EXTRACT_INPUT = z.object({
41
+ url: URL_,
42
+ goal: z.string().trim().min(1).max(500).describe("What you need from the page; the passages that match it are kept"),
43
+ max_tokens: MAX_TOKENS.default(1000),
44
+ });
45
+ export const SEARCH_INPUT = z.object({
46
+ query: z.string().trim().min(1).max(400).describe("Search query"),
47
+ count: z.number().int().min(1).max(20).default(8).describe("Number of results"),
48
+ recency: RECENCY,
49
+ domains: DOMAINS,
50
+ });
51
+ function modelResult(l) {
52
+ const text = (max) => (l ? z.string().max(max) : z.string());
53
+ return z.object({
54
+ answer: text(l?.answerChars ?? 0),
55
+ confidence: z.enum(["high", "medium", "low"]),
56
+ findings: z
57
+ .array(z.object({
58
+ claim: text(200),
59
+ source: z.number().int().min(1),
60
+ quote: text(220).optional(),
61
+ date: z.string().optional(),
62
+ }))
63
+ .max(l ? l.findings : 50),
64
+ sources: z.array(z.object({ url: z.string(), title: z.string(), published: z.string().optional() })).max(l ? l.findings + 3 : 50),
65
+ gaps: z.array(text(160)).max(l ? 3 : 20),
66
+ });
67
+ }
68
+ /** What the model returns. `source` is the 1-based position in `sources`, matching [n] markers in `answer`. Parsing is lenient. */
69
+ export const MODEL_RESULT = modelResult();
70
+ /** The JSON schema handed to `claude --json-schema` for one request, with its length limits. */
71
+ export function modelResultSchema(limits) {
72
+ // Claude Code's validator rejects the draft 2020-12 `$schema` stamp zod adds; the schema itself is plain draft-07 compatible.
73
+ const { $schema: _drop, ...schema } = z.toJSONSchema(modelResult(limits));
74
+ return JSON.stringify(schema);
75
+ }
76
+ export class ToolError extends Error {
77
+ code;
78
+ hint;
79
+ constructor(code, message, hint) {
80
+ super(message);
81
+ this.code = code;
82
+ this.hint = hint;
83
+ }
84
+ }
@@ -0,0 +1,64 @@
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,direct]
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
+ // A runtime that notices a dropped client only on its next write would otherwise keep a cancelled run going up to the
45
+ // SDK's 15 s default keep-alive; at 2 s an abandoned Claude run stops within about 2 s.
46
+ const handle = toNodeHandler(createMcpHandler(factory, { keepAliveMs: 2000 }));
47
+ const validHost = localhostHostValidation();
48
+ const validOrigin = localhostOriginValidation();
49
+ createServer((req, res) => {
50
+ if (new URL(req.url ?? "/", "http://x").pathname !== "/mcp") {
51
+ res.writeHead(404).end();
52
+ return;
53
+ }
54
+ if (token) {
55
+ if (req.headers.authorization !== `Bearer ${token}`) {
56
+ res.writeHead(401, { "www-authenticate": "Bearer" }).end();
57
+ return;
58
+ }
59
+ }
60
+ else if (!validHost(req, res) || !validOrigin(req, res))
61
+ return; // DNS-rebinding guard for tokenless loopback use
62
+ void handle(req, res);
63
+ }).listen(Number(port), host, () => console.error(`[web-mcp] ${VERSION} listening on http://${host}:${port}/mcp`));
64
+ }
@@ -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,207 @@
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: stripInvisible(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
+ // Characters a person cannot see but a model still reads, so they can carry hidden instructions: soft hyphen, zero-width
122
+ // characters and joiners, bidi controls, invisible operators, BOM, variation selectors and Unicode tag characters.
123
+ // Removing joiners can split emoji sequences and alter some scripts slightly; the text stays readable.
124
+ const INVISIBLE =
125
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: each invisible code point, joiners included, is matched on its own on purpose
126
+ /[\u00AD\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFE00-\uFE0F\uFEFF\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
127
+ export const stripInvisible = (s) => s.replace(INVISIBLE, "");
128
+ // Inline styles that hide an element. Text hidden by stylesheets stays: the reader applies no CSS.
129
+ const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:\.0+)?|font-size\s*:\s*0(?:\.0+)?(?:px|em|rem|pt|%)?)\s*(?:!important\s*)?(?:;|$)/i;
130
+ export function extract(html, url) {
131
+ // linkedom roots a document without <html> at its first tag, which Readability rejects.
132
+ const source = /<html[\s>]/i.test(html) ? html : `<!doctype html><html><head></head><body>${html}</body></html>`;
133
+ const { document } = parseHTML(source);
134
+ for (const el of [...document.querySelectorAll("[hidden], [aria-hidden='true'], [style]")]) {
135
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || HIDDEN_STYLE.test(el.getAttribute("style") ?? ""))
136
+ el.remove();
137
+ }
138
+ const title = document.querySelector("title")?.textContent?.trim() || url;
139
+ const plain = document.body?.textContent ?? ""; // fallback, taken before Readability rewrites the document
140
+ let article = null;
141
+ try {
142
+ article = new Readability(document).parse();
143
+ }
144
+ catch (e) {
145
+ console.error(`[web-mcp] readability failed on ${url}: ${e.message}`);
146
+ }
147
+ const markdown = article?.content ? turndown.turndown(article.content) : plain;
148
+ return {
149
+ url,
150
+ title: stripInvisible(article?.title?.trim() || title),
151
+ markdown: stripInvisible(markdown)
152
+ .replace(/\n{3,}/g, "\n\n")
153
+ .trim(),
154
+ published: article?.publishedTime ?? undefined,
155
+ };
156
+ }
157
+ /** Split markdown into passages of about 120 words, each carrying its nearest heading. */
158
+ export function chunk(markdown) {
159
+ const chunks = [];
160
+ let heading = "";
161
+ let buf = [];
162
+ const words = () => buf.join(" ").split(/\s+/).length;
163
+ const flush = () => {
164
+ if (buf.length)
165
+ chunks.push((heading && !buf[0]?.startsWith("#") ? `${heading}\n` : "") + buf.join("\n\n"));
166
+ buf = [];
167
+ };
168
+ for (const para of markdown.split(/\n\s*\n/)) {
169
+ const p = para.trim();
170
+ if (!p)
171
+ continue;
172
+ if (/^#{1,6}\s/.test(p)) {
173
+ flush();
174
+ heading = p.split("\n")[0] ?? "";
175
+ }
176
+ buf.push(p);
177
+ if (words() >= 120)
178
+ flush();
179
+ }
180
+ flush();
181
+ return chunks;
182
+ }
183
+ /** Keep the first passage and the passages that best match `query`, in page order, within `maxChars`. */
184
+ /** Passage indexes that match `query`, best first (BM25 with prefix and fuzzy matching). Unmatched passages are left out. */
185
+ export function rank(chunks, query) {
186
+ const ms = new MiniSearch({ fields: ["text"], storeFields: [] });
187
+ ms.addAll(chunks.map((text, id) => ({ id, text })));
188
+ return ms.search(query, { prefix: true, fuzzy: 0.2, combineWith: "OR" }).map((h) => h.id);
189
+ }
190
+ export function select(chunks, query, maxChars) {
191
+ if (chunks.join("\n\n").length <= maxChars)
192
+ return chunks.join("\n\n");
193
+ const ranked = rank(chunks, query);
194
+ const len = (i) => chunks[i]?.length ?? 0;
195
+ const keep = new Set([0]);
196
+ let used = len(0);
197
+ for (const id of ranked) {
198
+ if (keep.has(id) || used + len(id) > maxChars)
199
+ continue;
200
+ keep.add(id);
201
+ used += len(id) + 2;
202
+ }
203
+ return [...keep]
204
+ .sort((a, b) => a - b)
205
+ .map((i) => chunks[i] ?? "")
206
+ .join("\n\n");
207
+ }