brewpage-mcp 1.14.3 → 1.14.5

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/README.md CHANGED
@@ -17,7 +17,7 @@ Choosing a host? See how BrewPage compares with GitHub Pages, Netlify Drop, Tiin
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- npx -y brewpage-mcp@1.14.3
20
+ npx -y brewpage-mcp@1.14.5
21
21
  ```
22
22
 
23
23
  Requires Node.js 20 or newer. This starts the stdio server for an MCP client.
@@ -33,7 +33,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
33
33
  "mcpServers": {
34
34
  "brewpage": {
35
35
  "command": "npx",
36
- "args": ["-y", "brewpage-mcp@1.14.3"]
36
+ "args": ["-y", "brewpage-mcp@1.14.5"]
37
37
  }
38
38
  }
39
39
  }
@@ -44,7 +44,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
44
44
  Register a project-scoped stdio server:
45
45
 
46
46
  ```bash
47
- claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.14.3
47
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.14.5
48
48
  ```
49
49
 
50
50
  This writes `.mcp.json` in the project root. Use `--scope user` for user-scoped registration. See the [official Claude Code MCP guide](https://code.claude.com/docs/en/mcp).
@@ -58,7 +58,7 @@ Open **Settings > MCP** and add:
58
58
  "mcpServers": {
59
59
  "brewpage": {
60
60
  "command": "npx",
61
- "args": ["-y", "brewpage-mcp@1.14.3"]
61
+ "args": ["-y", "brewpage-mcp@1.14.5"]
62
62
  }
63
63
  }
64
64
  }
@@ -72,7 +72,7 @@ Open the Cline MCP settings panel and add:
72
72
  {
73
73
  "brewpage": {
74
74
  "command": "npx",
75
- "args": ["-y", "brewpage-mcp@1.14.3"]
75
+ "args": ["-y", "brewpage-mcp@1.14.5"]
76
76
  }
77
77
  }
78
78
  ```
@@ -80,7 +80,7 @@ Open the Cline MCP settings panel and add:
80
80
  ### Global Install
81
81
 
82
82
  ```bash
83
- npm install -g brewpage-mcp@1.14.3
83
+ npm install -g brewpage-mcp@1.14.5
84
84
  brewpage-mcp
85
85
  ```
86
86
 
@@ -215,7 +215,7 @@ publish_file(url="https://example.com/diagram.png")
215
215
 
216
216
  Publish a single-page or multi-file HTML site. Pass `entryContent` for a single page or `files` (array of `{path, content}`) for a multi-file site. Supports password protection, TTL, and owner token grouping.
217
217
 
218
- Limits (current values from `get_limits`): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; each path at most 380 characters. System files such as `.DS_Store` and `__MACOSX/` are skipped and never count; files with an unsupported extension or a too-long path are skipped and reported in notices. Entry: `entry`, else root `index.html`, else the shallowest HTML file. A site limit hit answers 400 with a message naming the limit; 413 means the request is over 25 MB, has too many files and form fields, or a file name/path is too long; 422 means no servable file is left.
218
+ Limits (current values from `get_limits`): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; each path at most 380 characters. System files such as `.DS_Store` and `__MACOSX/` are skipped and never count; files with an unsupported extension or a too-long path are skipped and reported in notices. Entry: `entry`, else root `index.html`, else the shallowest HTML file. A site limit hit answers 400 with a message naming the limit, as does a PDF/Office file that is unreadable, password-protected or too complex; 413 means the request is over 25 MB, has too many files and form fields, or a file name/path is too long; 422 means no servable file is left; 503 means busy: retry after `Retry-After` seconds (failed publish tools print it).
219
219
 
220
220
  Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for private, unlisted content; `public` lists the site in the gallery and sitemap for Google, Bing and AI search; a site always gets its own subdomain), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30), `ownerToken` (optional, groups site under an existing owner), `deliveryMode` (optional; fresh sites require `subdomain`).
221
221
 
@@ -485,12 +485,16 @@ Every publish response includes an **owner token** -- the only credential that a
485
485
  - `POST /mcp` only, JSON responses, no `Mcp-Session-Id`; `GET`/`DELETE /mcp` -> 405. `GET /healthz` for the container healthcheck.
486
486
  - Requests with an `Origin` header -> 403 (no browser access). Only peers in `BREWPAGE_TRUSTED_PROXY_CIDRS` are served (others -> 403); the last `X-Forwarded-For` IP goes to the API with `X-Forwarded-Proto`/`X-Forwarded-Host` of the public origin.
487
487
  - `publish_file` downloads only from public addresses (every redirect re-checked, at most 3, 20 MiB).
488
+ - Request body up to 32 MiB (`413` above), sent at least ~250 KiB/s after the first 2 s (`408` below). Light calls (handshake, `tools/list`, small reads), writes and large calls (over 1 MiB, `publish_file`) have separate slot pools; a full pool queues a request up to 30 s before `503`. A body without `Content-Length` that grows past 1 MiB gets `503` at once when the large-call slot is busy. A slot is held until the tool finishes; a client disconnect cancels its backend calls. Per-IP caps and the `publish_file` download budget skip shared platform egress (`BREWPAGE_SHARED_EGRESS_CIDRS` plus the published lists).
489
+ - Memory: run with `--max-old-space-size=192` and a 512 MiB container limit.
488
490
  - Never reads `BREWPAGE_OWNER_TOKEN`; refuses to start when it is set. Pass `ownerToken` per call.
489
491
  - One JSON log line per request on stdout (method, tool, status, outcome, ms); no arguments, tokens or IPs. `SIGTERM` drains for up to 30 s.
490
492
 
491
493
  | Variable | Default | Description |
492
494
  |----------|---------|-------------|
493
495
  | `BREWPAGE_TRUSTED_PROXY_CIDRS` | required | Comma list of reverse-proxy CIDRs or IPs |
496
+ | `BREWPAGE_SHARED_EGRESS_CIDRS` | empty | Comma list of platform egress CIDRs (one IP, many users): global limits only, no per-IP caps |
497
+ | `BREWPAGE_SHARED_EGRESS_LIST_URLS` | empty | Comma list of `https` URLs of published egress lists (`{"prefixes":[{"ipv4Prefix"}]}`, at least 100 prefixes, at most 1 MiB, no redirects); fetched at startup and daily, last good list kept |
494
498
  | `BREWPAGE_BACKEND_URL` | `http://brewpage-backend-mcp:8080` | API base URL (internal address) |
495
499
  | `BREWPAGE_PUBLIC_ORIGIN` | `https://brewpage.app` | Origin for returned links and forwarded proto/host |
496
500
  | `BREWPAGE_MCP_PORT` | `8787` | Listen port |
@@ -509,6 +513,14 @@ Every publish response includes an **owner token** -- the only credential that a
509
513
 
510
514
  ## Changelog
511
515
 
516
+ ## 1.14.5 -- 2026-10-27
517
+
518
+ - Site publish hints add 400 for an unreadable, password-protected or too complex PDF/Office file and 503 when busy; a failed publish on 503 prints `Retry-After`.
519
+
520
+ ## 1.14.4 -- 2026-10-26
521
+
522
+ - Remote mode: a request keeps its slot until the tool finishes, and a client disconnect cancels its backend calls and source download; backend calls time out after 60 s. Separate pools for light, write and large calls queue briefly instead of refusing; request body up to 32 MiB. `BREWPAGE_SHARED_EGRESS_CIDRS` and `BREWPAGE_SHARED_EGRESS_LIST_URLS` exempt platform egress from per-IP caps. Tools are unchanged.
523
+
512
524
  ## 1.14.3 -- 2026-10-26
513
525
 
514
526
  - Old publications (made before 5 October 2026): the 409 hint says to POST the content to `createUrl` (sent for every type) with the matching publish tool in that namespace; `republishUrl` is browser-only. Update tool descriptions mention `createUrl`.
package/dist/http.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ interface Cidr {
3
+ address: string;
4
+ prefix: number;
5
+ family: "ipv4" | "ipv6";
6
+ }
7
+ /** `{prefixes: [{ipv4Prefix | ipv6Prefix}]}`; drops unparsable and over-wide prefixes, rejects a truncated or reshaped file. */
8
+ export declare function parseEgressList(text: string): Cidr[];
2
9
  export declare function isBlockedSource(address: string): boolean;
3
- /** SSRF-safe publish_file download: every hop re-checked, at most 3 redirects, one Blob copy. */
4
- export declare function fetchSourceSafely(url: string, isBlocked?: (address: string) => boolean): Promise<Blob>;
10
+ /** SSRF-safe publish_file download: every hop re-checked, at most 3 redirects, one Blob copy; `abort` cancels it. */
11
+ export declare function fetchSourceSafely(url: string, isBlocked?: (address: string) => boolean, abort?: AbortSignal): Promise<Blob>;
12
+ export {};
package/dist/http.js CHANGED
@@ -10,14 +10,35 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
11
  import { createServer, OWNER_TOKEN_ENV, USER_AGENT } from "./tools.js";
12
12
  const MIB = 1024 * 1024;
13
- /** A 31 MiB tools/call peaked at 375 MiB RSS, 7.9 MiB at 164 MiB; 8 MiB keeps a 320 MiB container safe. */
14
- const MAX_BODY_BYTES = 8 * MIB;
13
+ /** Fits the largest site (25 MiB backend request: 20 MiB of inline text plus JSON escaping).
14
+ * Two 32 MiB sites + 8x1 MiB + 32 light per round peaked at 373 MiB (heap 192): run with mem_limit 512m. */
15
+ const MAX_BODY_BYTES = 32 * MIB;
15
16
  const LARGE_BODY_BYTES = 1 * MIB;
16
- const BODY_TIMEOUT_MS = 10_000;
17
+ const SMALL_BODY_BYTES = 64 * 1024;
18
+ /** After a 2 s grace a body must average ~250 KiB/s (checked every second), so a trickle cannot pin a slot. */
19
+ const BODY_GRACE_MS = 2_000;
20
+ const BODY_MIN_BYTES_PER_MS = 256;
21
+ /** Light = small body, not tools/call or a LIGHT_TOOLS call; own pool so writes cannot starve handshakes. */
22
+ const MAX_LIGHT_IN_FLIGHT = 32;
23
+ const MAX_LIGHT_PER_CLIENT = 4;
17
24
  const MAX_IN_FLIGHT = 8;
18
25
  const MAX_PER_CLIENT = 2;
19
- /** Heavy = body over 1 MiB or publish_file; each peaks ~100 MiB RSS, two reached 313 MiB, too close to 320 MiB. */
26
+ /** Heavy = body over 1 MiB or publish_file; one at a time keeps a 32 MiB call inside the container memory. */
20
27
  const MAX_HEAVY_IN_FLIGHT = 1;
28
+ const MAX_HEAVY_PER_CLIENT = 1;
29
+ /** A full pool queues a request (before its body is read) for up to 30 s instead of answering 503. */
30
+ const QUEUE_WAIT_MS = 30_000;
31
+ const MAX_QUEUED = 64;
32
+ /** Read-only tools with small answers; content reads (get_page/json/kv) stay in the standard pool. */
33
+ const LIGHT_TOOLS = new Set(["get_limits", "get_stats", "search_gallery"]);
34
+ const BACKEND_TIMEOUT_MS = 60_000;
35
+ /** Published egress lists (backend `openai-chatgpt-connectors` rules): daily refresh, last good set kept on failure. */
36
+ const SHARED_LIST_REFRESH_MS = 24 * 60 * 60_000;
37
+ const SHARED_LIST_TIMEOUT_MS = 15_000;
38
+ const SHARED_LIST_MAX_BYTES = 1 * MIB;
39
+ const SHARED_LIST_MIN_RANGES = 100;
40
+ const MIN_LISTED_PREFIX_V4 = 16;
41
+ const MIN_LISTED_PREFIX_V6 = 32;
21
42
  const MAX_SOURCE_BYTES = 20 * MIB;
22
43
  const MAX_REDIRECTS = 3;
23
44
  const SOURCE_TIMEOUT_MS = 30_000;
@@ -42,23 +63,68 @@ function fail(message) {
42
63
  process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), event: "startup_failed", message })}\n`);
43
64
  process.exit(1);
44
65
  }
45
- /** Comma list of CIDRs or bare IPs; empty entries skipped, any invalid entry is fatal. */
46
- function parseCidrs(raw) {
66
+ /** An IP or IP/prefix: one slash, 1-3 decimal digits, prefix within the family's range; else undefined. */
67
+ function parseCidr(entry) {
68
+ const parts = entry.split("/");
69
+ const version = isIP(parts[0]);
70
+ const max = version === 6 ? 128 : 32;
71
+ const prefix = parts.length === 1 ? max : parts.length === 2 && /^\d{1,3}$/.test(parts[1]) ? Number(parts[1]) : -1;
72
+ if (version === 0 || prefix < 0 || prefix > max)
73
+ return undefined;
74
+ return { address: parts[0], prefix, family: version === 6 ? "ipv6" : "ipv4" };
75
+ }
76
+ function blockList(cidrs) {
47
77
  const list = new BlockList();
48
- const entries = (raw ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
49
- if (entries.length === 0)
50
- fail("BREWPAGE_TRUSTED_PROXY_CIDRS is required: comma list of proxy CIDRs.");
78
+ for (const { address, prefix, family } of cidrs)
79
+ list.addSubnet(address, prefix, family);
80
+ return list;
81
+ }
82
+ /** Comma list of CIDRs or bare IPs from env `name`; empty entries skipped, any invalid entry is fatal. */
83
+ function parseCidrs(name, required) {
84
+ const entries = (process.env[name] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
85
+ if (entries.length === 0 && required)
86
+ fail(`${name} is required: comma list of proxy CIDRs.`);
87
+ return entries.map((entry) => parseCidr(entry) ?? fail(`${name}: invalid entry ${JSON.stringify(entry)}.`));
88
+ }
89
+ /** `{prefixes: [{ipv4Prefix | ipv6Prefix}]}`; drops unparsable and over-wide prefixes, rejects a truncated or reshaped file. */
90
+ export function parseEgressList(text) {
91
+ const prefixes = JSON.parse(text)?.prefixes;
92
+ if (!Array.isArray(prefixes))
93
+ throw new Error("no prefixes array");
94
+ const valid = prefixes
95
+ .map((entry) => entry?.ipv4Prefix ?? entry?.ipv6Prefix)
96
+ .map((cidr) => typeof cidr === "string" ? parseCidr(cidr) : undefined)
97
+ .filter((cidr) => cidr !== undefined && cidr.prefix >= (cidr.family === "ipv6" ? MIN_LISTED_PREFIX_V6 : MIN_LISTED_PREFIX_V4));
98
+ const invalid = prefixes.length - valid.length;
99
+ if (valid.length < SHARED_LIST_MIN_RANGES || invalid * 10 > prefixes.length)
100
+ throw new Error(`${valid.length} valid, ${invalid} invalid prefixes`);
101
+ return valid;
102
+ }
103
+ /** Comma list of https URLs from env `name`; any other entry is fatal. */
104
+ function parseListUrls(name) {
105
+ const entries = (process.env[name] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
51
106
  for (const entry of entries) {
52
- const [address, prefixText] = entry.split("/");
53
- const family = isIP(address);
54
- const prefix = prefixText === undefined ? (family === 6 ? 128 : 32) : Number(prefixText);
55
- const maxPrefix = family === 6 ? 128 : 32;
56
- if (family === 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
57
- fail(`BREWPAGE_TRUSTED_PROXY_CIDRS: invalid entry ${JSON.stringify(entry)}.`);
107
+ if (!URL.canParse(entry) || new URL(entry).protocol !== "https:")
108
+ fail(`${name}: invalid entry ${JSON.stringify(entry)}.`);
109
+ }
110
+ return entries;
111
+ }
112
+ /** Body bytes after decompression, cancelled past `limit`: a gzip bomb never grows past it. */
113
+ async function readCapped(res, limit) {
114
+ if (Number(res.headers.get("content-length") ?? 0) > limit)
115
+ throw new Error("list over 1 MiB");
116
+ const reader = res.body.getReader();
117
+ const chunks = [];
118
+ let size = 0;
119
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
120
+ size += next.value.length;
121
+ if (size > limit) {
122
+ await reader.cancel();
123
+ throw new Error("list over 1 MiB");
58
124
  }
59
- list.addSubnet(address, prefix, family === 6 ? "ipv6" : "ipv4");
125
+ chunks.push(next.value);
60
126
  }
61
- return list;
127
+ return Buffer.concat(chunks).toString("utf8");
62
128
  }
63
129
  function loadConfig() {
64
130
  if (process.env[OWNER_TOKEN_ENV] !== undefined)
@@ -73,7 +139,10 @@ function loadConfig() {
73
139
  publicOrigin: publicOrigin.origin,
74
140
  forwardedProto: publicOrigin.protocol.replace(/:$/, ""),
75
141
  forwardedHost: publicOrigin.host,
76
- trustedProxies: parseCidrs(process.env.BREWPAGE_TRUSTED_PROXY_CIDRS),
142
+ trustedProxies: blockList(parseCidrs("BREWPAGE_TRUSTED_PROXY_CIDRS", true)),
143
+ /** Platform egress (one IP, many users): global pools only, no per-client caps or download budget. */
144
+ sharedEgress: parseCidrs("BREWPAGE_SHARED_EGRESS_CIDRS", false),
145
+ sharedEgressLists: parseListUrls("BREWPAGE_SHARED_EGRESS_LIST_URLS"),
77
146
  host: process.env.BREWPAGE_MCP_HOST || "0.0.0.0",
78
147
  port,
79
148
  };
@@ -100,6 +169,7 @@ export function isBlockedSource(address) {
100
169
  return BLOCKED_SOURCES.check(bare, family === 6 ? "ipv6" : "ipv4");
101
170
  }
102
171
  const SOURCE_REFUSED = "Source URL refused: private or reserved address.";
172
+ const BODY_TOO_LARGE = "Request body over 32 MiB.";
103
173
  const SOURCE_TOO_LARGE = "Source file over 20 MiB.";
104
174
  /** DNS lookup that fails when any resolved address is blocked; runs on every connection. */
105
175
  function guardedLookup(isBlocked) {
@@ -169,9 +239,10 @@ function sourceHop(url, signal, isBlocked) {
169
239
  req.end();
170
240
  });
171
241
  }
172
- /** SSRF-safe publish_file download: every hop re-checked, at most 3 redirects, one Blob copy. */
173
- export async function fetchSourceSafely(url, isBlocked = isBlockedSource) {
174
- const signal = AbortSignal.timeout(SOURCE_TIMEOUT_MS);
242
+ /** SSRF-safe publish_file download: every hop re-checked, at most 3 redirects, one Blob copy; `abort` cancels it. */
243
+ export async function fetchSourceSafely(url, isBlocked = isBlockedSource, abort) {
244
+ const timeout = AbortSignal.timeout(SOURCE_TIMEOUT_MS);
245
+ const signal = abort ? AbortSignal.any([timeout, abort]) : timeout;
175
246
  let current = new URL(url);
176
247
  for (let hop = 0;; hop++) {
177
248
  const answer = await sourceHop(current, signal, isBlocked);
@@ -215,7 +286,7 @@ const backendFetch = async (input, init) => {
215
286
  return await fetch(input, init);
216
287
  }
217
288
  catch (error) {
218
- if (!isDialFailure(error) || Date.now() >= deadline)
289
+ if (!isDialFailure(error) || Date.now() >= deadline || init?.signal?.aborted)
219
290
  throw error;
220
291
  await new Promise((resolve) => setTimeout(resolve, DIAL_RETRY_PAUSE_MS));
221
292
  }
@@ -235,8 +306,8 @@ function rpcError(res, status, code, message, headers = {}) {
235
306
  res.writeHead(status, { "Content-Type": "application/json", ...headers });
236
307
  res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
237
308
  }
238
- /** Reads at most `limit` bytes within `timeoutMs`; never rejects. */
239
- function readBody(req, limit, timeoutMs) {
309
+ /** Reads at most `limit` bytes above the minimum rate; `onLarge` (false = busy) runs once past LARGE_BODY_BYTES; never rejects. */
310
+ function readBody(req, limit, onLarge) {
240
311
  return new Promise((resolve) => {
241
312
  const chunks = [];
242
313
  let size = 0;
@@ -245,20 +316,30 @@ function readBody(req, limit, timeoutMs) {
245
316
  if (done)
246
317
  return;
247
318
  done = true;
248
- clearTimeout(timer);
319
+ clearInterval(timer);
249
320
  resolve(value);
250
321
  };
251
- const timer = setTimeout(() => finish("timeout"), timeoutMs);
322
+ const started = Date.now();
323
+ const timer = setInterval(() => {
324
+ if (size < (Date.now() - started - BODY_GRACE_MS) * BODY_MIN_BYTES_PER_MS)
325
+ finish("timeout");
326
+ }, 1_000);
252
327
  req.on("data", (chunk) => {
328
+ const before = size;
253
329
  size += chunk.length;
254
330
  if (size > limit)
255
331
  return finish("too_large");
332
+ if (before <= LARGE_BODY_BYTES && size > LARGE_BODY_BYTES && !onLarge())
333
+ return finish("busy");
256
334
  if (!done)
257
335
  chunks.push(chunk);
258
336
  });
259
337
  req.on("end", () => finish(Buffer.concat(chunks)));
260
338
  req.on("error", () => finish("aborted"));
261
339
  req.on("close", () => finish("aborted"));
340
+ // A client that left while the request was queued closed before these listeners existed.
341
+ if (req.socket.destroyed)
342
+ finish("aborted");
262
343
  });
263
344
  }
264
345
  /** Tool names from an in-memory handshake; logs name only these. */
@@ -273,13 +354,109 @@ async function registeredToolNames() {
273
354
  await server.close();
274
355
  return new Set(tools.map(({ name }) => name));
275
356
  }
357
+ /** Slot pool: global limit with a FIFO wait queue, plus a per-client cap (client undefined = shared egress, no cap). */
358
+ class Pool {
359
+ limit;
360
+ perClient;
361
+ active = 0;
362
+ clients = new Map();
363
+ waiters = [];
364
+ constructor(limit, perClient) {
365
+ this.limit = limit;
366
+ this.perClient = perClient;
367
+ }
368
+ /** A free slot now, or false; never queues. */
369
+ tryTake(client) {
370
+ if (!this.reserve(client))
371
+ return false;
372
+ if (this.active < this.limit) {
373
+ this.active++;
374
+ return true;
375
+ }
376
+ this.release(client);
377
+ return false;
378
+ }
379
+ /** True once a slot is held; false at once over the per-client cap or a full queue, or after QUEUE_WAIT_MS. */
380
+ async take(client) {
381
+ if (!this.reserve(client))
382
+ return false;
383
+ if (this.active < this.limit) {
384
+ this.active++;
385
+ return true;
386
+ }
387
+ const granted = this.waiters.length < MAX_QUEUED && await new Promise((resolve) => {
388
+ const wake = () => {
389
+ clearTimeout(timer);
390
+ resolve(true);
391
+ };
392
+ const timer = setTimeout(() => {
393
+ this.waiters.splice(this.waiters.indexOf(wake), 1);
394
+ resolve(false);
395
+ }, QUEUE_WAIT_MS);
396
+ this.waiters.push(wake);
397
+ });
398
+ if (!granted)
399
+ this.release(client);
400
+ return granted;
401
+ }
402
+ /** Frees a slot; a queued request inherits it directly. */
403
+ give(client) {
404
+ this.release(client);
405
+ const next = this.waiters.shift();
406
+ if (next)
407
+ next();
408
+ else
409
+ this.active--;
410
+ }
411
+ reserve(client) {
412
+ if (client === undefined)
413
+ return true;
414
+ const held = this.clients.get(client) ?? 0;
415
+ if (held >= this.perClient)
416
+ return false;
417
+ this.clients.set(client, held + 1);
418
+ return true;
419
+ }
420
+ release(client) {
421
+ if (client === undefined)
422
+ return;
423
+ const left = (this.clients.get(client) ?? 1) - 1;
424
+ if (left > 0)
425
+ this.clients.set(client, left);
426
+ else
427
+ this.clients.delete(client);
428
+ }
429
+ }
276
430
  async function main() {
277
431
  const config = loadConfig();
278
432
  const toolNames = await registeredToolNames();
279
- const perClient = new Map();
280
- let inFlight = 0;
281
- let heavyInFlight = 0;
433
+ const light = new Pool(MAX_LIGHT_IN_FLIGHT, MAX_LIGHT_PER_CLIENT);
434
+ const standard = new Pool(MAX_IN_FLIGHT, MAX_PER_CLIENT);
435
+ const heavy = new Pool(MAX_HEAVY_IN_FLIGHT, MAX_HEAVY_PER_CLIENT);
282
436
  let draining = false;
437
+ const listed = new Map();
438
+ let sharedEgress = blockList(config.sharedEgress);
439
+ /** Never throws: a failed fetch or parse keeps the last good set and logs a warning. */
440
+ async function loadEgressList(url) {
441
+ try {
442
+ const res = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(SHARED_LIST_TIMEOUT_MS),
443
+ headers: { "User-Agent": REMOTE_USER_AGENT } });
444
+ if (!res.ok) {
445
+ await res.body?.cancel();
446
+ throw new Error(`HTTP ${res.status}`);
447
+ }
448
+ const ranges = parseEgressList(await readCapped(res, SHARED_LIST_MAX_BYTES));
449
+ listed.set(url, ranges);
450
+ sharedEgress = blockList([...config.sharedEgress, ...[...listed.values()].flat()]);
451
+ log({ event: "shared_egress_list", url, ranges: ranges.length });
452
+ }
453
+ catch (error) {
454
+ log({ event: "shared_egress_list_failed", url, message: error instanceof Error ? error.message : String(error) });
455
+ }
456
+ }
457
+ const refreshEgressLists = () => config.sharedEgressLists.forEach((url) => void loadEgressList(url));
458
+ refreshEgressLists();
459
+ setInterval(refreshEgressLists, SHARED_LIST_REFRESH_MS).unref();
283
460
  async function handleMcp(req, res, entry) {
284
461
  if (req.headers.origin !== undefined)
285
462
  return rpcError(res, 403, SERVER_ERROR, "Origin not allowed.");
@@ -296,92 +473,127 @@ async function main() {
296
473
  return rpcError(res, 503, SERVER_ERROR, "Shutting down.", { "Retry-After": "5" });
297
474
  const declared = Number(req.headers["content-length"]);
298
475
  if (declared > MAX_BODY_BYTES)
299
- return rpcError(res, 413, SERVER_ERROR, "Request body over 8 MiB.");
300
- const busy = () => rpcError(res, 503, SERVER_ERROR, "Busy, retry shortly.", { "Retry-After": "1" });
301
- let heavy = !(Number.isFinite(declared) && declared <= LARGE_BODY_BYTES);
302
- if (inFlight >= MAX_IN_FLIGHT || (perClient.get(client) ?? 0) >= MAX_PER_CLIENT || (heavy && heavyInFlight >= MAX_HEAVY_IN_FLIGHT)) {
303
- return busy();
304
- }
305
- inFlight++;
306
- perClient.set(client, (perClient.get(client) ?? 0) + 1);
307
- if (heavy)
308
- heavyInFlight++;
309
- res.once("close", () => {
310
- inFlight--;
311
- const left = (perClient.get(client) ?? 1) - 1;
312
- if (left > 0)
313
- perClient.set(client, left);
314
- else
315
- perClient.delete(client);
316
- if (heavy)
317
- heavyInFlight--;
318
- });
319
- const body = await readBody(req, MAX_BODY_BYTES, BODY_TIMEOUT_MS);
320
- if (!(body instanceof Buffer)) {
321
- if (body === "aborted")
322
- return;
323
- res.once("finish", () => req.socket.destroy());
324
- return body === "timeout"
325
- ? rpcError(res, 408, SERVER_ERROR, "Request body too slow.", { Connection: "close" })
326
- : rpcError(res, 413, SERVER_ERROR, "Request body over 8 MiB.", { Connection: "close" });
327
- }
328
- let message;
476
+ return rpcError(res, 413, SERVER_ERROR, BODY_TOO_LARGE);
477
+ // Discards any unread body so the 503 is delivered and the connection stays usable.
478
+ const busy = () => {
479
+ req.resume();
480
+ rpcError(res, 503, SERVER_ERROR, "Busy, retry shortly.", { "Retry-After": "1" });
481
+ };
482
+ const known = Number.isFinite(declared);
483
+ const shared = sharedEgress.check(client, isIP(client) === 6 ? "ipv6" : "ipv4");
484
+ const capKey = shared ? undefined : client;
485
+ // Registered first: a client that leaves at any point, even while queued, aborts the call.
486
+ const abort = new AbortController();
487
+ res.once("close", () => abort.abort());
488
+ // One slot per request, released only after the handler settles, never on socket close.
489
+ let slot;
490
+ const acquire = async (pool) => {
491
+ // A light slot is freed before waiting, so queued writes never hold handshake capacity.
492
+ if (slot === light) {
493
+ light.give(capKey);
494
+ slot = undefined;
495
+ }
496
+ if (!(await pool.take(capKey)))
497
+ return false;
498
+ slot?.give(capKey);
499
+ slot = pool;
500
+ return true;
501
+ };
329
502
  try {
330
- message = JSON.parse(body.toString("utf8"));
331
- }
332
- catch {
333
- return rpcError(res, 400, PARSE_ERROR, "Parse error.");
334
- }
335
- if (Array.isArray(message))
336
- return rpcError(res, 400, INVALID_REQUEST, "Batch requests are not supported.");
337
- if (message === null || typeof message !== "object")
338
- return rpcError(res, 400, INVALID_REQUEST, "Invalid Request.");
339
- const rpc = message;
340
- const toolName = rpc.method === "tools/call" ? rpc.params?.name : undefined;
341
- entry.method = typeof rpc.method === "string" ? rpc.method.slice(0, MAX_LOGGED_METHOD) : null;
342
- entry.tool = rpc.method === "tools/call" ? (typeof toolName === "string" && toolNames.has(toolName) ? toolName : "unknown") : null;
343
- if (toolName === "publish_file" && !heavy) {
344
- if (heavyInFlight >= MAX_HEAVY_IN_FLIGHT)
503
+ const first = !known ? standard : declared <= SMALL_BODY_BYTES ? light : declared <= LARGE_BODY_BYTES ? standard : heavy;
504
+ if (!(await acquire(first)))
505
+ return busy();
506
+ if (abort.signal.aborted)
507
+ return;
508
+ // Unknown length that grows past 1 MiB must find a free heavy slot at once.
509
+ const body = await readBody(req, MAX_BODY_BYTES, () => {
510
+ if (slot === heavy)
511
+ return true;
512
+ if (!heavy.tryTake(capKey))
513
+ return false;
514
+ slot?.give(capKey);
515
+ slot = heavy;
516
+ return true;
517
+ });
518
+ if (!(body instanceof Buffer)) {
519
+ if (body === "aborted")
520
+ return;
521
+ if (body === "busy")
522
+ return busy();
523
+ res.once("finish", () => req.socket.destroy());
524
+ return body === "timeout"
525
+ ? rpcError(res, 408, SERVER_ERROR, "Request body too slow.", { Connection: "close" })
526
+ : rpcError(res, 413, SERVER_ERROR, BODY_TOO_LARGE, { Connection: "close" });
527
+ }
528
+ let message;
529
+ try {
530
+ message = JSON.parse(body.toString("utf8"));
531
+ }
532
+ catch {
533
+ return rpcError(res, 400, PARSE_ERROR, "Parse error.");
534
+ }
535
+ if (Array.isArray(message))
536
+ return rpcError(res, 400, INVALID_REQUEST, "Batch requests are not supported.");
537
+ if (message === null || typeof message !== "object")
538
+ return rpcError(res, 400, INVALID_REQUEST, "Invalid Request.");
539
+ const rpc = message;
540
+ const toolName = rpc.method === "tools/call" ? rpc.params?.name : undefined;
541
+ entry.method = typeof rpc.method === "string" ? rpc.method.slice(0, MAX_LOGGED_METHOD) : null;
542
+ entry.tool = rpc.method === "tools/call" ? (typeof toolName === "string" && toolNames.has(toolName) ? toolName : "unknown") : null;
543
+ const isLight = rpc.method !== "tools/call" || (typeof toolName === "string" && LIGHT_TOOLS.has(toolName));
544
+ if (toolName === "publish_file" && slot !== heavy && !(await acquire(heavy)))
545
+ return busy();
546
+ if (slot === light && !isLight && !(await acquire(standard)))
345
547
  return busy();
346
- heavy = true;
347
- heavyInFlight++;
548
+ if (abort.signal.aborted)
549
+ return;
550
+ const forwarded = { "X-Forwarded-For": client, "X-Forwarded-Proto": config.forwardedProto, "X-Forwarded-Host": config.forwardedHost };
551
+ const callBackend = (input, init) => backendFetch(input, { ...init, signal: AbortSignal.any([abort.signal, AbortSignal.timeout(BACKEND_TIMEOUT_MS)]) });
552
+ const server = createServer({
553
+ baseUrl: config.backendUrl,
554
+ publicOrigin: config.publicOrigin,
555
+ userAgent: REMOTE_USER_AGENT,
556
+ headers: forwarded,
557
+ fetchSource: (url) => shared || takeDownload(client)
558
+ ? fetchSourceSafely(url, isBlockedSource, abort.signal)
559
+ : Promise.reject(new Error("Download limit reached for this address; try again in a few minutes.")),
560
+ fetch: callBackend,
561
+ });
562
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
563
+ const send = transport.send.bind(transport);
564
+ transport.send = (outgoing, options) => {
565
+ if ("error" in outgoing)
566
+ entry.outcome = "rpc_error";
567
+ else if ("result" in outgoing && outgoing.result.isError === true)
568
+ entry.outcome = "tool_error";
569
+ return send(outgoing, options);
570
+ };
571
+ try {
572
+ await server.connect(transport);
573
+ // JSON mode resolves only after the response is sent, i.e. after the handler settled.
574
+ await transport.handleRequest(req, res, message);
575
+ }
576
+ finally {
577
+ await transport.close();
578
+ await server.close();
579
+ }
580
+ }
581
+ finally {
582
+ slot?.give(capKey);
348
583
  }
349
- const server = createServer({
350
- baseUrl: config.backendUrl,
351
- publicOrigin: config.publicOrigin,
352
- userAgent: REMOTE_USER_AGENT,
353
- headers: { "X-Forwarded-For": client, "X-Forwarded-Proto": config.forwardedProto, "X-Forwarded-Host": config.forwardedHost },
354
- fetchSource: (url) => takeDownload(client)
355
- ? fetchSourceSafely(url)
356
- : Promise.reject(new Error("Download limit reached for this address; try again in a few minutes.")),
357
- fetch: backendFetch,
358
- });
359
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
360
- const send = transport.send.bind(transport);
361
- transport.send = (outgoing, options) => {
362
- if ("error" in outgoing)
363
- entry.outcome = "rpc_error";
364
- else if ("result" in outgoing && outgoing.result.isError === true)
365
- entry.outcome = "tool_error";
366
- return send(outgoing, options);
367
- };
368
- res.once("close", () => {
369
- void transport.close();
370
- void server.close();
371
- });
372
- await server.connect(transport);
373
- await transport.handleRequest(req, res, message);
374
584
  }
375
585
  function handle(req, res, entry) {
376
586
  const path = (req.url ?? "/").split("?")[0];
377
587
  if (path === "/healthz") {
378
588
  res.writeHead(draining ? 503 : 200, { "Content-Type": "application/json" });
379
589
  res.end(JSON.stringify({ status: draining ? "draining" : "ok", version: VERSION }));
380
- return;
590
+ return Promise.resolve();
381
591
  }
382
- if (path !== "/mcp")
383
- return rpcError(res, 404, SERVER_ERROR, "Not found.");
384
- handleMcp(req, res, entry).catch((error) => {
592
+ if (path !== "/mcp") {
593
+ rpcError(res, 404, SERVER_ERROR, "Not found.");
594
+ return Promise.resolve();
595
+ }
596
+ return handleMcp(req, res, entry).catch((error) => {
385
597
  entry.outcome = "exception";
386
598
  log({ event: "request_failed", message: error instanceof Error ? error.message : String(error) });
387
599
  if (!res.headersSent)
@@ -394,30 +606,38 @@ async function main() {
394
606
  const started = performance.now();
395
607
  const entry = { http: req.method ?? "", method: null, tool: null, status: 0, outcome: null, ms: 0 };
396
608
  const logged = !(req.url ?? "").startsWith("/healthz");
397
- res.once("close", () => {
398
- if (!logged)
399
- return;
400
- entry.status = res.statusCode;
401
- entry.outcome ??= res.statusCode < 400 ? "ok" : `http_${res.statusCode}`;
402
- entry.ms = Math.round(performance.now() - started);
403
- log(entry);
404
- });
609
+ const closedEarly = new Promise((resolve) => res.once("close", () => resolve(!res.writableFinished)));
610
+ let done;
405
611
  try {
406
- handle(req, res, entry);
612
+ done = handle(req, res, entry);
407
613
  }
408
614
  catch (error) {
409
615
  entry.outcome = "exception";
410
616
  log({ event: "request_failed", message: error instanceof Error ? error.message : String(error) });
411
617
  if (!res.headersSent)
412
618
  rpcError(res, 500, SERVER_ERROR, "Internal error.");
619
+ done = Promise.resolve();
413
620
  }
621
+ // One line once the socket closed and the handler settled (slots released); ms covers both.
622
+ void Promise.all([closedEarly, done]).then(([early]) => {
623
+ if (!logged)
624
+ return;
625
+ entry.status = res.statusCode;
626
+ if (early)
627
+ entry.outcome = "client_closed";
628
+ else
629
+ entry.outcome ??= res.statusCode < 400 ? "ok" : `http_${res.statusCode}`;
630
+ entry.ms = Math.round(performance.now() - started);
631
+ log(entry);
632
+ });
414
633
  });
415
- httpServer.requestTimeout = 120_000;
634
+ // Whole request incl. queue wait: 30 s queue + 2 s grace + 32 MiB at the body floor (~131 s) + margin.
635
+ httpServer.requestTimeout = QUEUE_WAIT_MS + BODY_GRACE_MS + MAX_BODY_BYTES / BODY_MIN_BYTES_PER_MS + 5_000;
416
636
  const shutdown = () => {
417
637
  if (draining)
418
638
  return;
419
639
  draining = true;
420
- log({ event: "draining", inFlight });
640
+ log({ event: "draining", inFlight: light.active + standard.active + heavy.active });
421
641
  httpServer.close(() => process.exit(0));
422
642
  httpServer.closeIdleConnections();
423
643
  setTimeout(() => {
package/dist/tools.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  export declare const DEFAULT_BASE_URL = "https://brewpage.app";
3
- export declare const USER_AGENT = "brewpage-mcp/1.14.3";
3
+ export declare const USER_AGENT = "brewpage-mcp/1.14.5";
4
4
  export declare const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
5
5
  export interface ServerOptions {
6
6
  /** BrewPage origin for API calls; default https://brewpage.app. */
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  export const DEFAULT_BASE_URL = "https://brewpage.app";
4
- export const USER_AGENT = "brewpage-mcp/1.14.3";
4
+ export const USER_AGENT = "brewpage-mcp/1.14.5";
5
5
  export const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
6
6
  const READ_ONLY = { readOnlyHint: true };
7
7
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
@@ -46,9 +46,14 @@ function maskToken(token) {
46
46
  function textResult(text, isError = false) {
47
47
  return isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] };
48
48
  }
49
+ /** ` Retry-After: <value>.` for a busy 503 that sends the header, else empty. */
50
+ function retryAfterText(status, headers) {
51
+ const after = status === 503 ? headers?.get("retry-after") : null;
52
+ return after ? ` Retry-After: ${after}.` : "";
53
+ }
49
54
  /** Owner-tool failure: the server answer verbatim plus a hint for a plain wrong-token 403 or a frozen old publication. */
50
- function ownerFailure(operation, status, data, fromEnv) {
51
- const lines = [`Failed to ${operation} (${status}): ${JSON.stringify(data)}`];
55
+ function ownerFailure(operation, status, data, fromEnv, headers) {
56
+ const lines = [`Failed to ${operation} (${status}): ${JSON.stringify(data)}${retryAfterText(status, headers)}`];
52
57
  const code = data?.code;
53
58
  if (status === 403 && (code === undefined || code === "OWNER_AUTHORIZATION_FAILED")) {
54
59
  lines.push(`Wrong owner token for this publication. Use ownerToken from create.${fromEnv ? ` Sent: ${OWNER_TOKEN_ENV}.` : ""}`);
@@ -106,8 +111,9 @@ const DELIVERY_MODE_SCHEMA = z.enum(["path", "subdomain"]).optional().describe("
106
111
  const SITE_LIMITS_TEXT = "Default limits (current values: GET /api/limits): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; " +
107
112
  "each path at most 380 characters. System files (.DS_Store, __MACOSX/ and similar) are skipped and never count; files with an unsupported extension " +
108
113
  "or a too-long path are skipped and listed in notices. Entry: `entry`, else root index.html, else the shallowest HTML file. " +
109
- "Errors: 400 = a site limit exceeded (the message names it), invalid path or no HTML file; 413 = request over 25 MB, " +
110
- "too many files and form fields, or a file name/path too long; 422 = no servable file left.";
114
+ "Errors: 400 = a site limit exceeded (the message names it), invalid path, no HTML file, or a PDF/Office file unreadable, " +
115
+ "password-protected or too complex; 413 = request over 25 MB, too many files and form fields, or a file name/path too long; " +
116
+ "422 = no servable file left; 503 = busy, retry after Retry-After seconds.";
111
117
  function hostingResponseLines(data) {
112
118
  const fields = ["routingCohort", "deliveryMode", "requestedDeliveryMode", "effectiveDeliveryMode",
113
119
  "deliveryModeMatched", "modeLocked", "hostingVersion", "access", "managementLink"];
@@ -182,7 +188,7 @@ export function createServer(options = {}) {
182
188
  const apiFetch = options.fetch ?? fetch;
183
189
  const server = new McpServer({
184
190
  name: "brewpage-mcp",
185
- version: "1.14.3",
191
+ version: "1.14.5",
186
192
  });
187
193
  /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
188
194
  function ownerTokenFor(argument) {
@@ -300,13 +306,13 @@ export function createServer(options = {}) {
300
306
  const owner = ownerTokenFor(ownerToken).token;
301
307
  if (owner)
302
308
  headers["X-Owner-Token"] = owner;
303
- const { ok, data } = await apiRequest("POST", path, body, headers);
309
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("POST", path, body, headers);
304
310
  if (!ok) {
305
311
  return {
306
312
  content: [
307
313
  {
308
314
  type: "text",
309
- text: `Failed to publish: ${JSON.stringify(data)}`,
315
+ text: `Failed to publish: ${JSON.stringify(data)}${retryAfterText(status, answerHeaders)}`,
310
316
  },
311
317
  ],
312
318
  isError: true,
@@ -357,7 +363,7 @@ export function createServer(options = {}) {
357
363
  content: [
358
364
  {
359
365
  type: "text",
360
- text: `Failed to upload file: ${JSON.stringify(data)}`,
366
+ text: `Failed to upload file: ${JSON.stringify(data)}${retryAfterText(res.status, res.headers)}`,
361
367
  },
362
368
  ],
363
369
  isError: true,
@@ -577,7 +583,7 @@ export function createServer(options = {}) {
577
583
  content: [
578
584
  {
579
585
  type: "text",
580
- text: `Failed to publish site: ${JSON.stringify(data)}`,
586
+ text: `Failed to publish site: ${JSON.stringify(data)}${retryAfterText(res.status, res.headers)}`,
581
587
  },
582
588
  ],
583
589
  isError: true,
@@ -673,7 +679,7 @@ export function createServer(options = {}) {
673
679
  const res = await bpFetch(url, { method: "PUT", headers, body: formData });
674
680
  const data = await res.json().catch(() => null);
675
681
  if (!res.ok)
676
- return ownerFailure("republish site", res.status, data, auth.fromEnv);
682
+ return ownerFailure("republish site", res.status, data, auth.fromEnv, res.headers);
677
683
  const site = data;
678
684
  const lines = [
679
685
  `Site republished successfully (full replace)!`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.14.3",
3
+ "version": "1.14.5",
4
4
  "mcpName": "io.github.kochetkov-ma/brewpage-mcp",
5
5
  "description": "MCP server for BrewPage — publish and manage HTML, sites, KV, JSON and files from AI assistants",
6
6
  "type": "module",