brewpage-mcp 1.13.1 → 1.14.0
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 +43 -16
- package/dist/http.d.ts +4 -0
- package/dist/http.js +438 -0
- package/dist/index.js +2 -1081
- package/dist/tools.d.ts +22 -0
- package/dist/tools.js +1178 -0
- package/package.json +3 -2
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.
|
|
20
|
+
npx -y brewpage-mcp@1.14.0
|
|
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.
|
|
36
|
+
"args": ["-y", "brewpage-mcp@1.14.0"]
|
|
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.
|
|
47
|
+
claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.14.0
|
|
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.
|
|
61
|
+
"args": ["-y", "brewpage-mcp@1.14.0"]
|
|
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.
|
|
75
|
+
"args": ["-y", "brewpage-mcp@1.14.0"]
|
|
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.
|
|
83
|
+
npm install -g brewpage-mcp@1.14.0
|
|
84
84
|
brewpage-mcp
|
|
85
85
|
```
|
|
86
86
|
|
|
@@ -104,14 +104,14 @@ Twenty tools are available. Creation returns an owner token; keep it to modify o
|
|
|
104
104
|
| `get_kv` | Read a KV value |
|
|
105
105
|
| `update_hosting` | Change eligible delivery mode with owner token and hosting version |
|
|
106
106
|
| `delete_resource` | Delete an HTML page, site, JSON document, KV store or file |
|
|
107
|
-
| `set_visibility` | Hide or show a public publication in gallery
|
|
107
|
+
| `set_visibility` | Hide or show a public publication in the gallery and sitemap; link unchanged |
|
|
108
108
|
| `bind_token` | Add an owner token to your profile (admin token) |
|
|
109
109
|
| `mint_token` | Mint a profile token, shown once (admin token) |
|
|
110
110
|
| `search_gallery` | Browse public content or list owned resources |
|
|
111
111
|
| `get_stats` | Read platform-wide statistics |
|
|
112
112
|
| `get_limits` | Read the current upload limits (`GET /api/limits`) |
|
|
113
113
|
|
|
114
|
-
All five publish tools accept optional `deliveryMode: "path" | "subdomain"`, sent only as `X-Delivery-Mode`; authored JSON and multipart bodies keep their existing format. Visibility and password protection are separate choices. Omitted namespace
|
|
114
|
+
All five publish tools accept optional `deliveryMode: "path" | "subdomain"`, sent only as `X-Delivery-Mode`; authored JSON and multipart bodies keep their existing format. Visibility and password protection are separate choices. Omitted namespace is private (a generated `priv-<random>` namespace): unlisted and not indexed; anyone with the link can still open it. `public` lists HTML, sites and files in the gallery and sitemap, so Google, Bing and AI search can find and index them; public pages are served on the brewpage.app domain for the strongest reach (a site always gets its own subdomain). Never put secrets in a public publication. Password-protected publications, JSON and KV are never listed.
|
|
115
115
|
|
|
116
116
|
The MCP server wraps a subset of the backend-generated public [REST contract](../openapi/openapi.json). TTL inputs take days or a string such as `2w`/`36h` (default 15); the server rounds to whole days and clamps to 1–30 (owner-specific caps and eligible automatic extensions can differ). Use returned `expiresAt`. Every tolerated-input adjustment (TTL clamp, format alias, skipped site member, normalised namespace) comes back in the response `notices` array and is printed as `Notice (code): message`; `secret-detected` and `manage-link-disabled` print as `WARNING (code): message`: relay them to the user. A minted `manageUrl` prints as `MANAGE LINK (private): …`: give it to the user privately, never publish it. A listed public result prints `VISIBILITY: public, listed. Hide: set_visibility listed=false`. `get_page` reads stored rendered HTML or format-specific code/text from the API; it does not call the owner-only original-source endpoint or a browser viewer. `publish_file` uploads from a source URL and does not expose password or TTL inputs.
|
|
117
117
|
|
|
@@ -141,7 +141,7 @@ update_hosting(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="tok
|
|
|
141
141
|
|
|
142
142
|
Publish HTML or Markdown content to BrewPage. Returns a public URL and owner token. Markdown raw HTML is rendered through an allowlist: scripts, styles, event handlers, forms, frames/embeds and `meta`/`base`/`link` are removed; links allow `http`, `https`, `mailto`, `tel` and relative URLs, images also `data:`; a lone unknown tag such as `<author>` shows as text.
|
|
143
143
|
|
|
144
|
-
Parameters: `content` (string), `format` (default `HTML`; `HTML`, `MARKDOWN` or an alias the server maps, e.g. `md`), `namespace` (optional -- **omit for unlisted content**;
|
|
144
|
+
Parameters: `content` (string), `format` (default `HTML`; `HTML`, `MARKDOWN` or an alias the server maps, e.g. `md`), `namespace` (optional -- **omit for private, unlisted content**; `public` lists the page in the gallery and sitemap for Google, Bing and AI search, served on the brewpage.app domain; never put secrets there), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30), `filename` (optional, used as title fallback), `showTopBar` (optional boolean -- adds a toolbar with filename, Download button, and theme toggle), `deliveryMode` (optional).
|
|
145
145
|
|
|
146
146
|
The result includes `deliveryModeReason` and a `Delivery notice:` line when the server returns them (see above).
|
|
147
147
|
|
|
@@ -197,7 +197,7 @@ get_page(namespace="public", id="aBcDeFgHiJ")
|
|
|
197
197
|
|
|
198
198
|
Upload a file to BrewPage by fetching it from a URL. Returns a public URL and owner token. Supports images, PDFs, video, audio, code files, and archives.
|
|
199
199
|
|
|
200
|
-
Parameters: `url` (string, the source URL to fetch), `namespace` (optional -- omit to keep the file private
|
|
200
|
+
Parameters: `url` (string, the source URL to fetch), `namespace` (optional -- omit to keep the file private: unlisted and not indexed, anyone with the link can open it; `public` lists the file in the gallery and sitemap for Google, Bing and AI search), `filename` (optional custom filename), `deliveryMode` (optional). This tool does not expose base64, password, or TTL inputs.
|
|
201
201
|
|
|
202
202
|
Example prompt:
|
|
203
203
|
|
|
@@ -217,7 +217,7 @@ Publish a single-page or multi-file HTML site. Pass `entryContent` for a single
|
|
|
217
217
|
|
|
218
218
|
Limits (current values from `get_limits`): a request is at most 25 MB; a site at most 200 files, 5 MB per file and 20 MB in total; each path at most 380 characters. System files such as `.DS_Store` and `__MACOSX/` are skipped and never count; files with an unsupported extension or a too-long path are skipped and reported in notices. Entry: `entry`, else root `index.html`, else the shallowest HTML file. A site limit hit answers 400 with a message naming the limit; 413 means the request is over 25 MB, has too many files and form fields, or a file name/path is too long; 422 means no servable file is left.
|
|
219
219
|
|
|
220
|
-
Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for unlisted content; `public`
|
|
220
|
+
Parameters: exactly one of `entryContent` (string) or non-empty `files` (array of `{path, content}`), `entry` (optional entry file path, default `index.html`), `namespace` (optional -- omit for private, unlisted content; `public` lists the site in the gallery and sitemap for Google, Bing and AI search; a site always gets its own subdomain), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30), `ownerToken` (optional, groups site under an existing owner), `deliveryMode` (optional; fresh sites require `subdomain`).
|
|
221
221
|
|
|
222
222
|
Example prompt:
|
|
223
223
|
|
|
@@ -247,7 +247,7 @@ republish_site(namespace="public", id="aBcDeFgHiJ", ownerToken="tok_...", files=
|
|
|
247
247
|
|
|
248
248
|
Publish a JSON document to BrewPage. Returns a public URL and owner token. The body may be passed as a JSON string or as a structured object.
|
|
249
249
|
|
|
250
|
-
Parameters: `json` (string, object, or array), `namespace` (optional -- omit for
|
|
250
|
+
Parameters: `json` (string, object, or array), `namespace` (optional -- omit for private content; `public` is the shared public namespace; JSON is never listed or indexed, anyone with the link can open it), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30), `tags` (optional `string[]`), `ownerToken` (optional existing owner), `deliveryMode` (optional).
|
|
251
251
|
|
|
252
252
|
Example prompt:
|
|
253
253
|
|
|
@@ -297,7 +297,7 @@ update_json(namespace="public", id="aBcDeFgHiJ", json={"mode": "dark", "version"
|
|
|
297
297
|
|
|
298
298
|
Create a new KV (key/value) entry. Returns a public URL and owner token. The KV entry is addressed by `(namespace, id, key)`; this tool creates the entry and emits the generated `id` along with the owner token.
|
|
299
299
|
|
|
300
|
-
Parameters: `key` (string), `value` (string), `namespace` (optional -- omit for
|
|
300
|
+
Parameters: `key` (string), `value` (string), `namespace` (optional -- omit for private content; `public` is the shared public namespace; KV is never listed or indexed, anyone with the link can open it), `password` (optional), `ttlDays` (days or a string such as `2w`; default 15; the server clamps to 1--30), `tags` (optional `string[]`), `ownerToken` (optional existing owner), `deliveryMode` (optional).
|
|
301
301
|
|
|
302
302
|
Example prompt:
|
|
303
303
|
|
|
@@ -379,7 +379,7 @@ delete_resource(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="to
|
|
|
379
379
|
|
|
380
380
|
### `set_visibility`
|
|
381
381
|
|
|
382
|
-
Hide or show a public publication in gallery
|
|
382
|
+
Hide or show a public publication in the gallery and sitemap; the link stays the same. Only public, password-free HTML, sites and files can be listed; others answer with a `visibility-not-listable` notice.
|
|
383
383
|
|
|
384
384
|
Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace`, `id`, `listed` (boolean), `ownerToken` (optional).
|
|
385
385
|
|
|
@@ -443,7 +443,7 @@ get_limits()
|
|
|
443
443
|
|--------------------|------|-------|
|
|
444
444
|
| "Publish this HTML so I can share it" | `publish_html` | `format=HTML`; save `ownerToken` |
|
|
445
445
|
| "Share these meeting notes as a link" | `publish_html` | `format=MARKDOWN`; readable rendered output |
|
|
446
|
-
| "Host this AI-generated artifact" | `publish_html` | `namespace` optional; **private/unlisted by default**; `public`
|
|
446
|
+
| "Host this AI-generated artifact" | `publish_html` | `namespace` optional; **private/unlisted by default**; `public` lists it in the gallery and sitemap |
|
|
447
447
|
| "Upload this image / PDF / video" | `publish_file` | Fetches from URL; inline preview on short URL |
|
|
448
448
|
| "Deploy this static site" | `publish_site` | Pass `files` array or `entryContent` |
|
|
449
449
|
| "Replace the site I published" | `republish_site` | Owner token; complete bundle replaces all existing files |
|
|
@@ -478,6 +478,24 @@ Every publish response includes an **owner token** -- the only credential that a
|
|
|
478
478
|
| `BREWPAGE_URL` | `https://brewpage.app` | Authorized apex API base URL; never a publication host |
|
|
479
479
|
| `BREWPAGE_OWNER_TOKEN` | unset | Optional default owner token (32 letters/digits) for every call; unset = new token per create |
|
|
480
480
|
|
|
481
|
+
## Remote HTTP mode
|
|
482
|
+
|
|
483
|
+
`node dist/http.js` serves stateless MCP Streamable HTTP for a deployment behind a reverse proxy (BrewPage runs it at `https://brewpage.app/mcp`). Docker: the image default is stdio; run `docker run ... <image> node dist/http.js`.
|
|
484
|
+
|
|
485
|
+
- `POST /mcp` only, JSON responses, no `Mcp-Session-Id`; `GET`/`DELETE /mcp` -> 405. `GET /healthz` for the container healthcheck.
|
|
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
|
+
- `publish_file` downloads only from public addresses (every redirect re-checked, at most 3, 20 MiB).
|
|
488
|
+
- Never reads `BREWPAGE_OWNER_TOKEN`; refuses to start when it is set. Pass `ownerToken` per call.
|
|
489
|
+
- 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
|
+
|
|
491
|
+
| Variable | Default | Description |
|
|
492
|
+
|----------|---------|-------------|
|
|
493
|
+
| `BREWPAGE_TRUSTED_PROXY_CIDRS` | required | Comma list of reverse-proxy CIDRs or IPs |
|
|
494
|
+
| `BREWPAGE_BACKEND_URL` | `http://brewpage-backend-mcp:8080` | API base URL (internal address) |
|
|
495
|
+
| `BREWPAGE_PUBLIC_ORIGIN` | `https://brewpage.app` | Origin for returned links and forwarded proto/host |
|
|
496
|
+
| `BREWPAGE_MCP_PORT` | `8787` | Listen port |
|
|
497
|
+
| `BREWPAGE_MCP_HOST` | `0.0.0.0` | Listen address |
|
|
498
|
+
|
|
481
499
|
## Links
|
|
482
500
|
|
|
483
501
|
- [brewpage.app](https://brewpage.app) -- Live platform
|
|
@@ -491,9 +509,18 @@ Every publish response includes an **owner token** -- the only credential that a
|
|
|
491
509
|
|
|
492
510
|
## Changelog
|
|
493
511
|
|
|
512
|
+
## 1.14.0 -- 2026-10-26
|
|
513
|
+
|
|
514
|
+
- Remote mode: `node dist/http.js` serves stateless Streamable HTTP (see [Remote HTTP mode](#remote-http-mode)); stdio is unchanged. `User-Agent: brewpage-mcp/1.14.0` (`(remote)` in HTTP mode).
|
|
515
|
+
|
|
516
|
+
## 1.13.2 -- 2026-10-25
|
|
517
|
+
|
|
518
|
+
- `namespace` guidance matches BrewPage 2.1.5: `public` lists HTML, sites and files in the gallery and sitemap for Google, Bing and AI search (pages on the brewpage.app domain, a site on its own subdomain); omitted stays private; JSON and KV are never listed. Private `publish_json`/`publish_kv` results no longer suggest `public` for listing.
|
|
519
|
+
- Old publications (made before 5 October 2026): the 409 hint mentions `republishUrl` only when the server sends it (HTML pages).
|
|
520
|
+
|
|
494
521
|
## 1.13.1 -- 2026-10-25
|
|
495
522
|
|
|
496
|
-
- Old publications (
|
|
523
|
+
- Old publications (made before 5 October 2026): update tools say they answer `409 OLD_PUBLICATION_READ_ONLY`; that failure adds a hint to publish the content anew and give the user the new URL and owner token.
|
|
497
524
|
|
|
498
525
|
## 1.13.0 -- 2026-10-25
|
|
499
526
|
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
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>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer as createHttpServer, request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
5
|
+
import { BlockList, isIP } from "node:net";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import { createServer, OWNER_TOKEN_ENV, USER_AGENT } from "./tools.js";
|
|
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;
|
|
15
|
+
const LARGE_BODY_BYTES = 1 * MIB;
|
|
16
|
+
const BODY_TIMEOUT_MS = 10_000;
|
|
17
|
+
const MAX_IN_FLIGHT = 8;
|
|
18
|
+
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. */
|
|
20
|
+
const MAX_HEAVY_IN_FLIGHT = 1;
|
|
21
|
+
const MAX_SOURCE_BYTES = 20 * MIB;
|
|
22
|
+
const MAX_REDIRECTS = 3;
|
|
23
|
+
const SOURCE_TIMEOUT_MS = 30_000;
|
|
24
|
+
const SOURCE_IDLE_MS = 10_000;
|
|
25
|
+
const DOWNLOADS_PER_WINDOW = 10;
|
|
26
|
+
const DOWNLOAD_WINDOW_MS = 10 * 60_000;
|
|
27
|
+
const MAX_TRACKED_CLIENTS = 10_000;
|
|
28
|
+
const DIAL_RETRY_MS = 30_000;
|
|
29
|
+
const DIAL_RETRY_PAUSE_MS = 500;
|
|
30
|
+
const DRAIN_MS = 30_000;
|
|
31
|
+
const MAX_LOGGED_METHOD = 64;
|
|
32
|
+
const VERSION = USER_AGENT.split("/")[1];
|
|
33
|
+
const REMOTE_USER_AGENT = `${USER_AGENT} (remote)`;
|
|
34
|
+
const PARSE_ERROR = -32700;
|
|
35
|
+
const INVALID_REQUEST = -32600;
|
|
36
|
+
const SERVER_ERROR = -32000;
|
|
37
|
+
/** One JSON line per event on stdout; callers never pass arguments, tokens or IPs. */
|
|
38
|
+
function log(fields) {
|
|
39
|
+
process.stdout.write(`${JSON.stringify({ ts: new Date().toISOString(), ...fields })}\n`);
|
|
40
|
+
}
|
|
41
|
+
function fail(message) {
|
|
42
|
+
process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), event: "startup_failed", message })}\n`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
/** Comma list of CIDRs or bare IPs; empty entries skipped, any invalid entry is fatal. */
|
|
46
|
+
function parseCidrs(raw) {
|
|
47
|
+
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.");
|
|
51
|
+
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)}.`);
|
|
58
|
+
}
|
|
59
|
+
list.addSubnet(address, prefix, family === 6 ? "ipv6" : "ipv4");
|
|
60
|
+
}
|
|
61
|
+
return list;
|
|
62
|
+
}
|
|
63
|
+
function loadConfig() {
|
|
64
|
+
if (process.env[OWNER_TOKEN_ENV] !== undefined)
|
|
65
|
+
fail(`${OWNER_TOKEN_ENV} must not be set in HTTP mode.`);
|
|
66
|
+
const backendUrl = (process.env.BREWPAGE_BACKEND_URL || "http://brewpage-backend-mcp:8080").replace(/\/+$/, "");
|
|
67
|
+
const publicOrigin = new URL(process.env.BREWPAGE_PUBLIC_ORIGIN || "https://brewpage.app");
|
|
68
|
+
const port = Number(process.env.BREWPAGE_MCP_PORT || "8787");
|
|
69
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
70
|
+
fail("BREWPAGE_MCP_PORT must be a TCP port.");
|
|
71
|
+
return {
|
|
72
|
+
backendUrl,
|
|
73
|
+
publicOrigin: publicOrigin.origin,
|
|
74
|
+
forwardedProto: publicOrigin.protocol.replace(/:$/, ""),
|
|
75
|
+
forwardedHost: publicOrigin.host,
|
|
76
|
+
trustedProxies: parseCidrs(process.env.BREWPAGE_TRUSTED_PROXY_CIDRS),
|
|
77
|
+
host: process.env.BREWPAGE_MCP_HOST || "0.0.0.0",
|
|
78
|
+
port,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Never-fetch ranges: private, loopback, link-local, CGNAT, ULA, multicast, reserved, SIIT/NAT64/6to4/Teredo.
|
|
82
|
+
* BlockList matches v4-mapped IPv6 against the v4 rules, so ::ffff:0:0/96 must not be listed (it would block all IPv4). */
|
|
83
|
+
const BLOCKED_SOURCES = new BlockList();
|
|
84
|
+
for (const [network, prefix] of [
|
|
85
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
86
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
|
|
87
|
+
["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
88
|
+
])
|
|
89
|
+
BLOCKED_SOURCES.addSubnet(network, prefix, "ipv4");
|
|
90
|
+
for (const [network, prefix] of [
|
|
91
|
+
["::", 96], ["::ffff:0:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 32],
|
|
92
|
+
["2001:db8::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8],
|
|
93
|
+
])
|
|
94
|
+
BLOCKED_SOURCES.addSubnet(network, prefix, "ipv6");
|
|
95
|
+
export function isBlockedSource(address) {
|
|
96
|
+
const bare = address.replace(/^\[|\]$/g, "").split("%")[0];
|
|
97
|
+
const family = isIP(bare);
|
|
98
|
+
if (family === 0)
|
|
99
|
+
return true;
|
|
100
|
+
return BLOCKED_SOURCES.check(bare, family === 6 ? "ipv6" : "ipv4");
|
|
101
|
+
}
|
|
102
|
+
const SOURCE_REFUSED = "Source URL refused: private or reserved address.";
|
|
103
|
+
const SOURCE_TOO_LARGE = "Source file over 20 MiB.";
|
|
104
|
+
/** DNS lookup that fails when any resolved address is blocked; runs on every connection. */
|
|
105
|
+
function guardedLookup(isBlocked) {
|
|
106
|
+
return (hostname, options, callback) => {
|
|
107
|
+
dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
108
|
+
if (error)
|
|
109
|
+
return callback(error, "");
|
|
110
|
+
if (addresses.length === 0 || addresses.some(({ address }) => isBlocked(address)))
|
|
111
|
+
return callback(new Error(SOURCE_REFUSED), "");
|
|
112
|
+
if (options.all)
|
|
113
|
+
return callback(null, addresses);
|
|
114
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** One HTTP(S) hop: literal and resolved addresses checked, non-2xx rejected, body capped; never throws outside the promise. */
|
|
119
|
+
function sourceHop(url, signal, isBlocked) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
122
|
+
return reject(new Error("Source URL refused: only http and https."));
|
|
123
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
124
|
+
if (isIP(host) !== 0 && isBlocked(host))
|
|
125
|
+
return reject(new Error(SOURCE_REFUSED));
|
|
126
|
+
let settled = false;
|
|
127
|
+
const settle = (action) => {
|
|
128
|
+
if (settled)
|
|
129
|
+
return;
|
|
130
|
+
settled = true;
|
|
131
|
+
action();
|
|
132
|
+
};
|
|
133
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
134
|
+
const req = send(url, { method: "GET", agent: false, lookup: guardedLookup(isBlocked), signal, headers: { "User-Agent": REMOTE_USER_AGENT } }, (res) => {
|
|
135
|
+
const status = res.statusCode ?? 0;
|
|
136
|
+
const location = res.headers.location;
|
|
137
|
+
if ([301, 302, 303, 307, 308].includes(status) && location) {
|
|
138
|
+
res.resume();
|
|
139
|
+
return settle(() => resolve({ location }));
|
|
140
|
+
}
|
|
141
|
+
if (status < 200 || status > 299) {
|
|
142
|
+
res.destroy();
|
|
143
|
+
return settle(() => reject(new Error(`Failed to fetch file from URL: ${status}`)));
|
|
144
|
+
}
|
|
145
|
+
const tooLarge = () => {
|
|
146
|
+
res.destroy();
|
|
147
|
+
settle(() => reject(new Error(SOURCE_TOO_LARGE)));
|
|
148
|
+
};
|
|
149
|
+
if (Number(res.headers["content-length"] ?? 0) > MAX_SOURCE_BYTES)
|
|
150
|
+
return tooLarge();
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
res.on("data", (chunk) => {
|
|
154
|
+
size += chunk.length;
|
|
155
|
+
if (size > MAX_SOURCE_BYTES)
|
|
156
|
+
return tooLarge();
|
|
157
|
+
if (!settled)
|
|
158
|
+
chunks.push(chunk);
|
|
159
|
+
});
|
|
160
|
+
res.on("error", (error) => settle(() => reject(error)));
|
|
161
|
+
res.on("end", () => {
|
|
162
|
+
const contentType = res.headers["content-type"] ?? "";
|
|
163
|
+
const type = /^[\x20-\x7e]{1,200}$/.test(contentType) ? contentType : "";
|
|
164
|
+
settle(() => resolve({ blob: new Blob(chunks, { type }) }));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
req.setTimeout(SOURCE_IDLE_MS, () => req.destroy(new Error("Source download timed out.")));
|
|
168
|
+
req.on("error", (error) => settle(() => reject(error)));
|
|
169
|
+
req.end();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
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);
|
|
175
|
+
let current = new URL(url);
|
|
176
|
+
for (let hop = 0;; hop++) {
|
|
177
|
+
const answer = await sourceHop(current, signal, isBlocked);
|
|
178
|
+
if ("blob" in answer)
|
|
179
|
+
return answer.blob;
|
|
180
|
+
if (hop >= MAX_REDIRECTS)
|
|
181
|
+
throw new Error("Source URL refused: more than 3 redirects.");
|
|
182
|
+
current = new URL(answer.location, current);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Fixed-window download budget per client IP; map size bounded by pruning, then clearing. */
|
|
186
|
+
const downloadWindows = new Map();
|
|
187
|
+
function takeDownload(client, now = Date.now()) {
|
|
188
|
+
let window = downloadWindows.get(client);
|
|
189
|
+
if (window === undefined || now - window.start >= DOWNLOAD_WINDOW_MS) {
|
|
190
|
+
if (window === undefined && downloadWindows.size >= MAX_TRACKED_CLIENTS) {
|
|
191
|
+
for (const [key, value] of downloadWindows)
|
|
192
|
+
if (now - value.start >= DOWNLOAD_WINDOW_MS)
|
|
193
|
+
downloadWindows.delete(key);
|
|
194
|
+
if (downloadWindows.size >= MAX_TRACKED_CLIENTS)
|
|
195
|
+
downloadWindows.clear();
|
|
196
|
+
}
|
|
197
|
+
window = { start: now, count: 0 };
|
|
198
|
+
downloadWindows.set(client, window);
|
|
199
|
+
}
|
|
200
|
+
if (window.count >= DOWNLOADS_PER_WINDOW)
|
|
201
|
+
return false;
|
|
202
|
+
window.count++;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const DIAL_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"]);
|
|
206
|
+
function isDialFailure(error) {
|
|
207
|
+
const cause = error?.cause;
|
|
208
|
+
return DIAL_CODES.has(cause?.code ?? "") || (cause?.errors ?? []).some(({ code }) => DIAL_CODES.has(code ?? ""));
|
|
209
|
+
}
|
|
210
|
+
/** Backend calls retry only connect failures (request never sent) for 30 s, e.g. during a backend restart. */
|
|
211
|
+
const backendFetch = async (input, init) => {
|
|
212
|
+
const deadline = Date.now() + DIAL_RETRY_MS;
|
|
213
|
+
for (;;) {
|
|
214
|
+
try {
|
|
215
|
+
return await fetch(input, init);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!isDialFailure(error) || Date.now() >= deadline)
|
|
219
|
+
throw error;
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, DIAL_RETRY_PAUSE_MS));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
function peerAddress(req) {
|
|
225
|
+
const raw = req.socket.remoteAddress ?? "";
|
|
226
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw);
|
|
227
|
+
return mapped ? mapped[1] : raw;
|
|
228
|
+
}
|
|
229
|
+
/** Last XFF entry (the proxy replaces the header); only an IP literal is accepted. */
|
|
230
|
+
function clientAddress(req) {
|
|
231
|
+
const last = (req.headers["x-forwarded-for"] ?? "").toString().split(",").at(-1)?.trim() ?? "";
|
|
232
|
+
return isIP(last) !== 0 ? last : undefined;
|
|
233
|
+
}
|
|
234
|
+
function rpcError(res, status, code, message, headers = {}) {
|
|
235
|
+
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
236
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
|
|
237
|
+
}
|
|
238
|
+
/** Reads at most `limit` bytes within `timeoutMs`; never rejects. */
|
|
239
|
+
function readBody(req, limit, timeoutMs) {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
const chunks = [];
|
|
242
|
+
let size = 0;
|
|
243
|
+
let done = false;
|
|
244
|
+
const finish = (value) => {
|
|
245
|
+
if (done)
|
|
246
|
+
return;
|
|
247
|
+
done = true;
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
resolve(value);
|
|
250
|
+
};
|
|
251
|
+
const timer = setTimeout(() => finish("timeout"), timeoutMs);
|
|
252
|
+
req.on("data", (chunk) => {
|
|
253
|
+
size += chunk.length;
|
|
254
|
+
if (size > limit)
|
|
255
|
+
return finish("too_large");
|
|
256
|
+
if (!done)
|
|
257
|
+
chunks.push(chunk);
|
|
258
|
+
});
|
|
259
|
+
req.on("end", () => finish(Buffer.concat(chunks)));
|
|
260
|
+
req.on("error", () => finish("aborted"));
|
|
261
|
+
req.on("close", () => finish("aborted"));
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** Tool names from an in-memory handshake; logs name only these. */
|
|
265
|
+
async function registeredToolNames() {
|
|
266
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
267
|
+
const server = createServer({ baseUrl: "http://127.0.0.1:9" });
|
|
268
|
+
const client = new Client({ name: "startup", version: VERSION });
|
|
269
|
+
await server.connect(serverSide);
|
|
270
|
+
await client.connect(clientSide);
|
|
271
|
+
const { tools } = await client.listTools();
|
|
272
|
+
await client.close();
|
|
273
|
+
await server.close();
|
|
274
|
+
return new Set(tools.map(({ name }) => name));
|
|
275
|
+
}
|
|
276
|
+
async function main() {
|
|
277
|
+
const config = loadConfig();
|
|
278
|
+
const toolNames = await registeredToolNames();
|
|
279
|
+
const perClient = new Map();
|
|
280
|
+
let inFlight = 0;
|
|
281
|
+
let heavyInFlight = 0;
|
|
282
|
+
let draining = false;
|
|
283
|
+
async function handleMcp(req, res, entry) {
|
|
284
|
+
if (req.headers.origin !== undefined)
|
|
285
|
+
return rpcError(res, 403, SERVER_ERROR, "Origin not allowed.");
|
|
286
|
+
const peer = peerAddress(req);
|
|
287
|
+
if (isIP(peer) === 0 || !config.trustedProxies.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4")) {
|
|
288
|
+
return rpcError(res, 403, SERVER_ERROR, "Forbidden.");
|
|
289
|
+
}
|
|
290
|
+
if (req.method !== "POST")
|
|
291
|
+
return rpcError(res, 405, SERVER_ERROR, "Method not allowed.", { Allow: "POST" });
|
|
292
|
+
const client = clientAddress(req);
|
|
293
|
+
if (client === undefined)
|
|
294
|
+
return rpcError(res, 400, SERVER_ERROR, "Missing client address.");
|
|
295
|
+
if (draining)
|
|
296
|
+
return rpcError(res, 503, SERVER_ERROR, "Shutting down.", { "Retry-After": "5" });
|
|
297
|
+
const declared = Number(req.headers["content-length"]);
|
|
298
|
+
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;
|
|
329
|
+
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)
|
|
345
|
+
return busy();
|
|
346
|
+
heavy = true;
|
|
347
|
+
heavyInFlight++;
|
|
348
|
+
}
|
|
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
|
+
}
|
|
375
|
+
function handle(req, res, entry) {
|
|
376
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
377
|
+
if (path === "/healthz") {
|
|
378
|
+
res.writeHead(draining ? 503 : 200, { "Content-Type": "application/json" });
|
|
379
|
+
res.end(JSON.stringify({ status: draining ? "draining" : "ok", version: VERSION }));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (path !== "/mcp")
|
|
383
|
+
return rpcError(res, 404, SERVER_ERROR, "Not found.");
|
|
384
|
+
handleMcp(req, res, entry).catch((error) => {
|
|
385
|
+
entry.outcome = "exception";
|
|
386
|
+
log({ event: "request_failed", message: error instanceof Error ? error.message : String(error) });
|
|
387
|
+
if (!res.headersSent)
|
|
388
|
+
rpcError(res, 500, SERVER_ERROR, "Internal error.");
|
|
389
|
+
else
|
|
390
|
+
res.end();
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const httpServer = createHttpServer((req, res) => {
|
|
394
|
+
const started = performance.now();
|
|
395
|
+
const entry = { http: req.method ?? "", method: null, tool: null, status: 0, outcome: null, ms: 0 };
|
|
396
|
+
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
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
handle(req, res, entry);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
entry.outcome = "exception";
|
|
410
|
+
log({ event: "request_failed", message: error instanceof Error ? error.message : String(error) });
|
|
411
|
+
if (!res.headersSent)
|
|
412
|
+
rpcError(res, 500, SERVER_ERROR, "Internal error.");
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
httpServer.requestTimeout = 120_000;
|
|
416
|
+
const shutdown = () => {
|
|
417
|
+
if (draining)
|
|
418
|
+
return;
|
|
419
|
+
draining = true;
|
|
420
|
+
log({ event: "draining", inFlight });
|
|
421
|
+
httpServer.close(() => process.exit(0));
|
|
422
|
+
httpServer.closeIdleConnections();
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
httpServer.closeAllConnections();
|
|
425
|
+
process.exit(0);
|
|
426
|
+
}, DRAIN_MS).unref();
|
|
427
|
+
};
|
|
428
|
+
process.on("SIGTERM", shutdown);
|
|
429
|
+
process.on("SIGINT", shutdown);
|
|
430
|
+
httpServer.listen(config.port, config.host, () => {
|
|
431
|
+
const address = httpServer.address();
|
|
432
|
+
log({ event: "listening", port: typeof address === "object" && address !== null ? address.port : config.port, version: VERSION });
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/** Runs only as the entry script; tests import the address guard and the source fetcher. */
|
|
436
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
437
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
438
|
+
}
|