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 +18 -6
- package/dist/http.d.ts +10 -2
- package/dist/http.js +336 -116
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +22 -20
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
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;
|
|
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
|
-
/**
|
|
46
|
-
function
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
125
|
+
chunks.push(next.value);
|
|
60
126
|
}
|
|
61
|
-
return
|
|
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(
|
|
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
|
|
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
|
|
239
|
-
function readBody(req, limit,
|
|
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
|
-
|
|
319
|
+
clearInterval(timer);
|
|
249
320
|
resolve(value);
|
|
250
321
|
};
|
|
251
|
-
const
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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.
|
|
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.
|
|
17
|
-
const MANAGE_URL_TEXT = "Returns manageUrl
|
|
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
|
|
58
|
-
lines.push("Read-only old publication: cannot be updated.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
84
|
-
const PUBLIC_NAMESPACE_TAIL = " Anyone can find and read a public page
|
|
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;
|
|
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 = "
|
|
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
|
|
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.
|
|
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.
|
|
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;
|
|
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:
|
|
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:
|
|
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
|
|
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