brewpage-mcp 1.14.2 → 1.14.4

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.2
20
+ npx -y brewpage-mcp@1.14.4
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.2"]
36
+ "args": ["-y", "brewpage-mcp@1.14.4"]
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.2
47
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.14.4
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.2"]
61
+ "args": ["-y", "brewpage-mcp@1.14.4"]
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.2"]
75
+ "args": ["-y", "brewpage-mcp@1.14.4"]
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.2
83
+ npm install -g brewpage-mcp@1.14.4
84
84
  brewpage-mcp
85
85
  ```
86
86
 
@@ -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.4 -- 2026-10-26
517
+
518
+ - 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.
519
+
520
+ ## 1.14.3 -- 2026-10-26
521
+
522
+ - 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`.
523
+
512
524
  ## 1.14.2 -- 2026-10-26
513
525
 
514
526
  - MCP Registry entry lists the remote endpoint `https://brewpage.app/mcp` (Streamable HTTP, no auth) beside the npm package. Tools are unchanged.
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.2";
3
+ export declare const USER_AGENT = "brewpage-mcp/1.14.4";
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.2";
4
+ export const USER_AGENT = "brewpage-mcp/1.14.4";
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 };
@@ -13,10 +13,10 @@ function toolMeta(title, hints) {
13
13
  const NO_OWNER_TOKEN = `No owner token: pass ownerToken or set ${OWNER_TOKEN_ENV}.`;
14
14
  const CREATE_OWNER_TOKEN_DESCRIPTION = `Optional. Default: ${OWNER_TOKEN_ENV}, else a new token per create.`;
15
15
  const OWNER_TOKEN_DESCRIPTION = `Owner token from create. Default: ${OWNER_TOKEN_ENV}.`;
16
- const SECRETS = "Secrets allowed; likely ones are flagged in notices. For a secret: private namespace + password.";
17
- const MANAGE_URL_TEXT = "Returns manageUrl (private): give it to the user privately; never publish it.";
16
+ const SECRETS = "Secrets allowed; likely ones are flagged in notices. Private namespace + password suit secrets.";
17
+ const MANAGE_URL_TEXT = "Returns manageUrl, a private management link (grants control; not for public sharing).";
18
18
  const OLD_READ_ONLY_CODE = "OLD_PUBLICATION_READ_ONLY";
19
- const OLD_READ_ONLY_TEXT = ` Old publications (made before 5 October 2026) are read-only: 409 ${OLD_READ_ONLY_CODE}.`;
19
+ const OLD_READ_ONLY_TEXT = ` Old publications (made before 5 October 2026) are read-only: 409 ${OLD_READ_ONLY_CODE} with createUrl to publish anew.`;
20
20
  /** Notice codes the agent must relay to the user. */
21
21
  const WARNING_CODES = new Set(["secret-detected", "manage-link-disabled"]);
22
22
  /** MCP type name -> REST path segment. */
@@ -54,11 +54,13 @@ function ownerFailure(operation, status, data, fromEnv) {
54
54
  lines.push(`Wrong owner token for this publication. Use ownerToken from create.${fromEnv ? ` Sent: ${OWNER_TOKEN_ENV}.` : ""}`);
55
55
  }
56
56
  if (status === 409 && code === OLD_READ_ONLY_CODE) {
57
- const republishUrl = data.republishUrl;
58
- lines.push("Read-only old publication: cannot be updated. Publish the content anew with the matching " +
59
- "publish tool in the same namespace; give the user the NEW URL and owner token. The old URL keeps its old content " +
60
- "until it expires." + (typeof republishUrl === "string" ? " Ignore republishUrl (browser-only)." : "") +
61
- (operation === "set KV" ? " For KV, read all keys with get_kv and publish them together with publish_kv." : ""));
57
+ const { createUrl, republishUrl } = data;
58
+ lines.push("Read-only old publication: cannot be updated. " +
59
+ (typeof createUrl === "string" ? `createUrl (${createUrl}) is the create endpoint for publishing the content anew in that namespace.`
60
+ : "The content can be published anew in the same namespace.") +
61
+ " A new publication gets a new URL and owner token; the old URL keeps its old content until it expires." +
62
+ (typeof republishUrl === "string" ? " republishUrl is a browser-only composer link." : "") +
63
+ (operation === "set KV" ? " A new KV store holds only the keys published to it." : ""));
62
64
  }
63
65
  return textResult(lines.join("\n"), true);
64
66
  }
@@ -80,8 +82,8 @@ const UNLISTED_NOTICE = "Private: not listed. Anyone with the link can open it."
80
82
  const LISTABLE_HINT = " Use namespace public to list it in the gallery and sitemap.";
81
83
  const PROTECTED_UNLISTED_NOTICE = "Password: link + password. Not listed.";
82
84
  const PUBLIC_NAMESPACE_LEAD = "namespace=public: listed in the public gallery and the sitemap, so Google, Bing and AI search can find and index it " +
83
- "and people can discover it. Use it for anything meant to be found or shared: docs, guides, demos, portfolios, reports, landing pages. ";
84
- const PUBLIC_NAMESPACE_TAIL = " Anyone can find and read a public page: never put secrets, tokens or personal data there. " +
85
+ "and people can discover it. Suits anything meant to be found or shared: docs, guides, demos, portfolios, reports, landing pages. ";
86
+ const PUBLIC_NAMESPACE_TAIL = " Anyone can find and read a public page, so it is unsuitable for secrets, tokens or personal data. " +
85
87
  "Omit (default): private. Unlisted and not indexed; anyone who has the link can still open it. ";
86
88
  const NAMESPACE_FORMAT = "Lowercase only: 1-32 of a-z, 0-9, hyphen.";
87
89
  /** `namespace` for publish_html and publish_file. */
@@ -95,16 +97,16 @@ const SITE_NAMESPACE_DESCRIPTION = PUBLIC_NAMESPACE_LEAD + "A site always gets i
95
97
  const DATA_NAMESPACE_DESCRIPTION = "Omit (default): private. namespace=public: the shared public namespace. JSON and KV are never listed in the gallery " +
96
98
  "or indexed, in any namespace; anyone who has the link can open it. " + NAMESPACE_FORMAT;
97
99
  const TTL_SCHEMA = z.union([z.number().positive(), z.string().min(1).max(32)]).optional().describe("Requested TTL: days as a number, or a string such as '2w', '36h' or '1 month' (default 15 days). " +
98
- "The server rounds to whole days and clamps to the allowed range (1-30 by default; owner caps differ), reporting each adjustment in notices; use returned expiresAt.");
100
+ "The server rounds to whole days and clamps to the allowed range (1-30 by default; owner caps differ), reporting each adjustment in notices; the returned expiresAt is the actual expiry.");
99
101
  const DELIVERY_MODE_SCHEMA = z.enum(["path", "subdomain"]).optional().describe("Optional delivery choice for a fresh publication, independent of password/namespace. " +
100
102
  "Fresh sites and HTML-format pages outside public (including the default unlisted namespace) always use subdomain; an explicit path there still publishes, on subdomain, with a delivery notice. " +
101
103
  "Everything else in any namespace (Markdown, code/text, files, JSON, KV, public HTML) defaults to path (Promotion); public HTML may automatically choose subdomain for browser features. " +
102
104
  "When creation returns an existing publication, it keeps its actual mode and URL.");
103
105
  /** Site upload rules shared by publish_site and republish_site; current values come from get_limits. */
104
- const SITE_LIMITS_TEXT = "Limits (call get_limits for current values): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; " +
106
+ 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; " +
105
107
  "each path at most 380 characters. System files (.DS_Store, __MACOSX/ and similar) are skipped and never count; files with an unsupported extension " +
106
108
  "or a too-long path are skipped and listed in notices. Entry: `entry`, else root index.html, else the shallowest HTML file. " +
107
- "Errors: 400 = a site limit exceeded (the message names it and what to do), invalid path or no HTML file; 413 = request over 25 MB, " +
109
+ "Errors: 400 = a site limit exceeded (the message names it), invalid path or no HTML file; 413 = request over 25 MB, " +
108
110
  "too many files and form fields, or a file name/path too long; 422 = no servable file left.";
109
111
  function hostingResponseLines(data) {
110
112
  const fields = ["routingCohort", "deliveryMode", "requestedDeliveryMode", "effectiveDeliveryMode",
@@ -180,7 +182,7 @@ export function createServer(options = {}) {
180
182
  const apiFetch = options.fetch ?? fetch;
181
183
  const server = new McpServer({
182
184
  name: "brewpage-mcp",
183
- version: "1.14.2",
185
+ version: "1.14.4",
184
186
  });
185
187
  /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
186
188
  function ownerTokenFor(argument) {
@@ -252,7 +254,7 @@ export function createServer(options = {}) {
252
254
  ...toolMeta("Publish HTML or Markdown page", ADDITIVE),
253
255
  description: "Publish HTML or Markdown content to BrewPage (repository default limit 5 MiB). Returns its primary share link and owner token. Supports password protection, custom TTL, optional filename, and an opt-in top toolbar (showTopBar). " +
254
256
  "When deliveryMode is omitted for a `public` page, the server may publish it on its own subdomain if the page needs browser features a BrewPage link does not provide; " +
255
- "the result then includes deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms and embeds are removed. Always share the returned URL. " +
257
+ "the result then includes deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms and embeds are removed. " +
256
258
  MANAGE_URL_TEXT + " " + SECRETS,
257
259
  inputSchema: {
258
260
  content: z.string().describe("HTML or Markdown content to publish"),
@@ -372,7 +374,7 @@ export function createServer(options = {}) {
372
374
  });
373
375
  server.registerTool("update_hosting", {
374
376
  ...toolMeta("Change delivery mode", ADDITIVE),
375
- description: "Change delivery mode for a NEW publication (not a site, not a non-public HTML-format page). Requires owner token; pass the current hostingVersion when known. Mismatches are reported, not rejected: a stale version still applies, and OLD links or forced subdomain modes stay unchanged; the result then carries a delivery notice. Content updates never change hosting.",
377
+ description: "Change delivery mode for a NEW publication (not a site, not a non-public HTML-format page). Requires owner token; optional expectedVersion = current hostingVersion. Mismatches are reported, not rejected: a stale version still applies, and OLD links or forced subdomain modes stay unchanged; the result then carries a delivery notice. Content updates never change hosting.",
376
378
  inputSchema: {
377
379
  type: z.enum(["html", "file", "json", "kv"]).describe("Resource type; sites always stay on their own subdomain"),
378
380
  namespace: z.string().describe("Resource namespace; non-public HTML-format pages stay on subdomain"),
@@ -504,7 +506,7 @@ export function createServer(options = {}) {
504
506
  });
505
507
  server.registerTool("publish_site", {
506
508
  ...toolMeta("Publish site", ADDITIVE),
507
- description: "Publish an HTML site to BrewPage. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides default `index.html`. Use its returned primary link and retain the owner token. " + SITE_LIMITS_TEXT +
509
+ description: "Publish an HTML site to BrewPage. Single-page: `entryContent`. Multi-page: `files` (array of `{path, content}`). Optional `entry` overrides default `index.html`. Returns its primary share link and owner token. " + SITE_LIMITS_TEXT +
508
510
  " " + MANAGE_URL_TEXT + " " + SECRETS,
509
511
  inputSchema: {
510
512
  deliveryMode: DELIVERY_MODE_SCHEMA,
@@ -612,7 +614,7 @@ export function createServer(options = {}) {
612
614
  });
613
615
  server.registerTool("republish_site", {
614
616
  ...toolMeta("Republish site", DESTRUCTIVE),
615
- description: "Republish (full-replace) an existing multi-file BrewPage site in place at the SAME URL/ID. Requires owner token. The uploaded bundle becomes the complete new file set — files absent from it are removed, new files added, matching files overwritten. Single-page: pass `entryContent`. Multi-page: pass `files` (array of `{path, content}`). Optional `entry` overrides the default `index.html`. Republishing preserves existing expiry." + OLD_READ_ONLY_TEXT + " " + SITE_LIMITS_TEXT,
617
+ description: "Republish (full-replace) an existing multi-file BrewPage site in place at the SAME URL/ID. Requires owner token. The uploaded bundle becomes the complete new file set — files absent from it are removed, new files added, matching files overwritten. Single-page: `entryContent`. Multi-page: `files` (array of `{path, content}`). Optional `entry` overrides the default `index.html`. Republishing preserves existing expiry." + OLD_READ_ONLY_TEXT + " " + SITE_LIMITS_TEXT,
616
618
  inputSchema: {
617
619
  namespace: z.string().describe("Site namespace"),
618
620
  id: z.string().describe("Site ID"),
@@ -999,7 +1001,7 @@ export function createServer(options = {}) {
999
1001
  ...toolMeta("Get upload limits", READ_ONLY),
1000
1002
  description: "Get the current BrewPage upload limits (GET /api/limits): request size, site file count, per-file and total size, " +
1001
1003
  "ZIP expansion, path and name lengths, TTL default/maximum, allowed file extensions and skipped system files. Sizes are bytes. " +
1002
- `With ${OWNER_TOKEN_ENV} set: also reach (token|profile) and ownerMaxTtlDays. Call it before publishing a large or multi-file site.`,
1004
+ `With ${OWNER_TOKEN_ENV} set: also reach (token|profile) and ownerMaxTtlDays.`,
1003
1005
  inputSchema: {},
1004
1006
  }, async () => {
1005
1007
  const headers = defaultOwnerToken ? { "X-Owner-Token": defaultOwnerToken } : {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
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",