brewpage-mcp 1.14.3 → 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 +14 -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 +2 -2
- 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,10 @@ 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
|
+
|
|
512
520
|
## 1.14.3 -- 2026-10-26
|
|
513
521
|
|
|
514
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`.
|
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 };
|
|
@@ -182,7 +182,7 @@ export function createServer(options = {}) {
|
|
|
182
182
|
const apiFetch = options.fetch ?? fetch;
|
|
183
183
|
const server = new McpServer({
|
|
184
184
|
name: "brewpage-mcp",
|
|
185
|
-
version: "1.14.
|
|
185
|
+
version: "1.14.4",
|
|
186
186
|
});
|
|
187
187
|
/** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
|
|
188
188
|
function ownerTokenFor(argument) {
|
package/package.json
CHANGED