brewpage-mcp 1.19.1 → 1.21.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 CHANGED
@@ -8,7 +8,7 @@ MCP server for [BrewPage](https://brewpage.app) -- publish and manage HTML, KV,
8
8
 
9
9
  ## What is BrewPage
10
10
 
11
- [BrewPage](https://brewpage.app) is a free instant hosting platform designed for AI agents and developers. One `POST` request publishes HTML, Markdown, a multi-file site, a JSON document, or a binary file and returns its primary HTTPS link -- no account needed, no API keys, no infrastructure setup. Every resource carries an **owner token** returned at creation time: use it to update content in place, delete the resource, or authenticate list operations across sessions. `brewpage-mcp` exposes this API as thirty typed MCP tools so any compatible agent -- Claude, Codex, Gemini, Cursor, Cline -- can publish, update, fetch, and manage BrewPage HTML, JSON, KV, and file content without leaving the conversation.
11
+ [BrewPage](https://brewpage.app) is a free instant hosting platform designed for AI agents and developers. One `POST` request publishes HTML, Markdown, a multi-file site, a JSON document, or a binary file and returns its primary HTTPS link -- no account needed, no API keys, no infrastructure setup. Every resource carries an **owner token** returned at creation time: use it to update content in place, delete the resource, or authenticate list operations across sessions. `brewpage-mcp` exposes this API as thirty-two typed MCP tools so any compatible agent -- Claude, Codex, Gemini, Cursor, Cline -- can publish, update, fetch, and manage BrewPage HTML, JSON, KV, and file content without leaving the conversation.
12
12
 
13
13
  Optional: sign in free (Google or any email) at https://account.brewpage.app to never lose your pages or tokens and to mint tokens that never expire. Not needed to publish.
14
14
 
@@ -18,7 +18,7 @@ Choosing a host? See how BrewPage compares with GitHub Pages, Netlify Drop, Tiin
18
18
 
19
19
  ### Remote (no install)
20
20
 
21
- One remote MCP server for every client: `https://brewpage.app/mcp` (Streamable HTTP, no authentication, same 30 tools). Add it by URL in Claude (a custom connector), ChatGPT (a custom MCP server) or any Streamable HTTP client.
21
+ One remote MCP server for every client: `https://brewpage.app/mcp` (Streamable HTTP, no authentication, same 32 tools). Add it by URL in Claude (a custom connector), ChatGPT (a custom MCP server) or any Streamable HTTP client.
22
22
 
23
23
  Claude Code: `claude mcp add --transport http brewpage --scope user https://brewpage.app/mcp` (leave out `--scope user` to add it to the current project only), or in `.mcp.json`:
24
24
 
@@ -38,7 +38,7 @@ Claude Code: `claude mcp add --transport http brewpage --scope user https://brew
38
38
  For clients that only start local servers; the same tools as the server URL.
39
39
 
40
40
  ```bash
41
- npx -y brewpage-mcp@1.19.1
41
+ npx -y brewpage-mcp@1.21.0
42
42
  ```
43
43
 
44
44
  Requires Node.js 20 or newer. This starts the stdio server for an MCP client.
@@ -63,7 +63,7 @@ Local fallback (stdio, needs Node.js; the same tools): add to `~/Library/Applica
63
63
  "mcpServers": {
64
64
  "brewpage": {
65
65
  "command": "npx",
66
- "args": ["-y", "brewpage-mcp@1.19.1"]
66
+ "args": ["-y", "brewpage-mcp@1.21.0"]
67
67
  }
68
68
  }
69
69
  }
@@ -86,7 +86,7 @@ Custom servers follow your ChatGPT plan and workspace settings.
86
86
  The remote command above is the default. Local fallback: register a project-scoped stdio server:
87
87
 
88
88
  ```bash
89
- claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.19.1
89
+ claude mcp add --transport stdio --scope project brewpage -- npx -y brewpage-mcp@1.21.0
90
90
  ```
91
91
 
92
92
  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).
@@ -110,7 +110,7 @@ Local fallback (stdio, needs Node.js; the same tools): open **Settings > MCP** a
110
110
  "mcpServers": {
111
111
  "brewpage": {
112
112
  "command": "npx",
113
- "args": ["-y", "brewpage-mcp@1.19.1"]
113
+ "args": ["-y", "brewpage-mcp@1.21.0"]
114
114
  }
115
115
  }
116
116
  }
@@ -124,14 +124,14 @@ Open the Cline MCP settings panel and add:
124
124
  {
125
125
  "brewpage": {
126
126
  "command": "npx",
127
- "args": ["-y", "brewpage-mcp@1.19.1"]
127
+ "args": ["-y", "brewpage-mcp@1.21.0"]
128
128
  }
129
129
  }
130
130
  ```
131
131
 
132
132
  ## Tools
133
133
 
134
- Thirty tools are available. Creation returns an owner token; keep it to modify or delete the resource later. Owner tools take `ownerToken`, else `BREWPAGE_OWNER_TOKEN` (local server only). Supported password inputs use `X-Password`. Secrets allowed; likely ones are flagged in notices. For a secret: private namespace + password.
134
+ Thirty-two tools are available. Creation returns an owner token; keep it to modify or delete the resource later. Owner tools take `ownerToken`, else `BREWPAGE_OWNER_TOKEN` (local server only). Supported password inputs use `X-Password`. Secrets allowed; likely ones are flagged in notices. For a secret: private namespace + password.
135
135
 
136
136
  | Tool | Purpose |
137
137
  |------|---------|
@@ -149,7 +149,9 @@ Thirty tools are available. Creation returns an owner token; keep it to modify o
149
149
  | `get_kv` | Read a KV value |
150
150
  | `update_hosting` | Change eligible delivery mode with owner token and hosting version |
151
151
  | `delete_resource` | Delete an HTML page, site, JSON document, KV store or file |
152
- | `set_visibility` | Hide or show a public publication in the gallery and sitemap; link unchanged |
152
+ | `set_visibility` | List or unlist a public publication in the gallery and sitemap; link unchanged |
153
+ | `make_private` | Make a public publication private: new private link, the public link stops working; one-way |
154
+ | `set_badge` | Hide or show the BrewPage badge on a publication of your profile, up to 5 hidden |
153
155
  | `set_title` | Rename an HTML page or site; the title stays on republish; link unchanged |
154
156
  | `bind_token` | Add an owner token to your profile (admin token) |
155
157
  | `mint_token` | Mint a profile token, shown once (admin token) |
@@ -436,9 +438,9 @@ delete_resource(type="html", namespace="public", id="aBcDeFgHiJ", ownerToken="to
436
438
 
437
439
  ### `set_visibility`
438
440
 
439
- 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.
441
+ List or unlist 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.
440
442
 
441
- Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace`, `id`, `listed` (boolean), `ownerToken` (optional).
443
+ Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace`, `id`, `listed` (boolean: `true` = listed, `false` = unlisted), `ownerToken` (optional).
442
444
 
443
445
  ```
444
446
  set_visibility(type="html", namespace="public", id="aBcDeFgHiJ", listed=false)
@@ -448,6 +450,34 @@ Output: `LISTED: false · link unchanged`.
448
450
 
449
451
  ---
450
452
 
453
+ ### `make_private`
454
+
455
+ Make a public publication private (`POST /api/{type}/{ns}/{id}/private`): it gets a new private link in a private namespace under a new id, and the public link stops working. Views and stats stay; a custom domain on it is detached. One-way: to go public again, publish again. The owner token stays the same; later calls take the new namespace and id.
456
+
457
+ Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace` (`public`), `id`, `ownerToken` (optional).
458
+
459
+ ```
460
+ make_private(type="html", namespace="public", id="aBcDeFgHiJ")
461
+ ```
462
+
463
+ Output: `MADE PRIVATE · the public link no longer works`, then the new link, namespace and id, and the new management link when the publication had one. Give the user the new link and the management link. A publication outside `public` answers 409 `NOT_PUBLIC`.
464
+
465
+ ---
466
+
467
+ ### `set_badge`
468
+
469
+ Hide or show the BrewPage badge on a publication (`POST /api/{type}/{ns}/{id}/badge`). HTML pages and sites show a small BrewPage badge; page code must not hide or alter it. Hiding works when the publication's owner token is in a BrewPage profile: sign in free at https://account.brewpage.app, then `bind_token`. A profile hides the badge on up to 5 publications; showing it again frees the slot.
470
+
471
+ Parameters: `type` (`html` | `site` | `file` | `json` | `kv`), `namespace`, `id`, `hidden` (optional boolean: `true` = hide, `false` = show; omitted reports the current state), `ownerToken` (optional).
472
+
473
+ ```
474
+ set_badge(type="html", namespace="public", id="aBcDeFgHiJ", hidden=true)
475
+ ```
476
+
477
+ Output: `BADGE: hidden · 2 of 5 hidden in the profile · link unchanged`. Without a profile the badge stays shown and the result carries the `badge-hide-needs-profile` notice; a sixth hide answers 409 `BADGE_HIDDEN_LIMIT`.
478
+
479
+ ---
480
+
451
481
  ### `set_title`
452
482
 
453
483
  Rename an HTML page or site: the title shown in the gallery, profile, search and link cards (`POST /api/{html|sites}/{ns}/{id}/title`). The new title stays when you republish; the link stays the same. Works when the publication's owner token is in a BrewPage profile: sign in at https://account.brewpage.app, then `bind_token`; otherwise 403 with a hint.
@@ -701,12 +731,14 @@ Every publish response includes an **owner token** -- the only credential that a
701
731
  - Memory: run with `--max-old-space-size=192` and a 512 MiB container limit.
702
732
  - Never reads `BREWPAGE_OWNER_TOKEN`; refuses to start when it is set. Pass `ownerToken` per call.
703
733
  - 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.
734
+ - `GET /metrics` (Prometheus text) only for peers in `BREWPAGE_METRICS_CIDRS`, else 404: `brewpage_mcp_requests_total{method,client}`, `brewpage_mcp_tool_calls_total{tool,client,outcome}` and the gauge `brewpage_mcp_clients_today{client}` (distinct caller addresses with a tool call since 00:00 UTC, kept only as hashes with a random salt drawn each day; resets on restart). `client` is `claude` (an address in `BREWPAGE_SHARED_EGRESS_CIDRS` or a `Claude...` User-Agent), `chatgpt` (an address in a published egress list or an `openai-mcp`/`ChatGPT` User-Agent), else `other`; `outcome` is `ok`, `tool_error`, `error` or `client_closed`. No IPs, arguments or content in any label.
704
735
 
705
736
  | Variable | Default | Description |
706
737
  |----------|---------|-------------|
707
738
  | `BREWPAGE_TRUSTED_PROXY_CIDRS` | required | Comma list of reverse-proxy CIDRs or IPs |
708
739
  | `BREWPAGE_SHARED_EGRESS_CIDRS` | empty | Comma list of platform egress CIDRs (one IP, many users): global limits only, no per-IP caps |
709
740
  | `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 |
741
+ | `BREWPAGE_METRICS_CIDRS` | empty | Comma list of CIDRs or IPs allowed to read `GET /metrics`; empty = `/metrics` answers 404 |
710
742
  | `BREWPAGE_BACKEND_URL` | `http://brewpage-backend-mcp:8080` | API base URL (internal address) |
711
743
  | `BREWPAGE_PUBLIC_ORIGIN` | `https://brewpage.app` | Origin for returned links and forwarded proto/host |
712
744
  | `BREWPAGE_MCP_PORT` | `8787` | Listen port |
@@ -725,6 +757,14 @@ Every publish response includes an **owner token** -- the only credential that a
725
757
 
726
758
  ## Changelog
727
759
 
760
+ ## 1.21.0 -- 2026-11-12
761
+
762
+ - Remote mode only: optional `GET /metrics` with usage counts for the operator (calls per tool, Claude, ChatGPT or other clients, distinct clients per day), served only to the addresses in `BREWPAGE_METRICS_CIDRS`. No addresses, arguments or content are kept or shown. Tools, inputs and stdio are unchanged: thirty-two tools, contract 2.27.0. `User-Agent: brewpage-mcp/1.21.0`.
763
+
764
+ ## 1.20.0 -- 2026-11-12
765
+
766
+ - Two new tools. `make_private` turns a public publication into a private one: new private link, the public link stops working, views and stats stay; one-way. `set_badge` hides or shows the BrewPage badge on a publication of your profile, up to 5 hidden. `set_visibility` reads as list or unlist; inputs unchanged. The server instructions and the tools that send page code say that page code must not hide or alter the badge. Thirty-two tools. Contract 2.27.0. `User-Agent: brewpage-mcp/1.20.0`.
767
+
728
768
  ## 1.19.1 -- 2026-11-12
729
769
 
730
770
  - README only: the remote server `https://brewpage.app/mcp` comes first, with setup steps for Claude and ChatGPT; the local `npx` server is the fallback; the global install section is removed. No tool or input change. Contract 2.22.0. `User-Agent: brewpage-mcp/1.19.1`.
package/dist/http.js CHANGED
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
  import { createMcpHandler, InMemoryTransport, isLegacyRequest, LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/server";
9
9
  import { NodeStreamableHTTPServerTransport, toWebRequest } from "@modelcontextprotocol/node";
10
10
  import { createServer, OWNER_TOKEN_ENV, USER_AGENT } from "./tools.js";
11
+ import { classifyClient, UsageMetrics } from "./metrics.js";
11
12
  const MIB = 1024 * 1024;
12
13
  /** Fits the largest site (25 MiB backend request: 20 MiB of inline text plus JSON escaping).
13
14
  * Two 32 MiB sites + 8x1 MiB + 32 light per round peaked at 373 MiB (heap 192): run with mem_limit 512m. */
@@ -143,6 +144,8 @@ function loadConfig() {
143
144
  /** Platform egress (one IP, many users): global pools only, no per-client caps or download budget. */
144
145
  sharedEgress: parseCidrs("BREWPAGE_SHARED_EGRESS_CIDRS", false),
145
146
  sharedEgressLists: parseListUrls("BREWPAGE_SHARED_EGRESS_LIST_URLS"),
147
+ /** Peers allowed to read GET /metrics (the Prometheus network); empty = /metrics answers 404. */
148
+ metricsPeers: parseCidrs("BREWPAGE_METRICS_CIDRS", false),
146
149
  host: process.env.BREWPAGE_MCP_HOST || "0.0.0.0",
147
150
  port,
148
151
  };
@@ -457,7 +460,10 @@ async function main() {
457
460
  const heavy = new Pool(MAX_HEAVY_IN_FLIGHT, MAX_HEAVY_PER_CLIENT);
458
461
  let draining = false;
459
462
  const listed = new Map();
460
- let sharedEgress = blockList(config.sharedEgress);
463
+ const staticEgress = blockList(config.sharedEgress);
464
+ let listedEgress = blockList([]);
465
+ const metricsPeers = config.metricsPeers.length > 0 ? blockList(config.metricsPeers) : undefined;
466
+ const metrics = new UsageMetrics([...toolNames]);
461
467
  /** Never throws: a failed fetch or parse keeps the last good set and logs a warning. */
462
468
  async function loadEgressList(url) {
463
469
  try {
@@ -469,7 +475,7 @@ async function main() {
469
475
  }
470
476
  const ranges = parseEgressList(await readCapped(res, SHARED_LIST_MAX_BYTES));
471
477
  listed.set(url, ranges);
472
- sharedEgress = blockList([...config.sharedEgress, ...[...listed.values()].flat()]);
478
+ listedEgress = blockList([...listed.values()].flat());
473
479
  log({ event: "shared_egress_list", url, ranges: ranges.length });
474
480
  }
475
481
  catch (error) {
@@ -479,7 +485,7 @@ async function main() {
479
485
  const refreshEgressLists = () => config.sharedEgressLists.forEach((url) => void loadEgressList(url));
480
486
  refreshEgressLists();
481
487
  setInterval(refreshEgressLists, SHARED_LIST_REFRESH_MS).unref();
482
- async function handleMcp(req, res, entry) {
488
+ async function handleMcp(req, res, entry, usage) {
483
489
  if (req.headers.origin !== undefined)
484
490
  return rpcError(res, 403, SERVER_ERROR, "Origin not allowed.");
485
491
  const peer = peerAddress(req);
@@ -502,7 +508,9 @@ async function main() {
502
508
  rpcError(res, 503, SERVER_ERROR, "Busy, retry shortly.", { "Retry-After": "1" });
503
509
  };
504
510
  const known = Number.isFinite(declared);
505
- const shared = sharedEgress.check(client, isIP(client) === 6 ? "ipv6" : "ipv4");
511
+ const family = isIP(client) === 6 ? "ipv6" : "ipv4";
512
+ const egress = staticEgress.check(client, family) ? "static" : listedEgress.check(client, family) ? "listed" : undefined;
513
+ const shared = egress !== undefined;
506
514
  const capKey = shared ? undefined : client;
507
515
  // Registered first: a client that leaves at any point, even while queued, aborts the call.
508
516
  const abort = new AbortController();
@@ -562,6 +570,8 @@ async function main() {
562
570
  const toolName = rpc.method === "tools/call" ? rpc.params?.name : undefined;
563
571
  entry.method = typeof rpc.method === "string" ? rpc.method.slice(0, MAX_LOGGED_METHOD) : null;
564
572
  entry.tool = rpc.method === "tools/call" ? (typeof toolName === "string" && toolNames.has(toolName) ? toolName : "unknown") : null;
573
+ usage.client = classifyClient(egress, (req.headers["user-agent"] ?? "").slice(0, MAX_LOGGED_METHOD));
574
+ usage.address = client;
565
575
  // Stateless: no server-push streams, as the 2025 GET stream (405); a refused auto-open never fails a connect.
566
576
  if (rpc.method === "subscriptions/listen") {
567
577
  const id = message.id;
@@ -634,18 +644,24 @@ async function main() {
634
644
  slot?.give(capKey);
635
645
  }
636
646
  }
637
- function handle(req, res, entry) {
647
+ function handle(req, res, entry, usage) {
638
648
  const path = (req.url ?? "/").split("?")[0];
639
649
  if (path === "/healthz") {
640
650
  res.writeHead(draining ? 503 : 200, { "Content-Type": "application/json" });
641
651
  res.end(JSON.stringify({ status: draining ? "draining" : "ok", version: VERSION }));
642
652
  return Promise.resolve();
643
653
  }
654
+ const peer = peerAddress(req);
655
+ if (path === "/metrics" && req.method === "GET" && isIP(peer) !== 0 && metricsPeers?.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4")) {
656
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
657
+ res.end(metrics.render());
658
+ return Promise.resolve();
659
+ }
644
660
  if (path !== "/mcp") {
645
661
  rpcError(res, 404, SERVER_ERROR, "Not found.");
646
662
  return Promise.resolve();
647
663
  }
648
- return handleMcp(req, res, entry).catch((error) => {
664
+ return handleMcp(req, res, entry, usage).catch((error) => {
649
665
  entry.outcome = "exception";
650
666
  log({ event: "request_failed", message: error instanceof Error ? error.message : String(error) });
651
667
  if (!res.headersSent)
@@ -657,11 +673,12 @@ async function main() {
657
673
  const httpServer = createHttpServer((req, res) => {
658
674
  const started = performance.now();
659
675
  const entry = { http: req.method ?? "", method: null, tool: null, status: 0, outcome: null, ms: 0 };
660
- const logged = !(req.url ?? "").startsWith("/healthz");
676
+ const usage = {};
677
+ const logged = !(req.url ?? "").startsWith("/healthz") && !(req.url ?? "").startsWith("/metrics");
661
678
  const closedEarly = new Promise((resolve) => res.once("close", () => resolve(!res.writableFinished)));
662
679
  let done;
663
680
  try {
664
- done = handle(req, res, entry);
681
+ done = handle(req, res, entry, usage);
665
682
  }
666
683
  catch (error) {
667
684
  entry.outcome = "exception";
@@ -681,6 +698,11 @@ async function main() {
681
698
  entry.outcome ??= res.statusCode < 400 ? "ok" : `http_${res.statusCode}`;
682
699
  entry.ms = Math.round(performance.now() - started);
683
700
  log(entry);
701
+ if (usage.client === undefined || typeof entry.method !== "string")
702
+ return;
703
+ metrics.request(entry.method, usage.client);
704
+ if (typeof entry.tool === "string")
705
+ metrics.toolCall(entry.tool, usage.client, String(entry.outcome), usage.address);
684
706
  });
685
707
  });
686
708
  // Whole request incl. queue wait: 30 s queue + 2 s grace + 32 MiB at the body floor (~131 s) + margin.
@@ -0,0 +1,24 @@
1
+ /** Client class: Claude (Anthropic egress or Claude UA), ChatGPT (OpenAI egress or UA), else other. */
2
+ export type ClientClass = "claude" | "chatgpt" | "other";
3
+ /** Egress source of the caller's IP: static shared CIDRs = Anthropic, published lists = OpenAI (deploy contract). */
4
+ export type Egress = "static" | "listed" | undefined;
5
+ export declare function classifyClient(egress: Egress, userAgent: string): ClientClass;
6
+ /** Bounded outcome label from the request log outcome. */
7
+ export declare function outcomeClass(outcome: string | number | null | undefined): string;
8
+ /** In-memory MCP usage counters in Prometheus text format; labels are closed sets, never IPs or arguments. */
9
+ export declare class UsageMetrics {
10
+ private readonly tools;
11
+ private readonly now;
12
+ private readonly requests;
13
+ private readonly toolCalls;
14
+ private day;
15
+ private salt;
16
+ private readonly daily;
17
+ constructor(tools: readonly string[], now?: () => Date);
18
+ request(method: string, client: ClientClass): void;
19
+ /** One tools/call; `address` only feeds the daily distinct set as a keyed hash. */
20
+ toolCall(tool: string, client: ClientClass, outcome: string, address: string): void;
21
+ render(): string;
22
+ /** New UTC day: forget every hash and draw a new random salt, so days cannot be linked. */
23
+ private roll;
24
+ }
@@ -0,0 +1,84 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ const CLIENTS = ["claude", "chatgpt", "other"];
3
+ const METHODS = ["initialize", "notifications/initialized", "tools/list", "tools/call", "ping", "server/discover", "other"];
4
+ const OUTCOMES = ["ok", "tool_error", "error", "client_closed"];
5
+ /** Distinct-client memory bound (~100 bytes each); past it the day's gauge stops growing. */
6
+ const MAX_DAILY_CLIENTS = 100_000;
7
+ export function classifyClient(egress, userAgent) {
8
+ if (egress === "static" || /^claude/i.test(userAgent))
9
+ return "claude";
10
+ if (egress === "listed" || /^openai-mcp|chatgpt/i.test(userAgent))
11
+ return "chatgpt";
12
+ return "other";
13
+ }
14
+ /** Bounded outcome label from the request log outcome. */
15
+ export function outcomeClass(outcome) {
16
+ return outcome === "ok" || outcome === "tool_error" || outcome === "client_closed" ? outcome : "error";
17
+ }
18
+ /** In-memory MCP usage counters in Prometheus text format; labels are closed sets, never IPs or arguments. */
19
+ export class UsageMetrics {
20
+ tools;
21
+ now;
22
+ requests = new Map();
23
+ toolCalls = new Map();
24
+ day = "";
25
+ salt = Buffer.alloc(0);
26
+ daily = new Map();
27
+ constructor(tools, now = () => new Date()) {
28
+ this.tools = tools;
29
+ this.now = now;
30
+ // Every series born at 0, so increase() sees the first call after a restart.
31
+ for (const client of CLIENTS) {
32
+ for (const method of METHODS)
33
+ this.requests.set(`${method}\t${client}`, 0);
34
+ for (const tool of [...tools, "unknown"])
35
+ for (const outcome of OUTCOMES)
36
+ this.toolCalls.set(`${tool}\t${client}\t${outcome}`, 0);
37
+ }
38
+ }
39
+ request(method, client) {
40
+ const key = `${METHODS.includes(method) ? method : "other"}\t${client}`;
41
+ this.requests.set(key, (this.requests.get(key) ?? 0) + 1);
42
+ }
43
+ /** One tools/call; `address` only feeds the daily distinct set as a keyed hash. */
44
+ toolCall(tool, client, outcome, address) {
45
+ const key = `${this.tools.includes(tool) ? tool : "unknown"}\t${client}\t${outcomeClass(outcome)}`;
46
+ this.toolCalls.set(key, (this.toolCalls.get(key) ?? 0) + 1);
47
+ this.roll();
48
+ const seen = this.daily.get(client);
49
+ if ([...this.daily.values()].reduce((sum, set) => sum + set.size, 0) >= MAX_DAILY_CLIENTS)
50
+ return;
51
+ seen.add(createHmac("sha256", this.salt).update(address).digest("base64").slice(0, 22));
52
+ }
53
+ render() {
54
+ this.roll();
55
+ const lines = [
56
+ "# HELP brewpage_mcp_requests_total Remote MCP JSON-RPC requests by method and client class.",
57
+ "# TYPE brewpage_mcp_requests_total counter",
58
+ ...[...this.requests].map(([key, value]) => {
59
+ const [method, client] = key.split("\t");
60
+ return `brewpage_mcp_requests_total{method="${method}",client="${client}"} ${value}`;
61
+ }),
62
+ "# HELP brewpage_mcp_tool_calls_total Remote MCP tools/call by tool, client class and outcome.",
63
+ "# TYPE brewpage_mcp_tool_calls_total counter",
64
+ ...[...this.toolCalls].map(([key, value]) => {
65
+ const [tool, client, outcome] = key.split("\t");
66
+ return `brewpage_mcp_tool_calls_total{tool="${tool}",client="${client}",outcome="${outcome}"} ${value}`;
67
+ }),
68
+ "# HELP brewpage_mcp_clients_today Distinct client addresses with a tools/call since 00:00 UTC (since start if later).",
69
+ "# TYPE brewpage_mcp_clients_today gauge",
70
+ ...CLIENTS.map((client) => `brewpage_mcp_clients_today{client="${client}"} ${this.daily.get(client).size}`),
71
+ ];
72
+ return `${lines.join("\n")}\n`;
73
+ }
74
+ /** New UTC day: forget every hash and draw a new random salt, so days cannot be linked. */
75
+ roll() {
76
+ const day = this.now().toISOString().slice(0, 10);
77
+ if (day === this.day)
78
+ return;
79
+ this.day = day;
80
+ this.salt = randomBytes(32);
81
+ for (const client of CLIENTS)
82
+ this.daily.set(client, new Set());
83
+ }
84
+ }
package/dist/tools.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  export declare const DEFAULT_BASE_URL = "https://brewpage.app";
3
- export declare const USER_AGENT = "brewpage-mcp/1.19.1";
3
+ export declare const USER_AGENT = "brewpage-mcp/1.21.0";
4
4
  export declare const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
5
5
  /** Server instructions of the handshake: one paragraph per line; the first stands alone within 512 chars. */
6
6
  export declare const SERVER_INSTRUCTIONS: string;
package/dist/tools.js CHANGED
@@ -2,13 +2,14 @@ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  import { PAGE_DATA_CALLS, PAGE_DATA_GUIDE } from "./guides/page-data.js";
4
4
  export const DEFAULT_BASE_URL = "https://brewpage.app";
5
- export const USER_AGENT = "brewpage-mcp/1.19.1";
5
+ export const USER_AGENT = "brewpage-mcp/1.21.0";
6
6
  export const OWNER_TOKEN_ENV = "BREWPAGE_OWNER_TOKEN";
7
7
  /** Server instructions of the handshake: one paragraph per line; the first stands alone within 512 chars. */
8
8
  export const SERVER_INSTRUCTIONS = [
9
9
  "BrewPage publishes HTML, Markdown, sites, files, JSON and KV and returns a share link plus an owner token. Keep the owner token private and send it as ownerToken on every later call for that publication; it cannot be recovered. A page that must save what its visitors enter (form answers, guestbook, votes, scores, game progress) needs Page data: read get_guide topic page-data BEFORE writing the page, then publish with the state argument.",
10
10
  "After publishing, give the user the page link and the management link from the result. Say that the management link is private: whoever has it can edit or delete the page, and its Data tab shows what visitors saved.",
11
11
  "KV and JSON tools hold your own data. Page data holds what a page's visitors save: get_page_data reads it, set_page_data writes author entries, configure_page_data changes who can read a list, delete_page_data moderates. In a later chat these four take the management link as manageUrl.",
12
+ "HTML pages and sites show a small BrewPage badge; page code must not hide or alter it. A profile can turn it off on up to 5 pages (set_badge).",
12
13
  ].join("\n");
13
14
  const READ_ONLY = { readOnlyHint: true };
14
15
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
@@ -27,6 +28,10 @@ const NO_PUBLIC_CREDENTIALS = "BrewPage tokens and manage links must never be in
27
28
  const SECRETS = "Secrets allowed; likely ones are flagged in notices. Private namespace + password suit secrets. " +
28
29
  NO_PUBLIC_CREDENTIALS;
29
30
  const MANAGE_URL_TEXT = "Returns manageUrl, a private management link (grants control; not for public sharing).";
31
+ /** Badge sentence of the four tools that publish or replace page code. */
32
+ const BADGE_TEXT = "Page code must not hide or alter the BrewPage badge; a profile can turn the badge off for a page.";
33
+ const BADGE_NEEDS_PROFILE_CODE = "badge-hide-needs-profile";
34
+ const BADGE_NEEDS_PROFILE_NEXT = "Next: sign in at https://account.brewpage.app, add this publication's owner token with bind_token, then retry.";
30
35
  const OLD_READ_ONLY_CODE = "OLD_PUBLICATION_READ_ONLY";
31
36
  const WRONG_TYPE_CODE = "WRONG_RESOURCE_TYPE";
32
37
  /** 403 message of a rename on a publication no profile owns. */
@@ -466,7 +471,7 @@ function ownerLinkLines(data) {
466
471
  lines.push(``, `MANAGE LINK (private): ${data.manageUrl}`, `Give it to the user with the link: anyone who has it can edit or delete this publication. Never post it publicly.`);
467
472
  }
468
473
  if (data.listed === true && !hasCredentialNotice(data))
469
- lines.push(``, `VISIBILITY: public, listed. Hide: set_visibility listed=false`);
474
+ lines.push(``, `VISIBILITY: public, listed. Unlist: set_visibility listed=false`);
470
475
  return lines;
471
476
  }
472
477
  /** Optional delivery fields on HTML and hosting responses: why the mode was chosen, flagged or not applied. */
@@ -513,7 +518,7 @@ export function createServer(options = {}) {
513
518
  const apiFetch = options.fetch ?? fetch;
514
519
  const server = new McpServer({
515
520
  name: "brewpage-mcp",
516
- version: "1.19.1",
521
+ version: "1.21.0",
517
522
  }, { instructions: SERVER_INSTRUCTIONS });
518
523
  /** Caller argument first, then the configured default; `fromEnv` marks the configured one. */
519
524
  function ownerTokenFor(argument) {
@@ -595,7 +600,7 @@ export function createServer(options = {}) {
595
600
  "A `public` page without deliveryMode may get its own subdomain if it needs browser features a BrewPage link lacks; " +
596
601
  "the result then has deliveryModeReason and a delivery notice. Markdown raw HTML is allowlisted: scripts, styles, event handlers, forms, embeds removed. " +
597
602
  "Optional `domain` connects a domain the user owns to an HTML-format page. " +
598
- MANAGE_URL_TEXT + " " + SECRETS + " " + PUBLISH_STATE_TEXT,
603
+ MANAGE_URL_TEXT + " " + SECRETS + " " + BADGE_TEXT + " " + PUBLISH_STATE_TEXT,
599
604
  inputSchema: {
600
605
  content: z.string().describe("HTML, Markdown, code or JSON to publish"),
601
606
  deliveryMode: DELIVERY_MODE_SCHEMA,
@@ -858,7 +863,7 @@ export function createServer(options = {}) {
858
863
  ...toolMeta("Publish site", ADDITIVE),
859
864
  description: "Publish an HTML site to BrewPage. " + PUBLIC_HINT + "Single-page: `entryContent`. Multi-page: `files` (`{path, content}` array). Optional `entry` overrides `index.html`. Returns its primary share link and owner token. " +
860
865
  "Static only: HTML, CSS, JS (incl. built React/Vue bundles) run in the browser; server code (PHP etc.) never runs. " + SITE_LIMITS_TEXT +
861
- " Optional `domain` connects a domain the user owns. " + MANAGE_URL_TEXT + " " + SECRETS + " " + PUBLISH_STATE_TEXT,
866
+ " Optional `domain` connects a domain the user owns. " + MANAGE_URL_TEXT + " " + SECRETS + " " + BADGE_TEXT + " " + PUBLISH_STATE_TEXT,
862
867
  inputSchema: {
863
868
  deliveryMode: DELIVERY_MODE_SCHEMA,
864
869
  entryContent: z
@@ -977,7 +982,7 @@ export function createServer(options = {}) {
977
982
  });
978
983
  server.registerTool("republish_site", {
979
984
  ...toolMeta("Republish site", DESTRUCTIVE),
980
- 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 + " " + NO_PUBLIC_CREDENTIALS,
985
+ 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 + " " + NO_PUBLIC_CREDENTIALS + " " + BADGE_TEXT,
981
986
  inputSchema: {
982
987
  namespace: z.string().describe("Site namespace"),
983
988
  id: z.string().describe("Site ID"),
@@ -1076,7 +1081,7 @@ export function createServer(options = {}) {
1076
1081
  ...toolMeta("Update page", DESTRUCTIVE),
1077
1082
  description: "Update an existing BrewPage HTML, Markdown, code or JSON page. Requires owner token. Returns the updated page metadata. " +
1078
1083
  "Format omitted keeps the stored format, except a whole HTML document makes a Markdown page an HTML page. " +
1079
- "The link and delivery mode never change on update; an advisory deliveryModeReason and delivery notice are included when the new content uses browser features that may not work fully on a BrewPage link." + OLD_READ_ONLY_TEXT + " " + NO_PUBLIC_CREDENTIALS,
1084
+ "The link and delivery mode never change on update; an advisory deliveryModeReason and delivery notice are included when the new content uses browser features that may not work fully on a BrewPage link." + OLD_READ_ONLY_TEXT + " " + NO_PUBLIC_CREDENTIALS + " " + BADGE_TEXT,
1080
1085
  inputSchema: {
1081
1086
  namespace: z.string().describe("Page namespace"),
1082
1087
  id: z.string().describe("Page ID"),
@@ -1475,12 +1480,12 @@ export function createServer(options = {}) {
1475
1480
  });
1476
1481
  server.registerTool("set_visibility", {
1477
1482
  ...toolMeta("Set gallery visibility", ADDITIVE),
1478
- description: "Hide or show a public publication in the gallery and sitemap. Link unchanged.",
1483
+ description: "List or unlist a public publication in the gallery and sitemap. Link unchanged.",
1479
1484
  inputSchema: {
1480
1485
  type: z.enum(["html", "site", "file", "json", "kv"]).describe("Publication type; only public html, site and file can be listed"),
1481
1486
  namespace: z.string().describe("Publication namespace"),
1482
1487
  id: z.string().describe("Publication ID"),
1483
- listed: z.boolean().describe("false = hide, true = show"),
1488
+ listed: z.boolean().describe("true = listed, false = unlisted"),
1484
1489
  ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
1485
1490
  },
1486
1491
  }, async ({ type, namespace, id, listed, ownerToken }) => {
@@ -1494,6 +1499,64 @@ export function createServer(options = {}) {
1494
1499
  const result = data;
1495
1500
  return textResult([...credentialAlertLines(result), `LISTED: ${result.listed} · link unchanged`, ...noticeLines(result)].join("\n"));
1496
1501
  });
1502
+ server.registerTool("make_private", {
1503
+ ...toolMeta("Make private", DESTRUCTIVE),
1504
+ description: "Make a public publication private: it gets a new private link and the public link stops working; a custom domain on it is detached. One-way. " +
1505
+ "Returns the new link, namespace and id, plus a new management link when the publication had one; the owner token stays the same.",
1506
+ inputSchema: {
1507
+ type: z.enum(["html", "site", "file", "json", "kv"]).describe("Publication type"),
1508
+ namespace: z.string().describe("Publication namespace; only `public` publications can be made private"),
1509
+ id: z.string().describe("Publication ID"),
1510
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
1511
+ },
1512
+ }, async ({ type, namespace, id, ownerToken }) => {
1513
+ const auth = ownerTokenFor(ownerToken);
1514
+ if (!auth.token)
1515
+ return textResult(NO_OWNER_TOKEN, true);
1516
+ const path = apiPath(TYPE_SEGMENTS[type], namespace, id, "private");
1517
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("POST", path, undefined, { "X-Owner-Token": auth.token });
1518
+ if (!ok)
1519
+ return ownerFailure("make private", status, data, auth.fromEnv, answerHeaders);
1520
+ const result = data;
1521
+ return textResult([
1522
+ ...credentialAlertLines(result),
1523
+ `MADE PRIVATE · the public link no longer works`,
1524
+ ``,
1525
+ `New link: ${result.link || "N/A"}`,
1526
+ `Namespace: ${result.namespace || "N/A"}`,
1527
+ `ID: ${result.id || "N/A"}`,
1528
+ `Give the user the new link. Later calls take this namespace and ID with the same owner token.`,
1529
+ ...noticeLines(result),
1530
+ ...ownerLinkLines(result),
1531
+ ].join("\n"));
1532
+ });
1533
+ server.registerTool("set_badge", {
1534
+ ...toolMeta("Hide or show badge", ADDITIVE),
1535
+ description: "Hide or show the BrewPage badge on a publication. Needs an owner token that is in a BrewPage profile (free sign-in at https://account.brewpage.app); up to 5 hidden per profile.",
1536
+ inputSchema: {
1537
+ type: z.enum(["html", "site", "file", "json", "kv"]).describe("Publication type"),
1538
+ namespace: z.string().describe("Publication namespace"),
1539
+ id: z.string().describe("Publication ID"),
1540
+ hidden: z.boolean().optional().describe("true = hide the badge, false = show it. Omit: report the current state."),
1541
+ ownerToken: z.string().optional().describe(OWNER_TOKEN_DESCRIPTION),
1542
+ },
1543
+ }, async ({ type, namespace, id, hidden, ownerToken }) => {
1544
+ const auth = ownerTokenFor(ownerToken);
1545
+ if (!auth.token)
1546
+ return textResult(NO_OWNER_TOKEN, true);
1547
+ const path = apiPath(TYPE_SEGMENTS[type], namespace, id, "badge");
1548
+ const { ok, status, data, headers: answerHeaders } = await apiRequest("POST", path, hidden === undefined ? {} : { hidden }, { "X-Owner-Token": auth.token });
1549
+ if (!ok)
1550
+ return ownerFailure("set badge", status, data, auth.fromEnv, answerHeaders);
1551
+ const result = data;
1552
+ const used = typeof result.used === "number" && result.used > 0 ? ` · ${result.used} of ${result.limit} hidden in the profile` : "";
1553
+ return textResult([
1554
+ ...credentialAlertLines(result),
1555
+ `BADGE: ${result.hidden === true ? "hidden" : "shown"}${used} · link unchanged`,
1556
+ ...noticeLines(result),
1557
+ ...(notices(result).some((n) => n.code === BADGE_NEEDS_PROFILE_CODE) ? [BADGE_NEEDS_PROFILE_NEXT] : []),
1558
+ ].join("\n"));
1559
+ });
1497
1560
  server.registerTool("set_title", {
1498
1561
  ...toolMeta("Rename page or site", ADDITIVE),
1499
1562
  description: "Rename an HTML page or site: the title shown in the gallery, profile, search and link cards. " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brewpage-mcp",
3
- "version": "1.19.1",
3
+ "version": "1.21.0",
4
4
  "mcpName": "io.github.kochetkov-ma/brewpage-mcp",
5
5
  "description": "MCP server for BrewPage — publish and manage HTML, sites, KV, JSON and files from AI assistants",
6
6
  "type": "module",